Skip to main content

core_api/
db.rs

1use crate::ingest::{IngestOptions, IngestReport};
2use crate::roles::{PropPredicate, 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, Expr, MatchDeleteNodeStmt, NodePat, Operand, Params, Pattern, PlanOp, Query, RetItem,
10    RetVal, WriteStatement,
11};
12use core_query::{eval_cmp, eval_filter, expand, neighborhood, Dir, Filter, GraphView, ResultSet};
13use core_rules::{
14    decode_rule_def, ef_max, evaluate, BuildProgress, EngineEdgeDelta, GraphMut, NodeView,
15    Predicate, 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, HashSet};
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            if let Some(v) = db.get_prop(key, field) {
808                return Ok(Some(v));
809            }
810            // Same stored-wins identity fallback as the read path:
811            // n.key / n.id / n.label, not only get_prop.
812            Ok(match field.as_str() {
813                "key" | "id" => Some(Value::Str(key.clone())),
814                "label" => db
815                    .node_ref(key)
816                    .map(|n| Value::Str(n.label().to_owned())),
817                _ => None,
818            })
819        }
820        Operand::FuncCall { name, args } => {
821            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
822        }
823        Operand::BinArith { op, left, right } => {
824            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
825            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
826            eval_set_return_arith(op, lv, rv)
827        }
828        Operand::Case { branches, default } => {
829            for (cond, value) in branches {
830                if eval_set_return_expr(db, match_rs, row, rel_vars, cond, params, 0)? {
831                    return eval_set_return_operand(db, match_rs, row, rel_vars, value, params);
832                }
833            }
834            match default {
835                Some(d) => eval_set_return_operand(db, match_rs, row, rel_vars, d, params),
836                None => Ok(None),
837            }
838        }
839        Operand::Index { base, index } => {
840            let base_val = eval_set_return_operand(db, match_rs, row, rel_vars, base, params)?;
841            let idx_val = eval_set_return_operand(db, match_rs, row, rel_vars, index, params)?;
842            Ok(core_query::value_ops::index_list(base_val, idx_val))
843        }
844    }
845}
846
847fn eval_set_return_expr<F: Fs>(
848    db: &GraphDb<F>,
849    match_rs: &ResultSet,
850    row: usize,
851    rel_vars: &[String],
852    expr: &Expr,
853    params: &BTreeMap<String, Value>,
854    depth: u32,
855) -> Result<bool> {
856    if depth > 256 {
857        return Err(GraphError::QueryError {
858            detail: "expression nesting too deep".into(),
859        });
860    }
861    match expr {
862        Expr::And(lhs, rhs) => {
863            let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
864            let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
865            Ok(l && r)
866        }
867        Expr::Or(lhs, rhs) => {
868            let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
869            let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
870            Ok(l || r)
871        }
872        Expr::Not(inner) => Ok(!eval_set_return_expr(
873            db,
874            match_rs,
875            row,
876            rel_vars,
877            inner,
878            params,
879            depth + 1,
880        )?),
881        Expr::Cmp { lhs, op, rhs } => {
882            let l = eval_set_return_operand(db, match_rs, row, rel_vars, lhs, params)?;
883            let r = eval_set_return_operand(db, match_rs, row, rel_vars, rhs, params)?;
884            match (l, r) {
885                (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
886                _ => Ok(false),
887            }
888        }
889        Expr::Truthy(op) => {
890            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
891            Ok(match val {
892                None => false,
893                Some(Value::Bool(b)) => b,
894                Some(Value::Int(n)) => n != 0,
895                Some(Value::Float(f)) => f != 0.0,
896                Some(Value::Str(s)) => !s.is_empty(),
897                Some(Value::List(v)) => !v.is_empty(),
898                Some(Value::Map(m)) => !m.is_empty(),
899            })
900        }
901        Expr::IsNull(op) => {
902            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
903            Ok(val.is_none())
904        }
905        Expr::IsNotNull(op) => {
906            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
907            Ok(val.is_some())
908        }
909        Expr::In { expr, list } => {
910            let Some(needle) = eval_set_return_operand(db, match_rs, row, rel_vars, expr, params)?
911            else {
912                return Ok(false);
913            };
914            for item_op in list {
915                match eval_set_return_operand(db, match_rs, row, rel_vars, item_op, params)? {
916                    None => {}
917                    Some(Value::List(items)) => {
918                        for item in items {
919                            if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) {
920                                return Ok(true);
921                            }
922                        }
923                    }
924                    Some(item) if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) => {
925                        return Ok(true);
926                    }
927                    Some(_) => {}
928                }
929            }
930            Ok(false)
931        }
932    }
933}
934
935fn eval_set_return_arith(
936    op: &ArithOp,
937    lv: Option<Value>,
938    rv: Option<Value>,
939) -> Result<Option<Value>> {
940    match (lv, rv) {
941        (None, _) | (_, None) => Ok(None),
942        (Some(Value::Int(a)), Some(Value::Int(b))) => {
943            let result = match op {
944                ArithOp::Sub => a.saturating_sub(b),
945                ArithOp::Mul => a.saturating_mul(b),
946                ArithOp::Add => a.saturating_add(b),
947                ArithOp::Div => {
948                    if b == 0 {
949                        return Err(GraphError::QueryError {
950                            detail: "division by zero".into(),
951                        });
952                    }
953                    a.checked_div(b).unwrap_or(i64::MAX)
954                }
955            };
956            Ok(Some(Value::Int(result)))
957        }
958        (Some(lv), Some(rv)) => {
959            let a = match &lv {
960                Value::Float(f) => *f,
961                Value::Int(i) => *i as f64,
962                _ => {
963                    return Err(GraphError::QueryError {
964                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
965                    })
966                }
967            };
968            let b = match &rv {
969                Value::Float(f) => *f,
970                Value::Int(i) => *i as f64,
971                _ => {
972                    return Err(GraphError::QueryError {
973                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
974                    })
975                }
976            };
977            let result = match op {
978                ArithOp::Sub => a - b,
979                ArithOp::Mul => a * b,
980                ArithOp::Add => a + b,
981                ArithOp::Div => {
982                    if b == 0.0 {
983                        return Err(GraphError::QueryError {
984                            detail: "division by zero".into(),
985                        });
986                    }
987                    a / b
988                }
989            };
990            Ok(Some(Value::Float(result)))
991        }
992    }
993}
994
995fn eval_set_return_func<F: Fs>(
996    db: &GraphDb<F>,
997    match_rs: &ResultSet,
998    row: usize,
999    rel_vars: &[String],
1000    name: &str,
1001    args: &[Operand],
1002    params: &BTreeMap<String, Value>,
1003) -> Result<Option<Value>> {
1004    let norm = name.to_ascii_lowercase();
1005    if norm == "type" {
1006        if args.len() != 1 {
1007            return Err(GraphError::QueryError {
1008                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
1009            });
1010        }
1011        let Operand::Var(rel) = &args[0] else {
1012            return Err(GraphError::QueryError {
1013                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
1014            });
1015        };
1016        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
1017    }
1018    if norm == "key" || norm == "id" {
1019        let fname = if norm == "id" { "id" } else { "key" };
1020        if args.len() != 1 {
1021            return Err(GraphError::QueryError {
1022                detail: format!("{fname}() requires exactly 1 argument, got {}", args.len()),
1023            });
1024        }
1025        let Operand::Var(var) = &args[0] else {
1026            return Err(GraphError::QueryError {
1027                detail: format!("{fname}() argument must be a node variable (e.g. {fname}(n))"),
1028            });
1029        };
1030        if rel_vars.iter().any(|r| r == var) {
1031            return Err(GraphError::QueryError {
1032                detail: format!("{fname}() argument `{var}` is a relationship, not a node"),
1033            });
1034        }
1035        // MATCH rows bind node variables to their key string, so the column
1036        // value *is* the key. `id()` aliases `key()`.
1037        return Ok(match_rs.get(row, var).cloned());
1038    }
1039    let mut vals = Vec::with_capacity(args.len());
1040    for arg in args {
1041        vals.push(eval_set_return_operand(
1042            db, match_rs, row, rel_vars, arg, params,
1043        )?);
1044    }
1045    match norm.as_str() {
1046        "tolower" => {
1047            if vals.len() != 1 {
1048                return Err(GraphError::QueryError {
1049                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
1050                });
1051            }
1052            Ok(vals[0].clone().map(|val| match val {
1053                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1054                other => other,
1055            }))
1056        }
1057        "toupper" => {
1058            if vals.len() != 1 {
1059                return Err(GraphError::QueryError {
1060                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
1061                });
1062            }
1063            Ok(vals[0].clone().map(|val| match val {
1064                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1065                other => other,
1066            }))
1067        }
1068        "size" => match vals.first().cloned().flatten() {
1069            None => Ok(None),
1070            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1071            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1072            Some(_) => Ok(None),
1073        },
1074        "coalesce" => Ok(vals.into_iter().flatten().next()),
1075        "abs" => match vals.first().cloned().flatten() {
1076            None => Ok(None),
1077            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
1078            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1079            Some(_) => Ok(None),
1080        },
1081        "round" => match vals.first().cloned().flatten() {
1082            None => Ok(None),
1083            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1084            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
1085            Some(_) => Ok(None),
1086        },
1087        "decay" => {
1088            if vals.len() != 3 {
1089                return Err(GraphError::QueryError {
1090                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
1091                });
1092            }
1093            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
1094                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
1095                (Some(b), Some(a), Some(h)) => {
1096                    let numeric = |v: Value| -> Result<f64> {
1097                        match v {
1098                            Value::Int(n) => Ok(n as f64),
1099                            Value::Float(f) => Ok(f),
1100                            other => Err(GraphError::QueryError {
1101                                detail: format!(
1102                                    "decay() requires numeric arguments, got {other:?}"
1103                                ),
1104                            }),
1105                        }
1106                    };
1107                    let b = numeric(b)?;
1108                    let a = numeric(a)?;
1109                    let h = numeric(h)?;
1110                    if h <= 0.0 {
1111                        return Err(GraphError::QueryError {
1112                            detail: "decay() requires halflife > 0".into(),
1113                        });
1114                    }
1115                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1116                }
1117            }
1118        }
1119        _ => Err(GraphError::QueryError {
1120            detail: format!(
1121                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key, id"
1122            ),
1123        }),
1124    }
1125}
1126
1127fn eval_set_return_item<F: Fs>(
1128    db: &GraphDb<F>,
1129    match_rs: &ResultSet,
1130    row: usize,
1131    rel_vars: &[String],
1132    item: &RetItem,
1133    params: &BTreeMap<String, Value>,
1134) -> Result<Option<Value>> {
1135    match &item.value {
1136        RetVal::Var(v) => eval_set_return_operand(
1137            db,
1138            match_rs,
1139            row,
1140            rel_vars,
1141            &Operand::Var(v.clone()),
1142            params,
1143        ),
1144        RetVal::Prop { var, field } => eval_set_return_operand(
1145            db,
1146            match_rs,
1147            row,
1148            rel_vars,
1149            &Operand::Prop {
1150                var: var.clone(),
1151                field: field.clone(),
1152            },
1153            params,
1154        ),
1155        RetVal::FuncCall { name, args } => {
1156            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1157        }
1158        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1159        RetVal::Agg { .. } => Err(GraphError::QueryError {
1160            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1161        }),
1162    }
1163}
1164
1165/// Project user RETURN from original MATCH rows after SET. No rematch.
1166fn project_set_return_rows<F: Fs>(
1167    db: &GraphDb<F>,
1168    rel_vars: &[String],
1169    match_rs: &ResultSet,
1170    returns: &[RetItem],
1171    params: &BTreeMap<String, Value>,
1172) -> Result<ResultSet> {
1173    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1174    let mut out = ResultSet::new(columns);
1175    for row in 0..match_rs.len() {
1176        let mut cells = Vec::with_capacity(returns.len());
1177        for item in returns {
1178            cells.push(eval_set_return_item(
1179                db, match_rs, row, rel_vars, item, params,
1180            )?);
1181        }
1182        out.push_row(cells);
1183    }
1184    Ok(out)
1185}
1186
1187/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1188/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1189/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1190/// Returns `None` for non-list values or lists with non-numeric elements.
1191/// Extra candidates pulled from an approximate index before re-scoring, over and
1192/// above the `k` asked for.
1193///
1194/// The index orders candidates by `f32` distances, which agree with the exact
1195/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1196/// candidates inside a band that narrow — it cannot move a hit past one that is
1197/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1198/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1199/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1200/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1201/// then the members are interchangeable anyway. `min` is applied to the exact
1202/// score, never to the index's, so a hit sitting on the threshold is decided
1203/// exactly.
1204const VECTOR_RESCORE_MARGIN: usize = 16;
1205
1206/// Cosine similarity between an already-unit query and node `id`'s `field`
1207/// vector, read from the **`f64`** properties. `None` when the node has no
1208/// numeric-list vector there, or its norm is zero.
1209///
1210/// The single definition of the score this API reports. Both the brute-force
1211/// scan and the re-scoring step that follows an index lookup go through it, so
1212/// the two paths cannot disagree — which is the property
1213/// `index_and_brute_force_agree_on_scores` pins.
1214fn exact_vector_similarity(
1215    view: &GraphView<'_>,
1216    id: u32,
1217    field: &str,
1218    q_unit: &[f64],
1219) -> Option<f64> {
1220    let v = view.prop(id, field)?;
1221    let xs = value_as_float_list(&v.into_value())?;
1222    let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1223    if v_norm == 0.0 {
1224        return None;
1225    }
1226    Some(
1227        q_unit
1228            .iter()
1229            .zip(xs.iter())
1230            .map(|(a, b)| a * (b / v_norm))
1231            .sum(),
1232    )
1233}
1234
1235fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1236    match v {
1237        Value::List(items) => items
1238            .iter()
1239            .map(|item| match item {
1240                Value::Float(f) => Some(*f),
1241                Value::Int(i) => Some(*i as f64),
1242                _ => None,
1243            })
1244            .collect(),
1245        _ => None,
1246    }
1247}
1248
1249fn make_graph_mut<'a>(
1250    ids: &'a IdMap,
1251    syms: &'a mut Interner,
1252    labels: &'a [u32],
1253    props: core_storage::v8::seam::ColumnsView<'a>,
1254    topo: &'a mut Topology,
1255    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1256    edge_props: &'a mut EdgeProps,
1257) -> GraphMut<'a> {
1258    GraphMut {
1259        ids,
1260        syms,
1261        labels,
1262        props,
1263        topo,
1264        base_topo: base_csr(base),
1265        edge_props,
1266    }
1267}
1268
1269/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1270///
1271/// A store opened from a snapshot keeps its edges in the mapping and its
1272/// overlay empty, so a rule that reads the graph's shape has to see both.
1273fn base_csr(
1274    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1275) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1276    base.as_ref().map(|b| {
1277        b.topology()
1278            .expect("base topology section bounds validated at open")
1279    })
1280}
1281
1282/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1283///
1284/// Takes explicit field references rather than `&self` so the caller can hold
1285/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1286fn build_props_view<'a>(
1287    props: &'a ColumnStore,
1288    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1289) -> core_storage::v8::seam::ColumnsView<'a> {
1290    match base {
1291        None => core_storage::v8::seam::ColumnsView::owned(props),
1292        Some(b) => {
1293            let archived = b
1294                .columns()
1295                .expect("base columns section bounds validated at open");
1296            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1297                .with_shared_strings(base_string_table(b))
1298        }
1299    }
1300}
1301
1302/// The base columns section paired with the string table that resolves its
1303/// string ids — what `ViewStore` needs to read a neighbour's string property
1304/// out of a V9 snapshot.
1305fn base_columns(
1306    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1307) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1308    base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1309        cols: b
1310            .columns()
1311            .expect("base columns section bounds validated at open"),
1312        strings: base_string_table(b),
1313    })
1314}
1315
1316/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1317///
1318/// Every `ColumnsView` built over a base must carry it: without it a V9
1319/// snapshot's string columns, whose own tables are empty, read back as absent.
1320fn base_string_table(
1321    base: &core_storage::v8::MappedBase,
1322) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1323    base.string_table()
1324        .transpose()
1325        .expect("base strings section bounds validated at open")
1326}
1327
1328fn build_topo_view<'a>(
1329    overlay: &'a Topology,
1330    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1331) -> core_storage::v8::seam::TopologyView<'a> {
1332    match base {
1333        None => core_storage::v8::seam::TopologyView::owned(overlay),
1334        Some(b) => {
1335            let archived_csr = b
1336                .topology()
1337                .expect("base topology section bounds validated at open");
1338            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1339        }
1340    }
1341}
1342
1343/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1344///
1345/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1346/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1347/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1348/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1349/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1350#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1351pub enum FsyncPolicy {
1352    /// Every WAL commit calls `fs.sync` (today's behavior).
1353    #[default]
1354    Strict,
1355    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1356    /// this policy is set on the database.
1357    Batched,
1358    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1359    Relaxed,
1360}
1361
1362/// A precondition for a compare-and-set batch write.
1363///
1364/// All preconditions in a [`GraphDb::write_batch_cas`] or
1365/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1366/// any operation in the batch is applied.  If any precondition fails, the
1367/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1368/// is written.
1369///
1370/// # Touch definition
1371///
1372/// A node's last-change commit (`last_changed`) is updated when any of the
1373/// following state-changing WAL records touch it:
1374///
1375/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1376/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1377/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1378///   endpoints (an edge change touches both sides).
1379/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1380///   for deleted keys so the pre-deletion entry is never observed.
1381///
1382/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1383/// state no-ops.  The underlying mutation that triggered rule firing already
1384/// updated the relevant nodes' last-change entries.  Rule-management records
1385/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1386/// do not touch any node's last-change.
1387#[derive(Debug, Clone, PartialEq, Eq)]
1388pub enum Precondition {
1389    /// The node's last-change commit must equal `expected`.
1390    ///
1391    /// Fails with [`GraphError::CasConflict`] when:
1392    /// - The node does not exist (`last_changed` returns `None`), or
1393    /// - The recorded commit seq does not match `expected`.
1394    NodeUnchangedSince { key: String, expected: u64 },
1395    /// The node must not exist (not inserted, or already deleted).
1396    ///
1397    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1398    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1399    NodeAbsent { key: String },
1400}
1401
1402pub struct GraphDb<F: Fs> {
1403    fs: F,
1404    ids: IdMap,
1405    syms: Interner,
1406    topo: Topology,
1407    props: ColumnStore,
1408    labels: Vec<u32>, // node id -> label symbol
1409    /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1410    /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1411    ///
1412    /// A private table rather than the shared [`Interner`]: interning
1413    /// `"default"` at open would add a symbol to the store's symbol table and
1414    /// change the bytes of the next snapshot of a store that has no namespaces
1415    /// at all.
1416    ns_names: Vec<String>,
1417    /// Namespace index per dense node id, into [`Self::ns_names`];
1418    /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1419    ///
1420    /// Derived: built by one pass over the `ns` column at open (which reads
1421    /// nothing when the column does not exist) and maintained at every node
1422    /// insert. Never written to a snapshot or the WAL, because the property it
1423    /// mirrors already is. A namespace cannot change, so no other record shape
1424    /// can move a node between namespaces.
1425    node_ns: Vec<u32>,
1426    edge_props: EdgeProps,
1427    engine: RuleEngine,
1428    view_store: ViewStore,
1429    /// Incremental inverted index for full-text-lite search.
1430    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1431    fulltext: FulltextIndex,
1432    /// Opt-in equality index over scalar node properties.
1433    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1434    /// open end (mirrors `fulltext`).
1435    prop_index: PropertyIndex,
1436    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1437    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1438    fsync: FsyncPolicy,
1439    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1440    /// call increments this once; all events emitted from that call share the same
1441    /// `commit_seq` value.
1442    commit_seq: u64,
1443    /// RBAC role definitions loaded from `roles.json` at open.
1444    ///
1445    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1446    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1447    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1448    roles: Option<Vec<RoleDef>>,
1449    /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1450    /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1451    ///
1452    /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1453    /// taken from this handle. Replaced (not cleared) whenever the role
1454    /// definitions change or the store is reloaded, which `commit_seq` does not
1455    /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1456    role_masks: Arc<crate::mask::RoleMaskCache>,
1457    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1458    /// distribute_events call.
1459    subscriptions: Vec<SubEntry>,
1460    /// Live query subscriptions. Re-executed on every commit when non-empty.
1461    /// Dead `Weak` entries are pruned inside `distribute_events`.
1462    query_subscriptions: Vec<QuerySubEntry>,
1463    /// Queue capacity for new subscriptions created by this db.  Default is
1464    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1465    /// to test Lagged behaviour with small queues.
1466    sub_capacity: usize,
1467    /// True for as-of instances opened via [`GraphDb::open_at`].
1468    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1469    /// when this flag is set.
1470    read_only: bool,
1471    /// Total WAL commit count at the time [`open_at`] was called.
1472    /// 0 for normal (non-as-of) instances.
1473    total_wal_commits: u64,
1474    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1475    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1476    /// reads go through a merged `TopologyView`.  `self.props` is always
1477    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1478    base: Option<Arc<core_storage::v8::MappedBase>>,
1479    // ── MVCC epoch reader state ───────────────────────────────────────────────
1480    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1481    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1482    /// `None` only between struct creation and the first fold.
1483    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1484    /// Per-commit deltas accumulated since the last fold.
1485    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1486    /// How many commits have occurred since the last fold.
1487    commits_since_fold: usize,
1488    /// When true, `log_then_apply_with` buffers event notifications instead of
1489    /// firing them immediately.  Used by the group-commit drain thread to defer
1490    /// events until after the group fsync (R2: durability before notification).
1491    /// Cleared to false once the drain thread flushes or discards the buffer.
1492    defer_events: bool,
1493    /// Buffered events accumulated while `defer_events` is true.
1494    deferred_events: Vec<DeferredEvent>,
1495    /// Set to true by the group-commit drain thread when a group fsync fails
1496    /// after WAL truncation.  All subsequent mutation attempts return an IO
1497    /// error until the database is reopened.
1498    degraded: bool,
1499    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1500    /// HNSW, and IVF sections from the mmap base into the engine's retained
1501    /// fields.  `false` on all opens until first use; always `true` for non-V8
1502    /// opens (base is None, fast-path sets flag immediately).
1503    v8_sections_loaded: std::sync::atomic::AtomicBool,
1504    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1505    v8_sections_mutex: std::sync::Mutex<()>,
1506    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1507    /// the node was last modified by commit `seq`.
1508    ///
1509    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1510    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1511    /// nodes return `None` from `last_changed` until they are next mutated.
1512    ///
1513    /// See [`Precondition`] for the full touch definition.
1514    last_change: HashMap<u32, u64>,
1515    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1516    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1517    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1518    wal_archive_retention: Option<u32>,
1519    /// Global frame index of the first commit that is still reachable through
1520    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1521    /// Default 0 = all history reachable.
1522    wal_horizon_floor: u64,
1523    /// True when the surviving archive chain forms a continuous WAL history
1524    /// starting from the store's first commit (the genesis chain).
1525    ///
1526    /// `open_at` may replay archive-resident commits from empty state only when
1527    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1528    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1529    ///     already exist (breaks the chain for subsequent archives), or
1530    ///   - any archive is pruned (floor advances past zero).
1531    ///
1532    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1533    archive_genesis_chain: bool,
1534    /// Transient write-authz context set by `write_batch_authz` /
1535    /// `query_write_authz` for the duration of ONE mutation call.
1536    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1537    pending_write_authz: Option<WriteAuthz>,
1538    /// Slow-query threshold in milliseconds.  0 = disabled.
1539    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1540    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1541    /// — env vars are process-global and race parallel test threads).
1542    slow_query_threshold_ms: u64,
1543    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1544    /// can record entries without requiring `&mut self`).
1545    slow_queries: std::sync::Mutex<SlowQueryLog>,
1546    /// Instant at which the database was opened (used by `/metrics` uptime).
1547    started_at: std::time::Instant,
1548    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1549    /// Byte offset of the WAL prefix already applied to in-memory state.
1550    ///
1551    /// Advanced by exactly the encoded length of every frame this handle
1552    /// appends, and by the decoded byte count of every tail
1553    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1554    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1555    /// drain thread truncates a failed group. Compared against the WAL's
1556    /// on-disk length to decide staleness.
1557    wal_consumed: u64,
1558    /// Identity of the snapshot this handle's base state came from, as
1559    /// `(len, mtime_nanos)`. A different value means another process replaced
1560    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1561    snapshot_ident: Option<(u64, u64)>,
1562    /// The options this handle was opened with. Replayed verbatim when
1563    /// `refresh` has to rebuild from disk.
1564    open_opts: OpenOptions,
1565    /// True when this handle holds the cross-process write lock for its whole
1566    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1567    /// no-op on such a handle, and never releases the lock.
1568    holds_lifetime_lock: bool,
1569    /// True between a failed lock acquisition and the end of the write scope
1570    /// that failed. Makes every WAL-appending mutation in that scope return
1571    /// [`GraphError::Busy`] instead of writing.
1572    lock_denied: bool,
1573    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1574    /// pinned to one commit, so it is never stale and never refreshes — later
1575    /// commits by any process are deliberately invisible to it.
1576    pinned: bool,
1577}
1578
1579/// One group of deferred event notifications, held until the group fsync
1580/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1581struct DeferredEvent {
1582    rec: core_storage::WalRecord,
1583    engine_deltas: Vec<EngineEdgeDelta>,
1584    seq: u64,
1585    ingest: Option<(String, usize)>,
1586}
1587
1588/// Options for [`GraphDb::open_with_options`].
1589#[derive(Clone, Copy, Debug)]
1590pub struct OpenOptions {
1591    /// Rewrite an old-format snapshot to the current VERSION after a
1592    /// successful load (default `true`). The old snapshot is kept as
1593    /// `snapshot.bin.bak` until the next clean open at the current version,
1594    /// at which point the `.bak` is deleted.
1595    ///
1596    /// Set to `false` to open a store without touching any on-disk files
1597    /// (useful for read-only inspection of a store at an older format).
1598    pub auto_migrate: bool,
1599
1600    /// Write the valid WAL prefix back over a torn tail on open (default
1601    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1602    ///
1603    /// Set to `false` for an unattended reader. The valid prefix is still
1604    /// decoded and replayed in memory, but nothing is written: a reader that
1605    /// opens while another process is mid-append would otherwise discard a
1606    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1607    /// every prompt, passes `false` for exactly this reason.
1608    pub repair_wal: bool,
1609
1610    /// Open without ever writing to the store (default `false`).
1611    ///
1612    /// A read-only handle:
1613    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1614    ///   `snapshot()`;
1615    /// - performs no disk write at open — no WAL repair write-back and no
1616    ///   auto-migration rewrite, whatever the other two flags say;
1617    /// - never takes the cross-process write lock, so it opens immediately even
1618    ///   while another process is writing, and never makes a writer wait.
1619    ///
1620    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1621    /// normally, so a read-only handle can follow another process's commits.
1622    pub read_only: bool,
1623}
1624
1625impl Default for OpenOptions {
1626    fn default() -> Self {
1627        Self {
1628            auto_migrate: true,
1629            repair_wal: true,
1630            read_only: false,
1631        }
1632    }
1633}
1634
1635/// How long a writer polls for the cross-process write lock before giving up
1636/// with [`GraphError::Busy`].
1637///
1638/// Long enough to ride out another process's commit (a batch apply plus one
1639/// fsync), short enough that a stuck peer surfaces as an error rather than a
1640/// hang.
1641pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1642
1643/// Refusal when a `MERGE` create cannot choose a namespace.
1644///
1645/// A role bound to two or more namespaces cannot have its create arm land in
1646/// `default`, and the statement did not name `ns`. The role must name one.
1647pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1648    "role-bound token: MERGE create requires the role to name one namespace";
1649
1650/// Interval between poll attempts while waiting for the cross-process lock.
1651pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1652
1653/// Why `load_from_disk` is running, which decides whether it may repair.
1654#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1655enum LoadOrigin {
1656    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1657    /// the signature of a crash and truncating it is correct, and archives
1658    /// orphaned by an interrupted prune can be swept.
1659    Open,
1660    /// A reload driven by [`GraphDb::refresh`], because another process
1661    /// replaced the snapshot. Nothing here is crash recovery — the store is
1662    /// live and someone else is writing it — so this origin writes nothing.
1663    Reload,
1664}
1665
1666/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1667///
1668/// `None` at the call site = full authority (today's zero-cost behavior).
1669/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1670/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1671/// record is built.  A denial returns an error with no WAL frame written.
1672///
1673/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1674/// hidden-node existence to callers.
1675#[derive(Clone, Debug)]
1676pub struct WriteAuthz {
1677    pub role: String,
1678    pub scope: WriteScope,
1679    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1680    /// Always `Omit`-mode — never `Stub`.
1681    pub mask: crate::mask::NodeMask,
1682}
1683
1684/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1685///
1686/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1687/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1688/// syncs the directory entry. This is the only correct path for writing the
1689/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1690/// the directory sync.
1691pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1692    use core_storage::fs::{FileId, Fs as _};
1693    RealFs::new(dir)
1694        .map_err(core_storage::GraphError::Io)?
1695        .write_atomic(FileId::SnapshotBak, bytes)
1696        .map_err(core_storage::GraphError::Io)
1697}
1698
1699/// Return the on-disk snapshot format version without decoding the full snapshot.
1700///
1701/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1702/// snapshot file exists (WAL-only store). Returns an error if the header is
1703/// malformed.
1704pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1705    use std::io::Read as _;
1706    let path = dir.join("snapshot.bin");
1707    let mut header = [0u8; 6];
1708    let n = match std::fs::File::open(&path) {
1709        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1710        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1711        Err(e) => return Err(core_storage::GraphError::Io(e)),
1712    };
1713    core_storage::snapshot::peek_version(&header[..n])
1714}
1715
1716/// Options for [`GraphDb::snapshot_with`].
1717#[derive(Debug, Clone, Default)]
1718pub struct SnapshotOptions {
1719    /// When `true`, the WAL is preserved after the snapshot write.
1720    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1721    /// When `false` (the default), the WAL is truncated to a minimal
1722    /// baseline so cold-start replay stays fast.
1723    pub keep_wal: bool,
1724    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1725    /// before a fresh WAL baseline is written (history-preserving snapshot).
1726    ///
1727    /// This is the feature opt-in: `false` (the default) leaves the existing
1728    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1729    /// precedence over `keep_wal` when both are set.
1730    ///
1731    /// Archives can be scanned by [`GraphDb::node_history`],
1732    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1733    /// [`GraphDb::open_at`], extending the reachable history horizon across
1734    /// snapshot boundaries.
1735    pub archive_wal: bool,
1736}
1737
1738/// Derive the scan-label sym for the commit-skip fast-path.
1739///
1740/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1741/// or `IndexIntersect`) with a concrete label string, then interns it.
1742///
1743/// Returns `None` in all cases where skipping is unsafe:
1744/// - Any `Expand` op is present (edge traversal; edges change results regardless
1745///   of node labels).
1746/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1747/// - No recognizable leading scan op is found.
1748///
1749/// This is the conservative v0.4.3 boundary. The caller stores the result in
1750/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1751fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1752    // Any Expand → must always re-execute (edges can change join results).
1753    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
1754        return None;
1755    }
1756    for op in ops {
1757        match op {
1758            PlanOp::ScanLabel {
1759                label: Some(label), ..
1760            } => return Some(syms.intern(label)),
1761            PlanOp::IndexScan {
1762                label: Some(label), ..
1763            } => return Some(syms.intern(label)),
1764            PlanOp::IndexIntersect {
1765                label: Some(label), ..
1766            } => return Some(syms.intern(label)),
1767            _ => {}
1768        }
1769    }
1770    None
1771}
1772
1773/// How an as-of read is restricted — the argument to
1774/// [`GraphDb::query_at_scoped`].
1775///
1776/// Every variant is resolved against the graph **as it was at the requested
1777/// commit**, not against the current graph.
1778#[derive(Debug, Clone, Copy)]
1779pub enum AsOfScope<'a> {
1780    /// Everything the named role may see. The role *definition* is the current
1781    /// one — `roles.json` is a sidecar and has no past version — but its
1782    /// `keys` and `labels` are resolved against the as-of graph.
1783    Role(&'a str),
1784    /// An explicit node-key allow-list. Keys that did not exist at that commit
1785    /// resolve to nothing.
1786    Keys(&'a [String]),
1787    /// A role intersected with a client-supplied allow-list. The intersection
1788    /// is the never-widen rule: a client mask can only narrow a role.
1789    RoleAndKeys(&'a str, &'a [String]),
1790    /// Every live node in one namespace, as the graph was at that commit.
1791    ///
1792    /// A namespace cannot change — it is set at insert and immutable — so the
1793    /// answer is simply "the nodes that existed then and are in this
1794    /// namespace". A name no node uses resolves to nothing, never to
1795    /// everything.
1796    Namespace(&'a str),
1797}
1798
1799impl GraphDb<RealFs> {
1800    /// Open the database at `dir` with default options.
1801    ///
1802    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
1803    /// Old-format snapshots (V5, V6) are automatically migrated to the
1804    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
1805    pub fn open(dir: &std::path::Path) -> Result<Self> {
1806        Self::open_with_options(dir, OpenOptions::default())
1807    }
1808
1809    /// Open the database at `dir` with explicit options.
1810    ///
1811    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
1812    /// snapshot is an older format version, this function:
1813    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
1814    ///      + fsynced) before any modification.
1815    ///   2. Rewrites `snapshot.bin` at the current format version via
1816    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
1817    ///
1818    /// If migration fails the error is returned and the original files are
1819    /// intact (the `.bak` was written before the new snapshot was attempted).
1820    ///
1821    /// A clean open that finds the snapshot already at the current version
1822    /// deletes any leftover `.bak` file.
1823    ///
1824    /// WAL-only stores (no snapshot) are never auto-migrated on open.
1825    ///
1826    /// `opts.repair_wal` controls the other write this function can make; see
1827    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
1828    /// no file on disk.
1829    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
1830        Self::open_dir(dir, opts, true)
1831    }
1832
1833    /// Open without taking the cross-process write lock for the handle's
1834    /// lifetime.
1835    ///
1836    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
1837    /// its handle open indefinitely, so it takes the lock per write instead of
1838    /// keeping every other process out of the store for as long as it runs.
1839    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
1840        Self::open_dir(dir, OpenOptions::default(), false)
1841    }
1842
1843    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
1844        // Header-only peek — 6 bytes, no full decode.
1845        let snap_version = snapshot_version_at(dir)?;
1846
1847        // Full load: decode snapshot + replay WAL + rebuild indexes.
1848        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
1849
1850        // A read-only handle writes nothing at open, so it never migrates —
1851        // the old-format snapshot is loaded and left exactly as it is.
1852        if opts.auto_migrate && !opts.read_only {
1853            match snap_version {
1854                Some(ver) if ver < core_storage::snapshot::VERSION => {
1855                    let _tm = std::time::Instant::now();
1856                    // Copy the original snapshot to .bak at OS level — no in-memory
1857                    // buffer required for a 2+ GiB file.
1858                    //
1859                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
1860                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
1861                    // A torn .bak on crash is acceptable because the original
1862                    // snapshot.bin is the authoritative source until after the rename.
1863                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
1864                        .map_err(core_storage::GraphError::Io)?;
1865                    trace_migrate!("bak copy done", _tm);
1866                    // Rewrite snapshot at current version; keep WAL intact.
1867                    db.snapshot_with(SnapshotOptions {
1868                        keep_wal: true,
1869                        ..SnapshotOptions::default()
1870                    })?;
1871                    trace_migrate!("snapshot_with done", _tm);
1872                }
1873                Some(_) => {
1874                    // Already current version: remove any leftover .bak.
1875                    let bak = dir.join("snapshot.bin.bak");
1876                    if bak.exists() {
1877                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
1878                    }
1879                }
1880                None => {
1881                    // WAL-only store — nothing to migrate on open.
1882                }
1883            }
1884        }
1885
1886        Ok(db)
1887    }
1888
1889    /// Open a read-only view of the database as it existed after `commit`.
1890    ///
1891    /// Commit indices are 0-based over the current WAL: commit 0 is the state
1892    /// after the first WAL frame, commit N-1 is the state after the N-th (most
1893    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
1894    ///
1895    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
1896    /// so as-of can only reach commits recorded in the current WAL (those
1897    /// written after the most recent snapshot, or all commits if no snapshot
1898    /// was ever taken).  Commit 0 in `open_at` always refers to the first
1899    /// frame in the WAL that exists on disk, not the first ever write to the
1900    /// database.  When the on-disk snapshot recorded that it truncated the
1901    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
1902    /// before frame replay, so the as-of view includes all pre-snapshot data.
1903    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
1904    /// are ignored and replay is WAL-only, as before.
1905    ///
1906    /// **Read-only.** Every mutation method and `snapshot()` on the returned
1907    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
1908    /// `stats()` work normally.
1909    ///
1910    /// # Errors
1911    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
1912    ///   when the WAL is empty after a snapshot).
1913    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
1914        Self::open_at_with(RealFs::new(dir)?, commit)
1915    }
1916
1917    /// Run a **read-only** Cypher query against the graph as it existed at
1918    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
1919    /// of this store's directory at that commit and executes the read there.
1920    ///
1921    /// The current instance is unaffected. Write statements are rejected (the
1922    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
1923    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
1924    /// state. Prefer this over holding many historical instances open.
1925    ///
1926    /// # Errors
1927    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
1928    /// - A query error for a malformed or write query.
1929    pub fn query_at(
1930        &self,
1931        commit: u64,
1932        cypher: &str,
1933        params: &std::collections::BTreeMap<String, Value>,
1934    ) -> Result<ResultSet> {
1935        let temporal = self.open_at_for_read(commit, cypher)?;
1936        temporal.query(cypher, params)
1937    }
1938
1939    /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
1940    ///
1941    /// The **graph** is as of `commit`; the **role definition** is as it is
1942    /// now, because `roles.json` is a sidecar and is never a WAL record — it
1943    /// has no past version to read. A role's `keys` and `labels` are resolved
1944    /// against the commit-`commit` graph, so a role that may see a label sees
1945    /// exactly the nodes that carried it then, and an explicit key that did
1946    /// not exist yet resolves to nothing.
1947    ///
1948    /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
1949    /// only narrow what a role may see, never widen it.
1950    ///
1951    /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
1952    /// them.
1953    ///
1954    /// # Errors
1955    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
1956    ///   range; the error carries that range.
1957    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
1958    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
1959    /// - A query error for a malformed or write query.
1960    pub fn query_at_scoped(
1961        &self,
1962        commit: u64,
1963        cypher: &str,
1964        params: &std::collections::BTreeMap<String, Value>,
1965        scope: AsOfScope<'_>,
1966    ) -> Result<ResultSet> {
1967        let temporal = self.open_at_for_read(commit, cypher)?;
1968        let mask = temporal.mask_at_scope(scope)?;
1969        temporal.query_masked(cypher, params, &mask)
1970    }
1971
1972    /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
1973    /// whatever `scope` resolves to.
1974    ///
1975    /// This is what a surface needs when a caller passes `namespace` beside a
1976    /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
1977    /// restriction, and the namespace is a second one that composes with it
1978    /// rather than replacing it. The intersection is the never-widen rule — a
1979    /// namespace can only narrow what the scope already allows — and both legs
1980    /// are resolved against the graph as it was at `commit`.
1981    ///
1982    /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
1983    pub fn query_at_scoped_in_namespace(
1984        &self,
1985        commit: u64,
1986        cypher: &str,
1987        params: &std::collections::BTreeMap<String, Value>,
1988        scope: AsOfScope<'_>,
1989        namespace: &str,
1990    ) -> Result<ResultSet> {
1991        let temporal = self.open_at_for_read(commit, cypher)?;
1992        let mask = temporal
1993            .mask_at_scope(scope)?
1994            .intersect(&temporal.mask_for_namespace(namespace));
1995        temporal.query_masked(cypher, params, &mask)
1996    }
1997
1998    /// Open the temporal view for a time-travel read and refuse write Cypher.
1999    ///
2000    /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
2001    /// resolve the commit and reject writes identically.
2002    fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
2003        let dir = self.fs.dir().to_path_buf();
2004        let temporal = Self::open_at(&dir, commit)?;
2005        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
2006            detail: format!("lex: {e}"),
2007        })?) {
2008            return Err(GraphError::QueryError {
2009                detail: "query_at is read-only: write statements are not permitted in a \
2010                         time-travel query"
2011                    .into(),
2012            });
2013        }
2014        Ok(temporal)
2015    }
2016}
2017
2018impl<F: Fs> GraphDb<F> {
2019    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
2020    pub fn open_with(fs: F) -> Result<Self> {
2021        Self::open_with_repair(fs, true)
2022    }
2023
2024    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
2025    /// prefix without writing the truncation back. See
2026    /// [`OpenOptions::repair_wal`].
2027    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
2028        Self::open_generic(
2029            fs,
2030            OpenOptions {
2031                repair_wal,
2032                ..OpenOptions::default()
2033            },
2034            true,
2035        )
2036    }
2037
2038    /// Shared open path.
2039    ///
2040    /// `hold_lock` requests the cross-process write lock for the whole handle
2041    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
2042    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
2043    /// `false` and takes the lock per write instead, so that a long-lived
2044    /// server does not keep every other process out of the store.
2045    ///
2046    /// A read-only open never takes the lock regardless of `hold_lock`.
2047    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2048        let mut db = Self::new_empty(fs, opts);
2049        db.read_only = opts.read_only;
2050        if hold_lock && !opts.read_only {
2051            if !db.poll_lock(WRITE_LOCK_WAIT)? {
2052                return Err(GraphError::Busy { holder: None });
2053            }
2054            db.holds_lifetime_lock = true;
2055        }
2056        db.load_from_disk(LoadOrigin::Open)?;
2057        Ok(db)
2058    }
2059
2060    /// A handle with no state loaded: every field at its empty value, the
2061    /// filesystem and options in place. Only [`load_from_disk`] makes it
2062    /// usable.
2063    fn new_empty(fs: F, opts: OpenOptions) -> Self {
2064        Self {
2065            fs,
2066            ids: IdMap::new(),
2067            syms: Interner::new(),
2068            topo: Topology::new(),
2069            props: ColumnStore::new(),
2070            labels: Vec::new(),
2071            ns_names: vec![NS_DEFAULT.to_string()],
2072            node_ns: Vec::new(),
2073            edge_props: EdgeProps::new(),
2074            engine: RuleEngine::new(),
2075            view_store: ViewStore::new(),
2076            fulltext: FulltextIndex::new(),
2077            prop_index: PropertyIndex::new(),
2078            event_sink: None,
2079            fsync: FsyncPolicy::Strict,
2080            commit_seq: 0,
2081            roles: Some(vec![]),
2082            role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
2083            subscriptions: Vec::new(),
2084            query_subscriptions: Vec::new(),
2085            sub_capacity: DEFAULT_SUB_CAPACITY,
2086            read_only: false,
2087            total_wal_commits: 0,
2088            base: None,
2089            fold_overlay: None,
2090            delta_tail: Vec::new(),
2091            commits_since_fold: 0,
2092            defer_events: false,
2093            deferred_events: Vec::new(),
2094            degraded: false,
2095            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2096            v8_sections_mutex: std::sync::Mutex::new(()),
2097            last_change: HashMap::new(),
2098            wal_archive_retention: None,
2099            wal_horizon_floor: 0,
2100            archive_genesis_chain: false,
2101            pending_write_authz: None,
2102            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2103                .ok()
2104                .and_then(|v| v.parse().ok())
2105                .unwrap_or(100),
2106            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2107                entries: std::collections::VecDeque::new(),
2108                total: 0,
2109            }),
2110            started_at: std::time::Instant::now(),
2111            wal_consumed: 0,
2112            snapshot_ident: None,
2113            open_opts: opts,
2114            holds_lifetime_lock: false,
2115            lock_denied: false,
2116            pinned: false,
2117        }
2118    }
2119
2120    /// Return every field describing stored graph state to its empty value,
2121    /// leaving this handle's own identity alone.
2122    ///
2123    /// Preserved on purpose: the filesystem, open options, lock ownership, the
2124    /// event sink and subscriptions, fsync policy, degraded flag, and the
2125    /// slow-query configuration and log. A caller that registered a sink or a
2126    /// subscription keeps it across a reload.
2127    fn reset_for_reload(&mut self) {
2128        self.ids = IdMap::new();
2129        self.syms = Interner::new();
2130        self.topo = Topology::new();
2131        self.props = ColumnStore::new();
2132        self.labels = Vec::new();
2133        self.ns_names = vec![NS_DEFAULT.to_string()];
2134        self.node_ns = Vec::new();
2135        self.edge_props = EdgeProps::new();
2136        self.engine = RuleEngine::new();
2137        self.view_store = ViewStore::new();
2138        self.fulltext = FulltextIndex::new();
2139        self.prop_index = PropertyIndex::new();
2140        self.commit_seq = 0;
2141        self.roles = Some(vec![]);
2142        // A fresh cache, not a cleared one: any reader snapshot still holding
2143        // the old `Arc` keeps it to itself, so nothing it memoised against the
2144        // pre-reload store can be read back through this handle.
2145        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2146        self.total_wal_commits = 0;
2147        self.base = None;
2148        self.fold_overlay = None;
2149        self.delta_tail = Vec::new();
2150        self.commits_since_fold = 0;
2151        self.deferred_events = Vec::new();
2152        self.v8_sections_loaded
2153            .store(false, std::sync::atomic::Ordering::Release);
2154        self.last_change = HashMap::new();
2155        self.wal_horizon_floor = 0;
2156        self.archive_genesis_chain = false;
2157        self.pending_write_authz = None;
2158        self.wal_consumed = 0;
2159        self.snapshot_ident = None;
2160    }
2161
2162    /// Load the snapshot base and replay the WAL into an empty handle — the
2163    /// whole of what opening a store does after the struct exists.
2164    ///
2165    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2166    /// rebuild a handle in place, without ownership of `F`, when another
2167    /// process replaces the snapshot underneath it.
2168    ///
2169    /// `origin` decides whether the two repair writes this function can make
2170    /// are appropriate; see [`LoadOrigin`].
2171    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2172        // Both writes below are crash recovery, and only an open is entitled to
2173        // perform them. A read-only handle promises to touch nothing, and a
2174        // reload driven by `refresh` is looking at a store another process is
2175        // actively writing: what looks like a torn tail there is a peer
2176        // mid-append, and what looks like an orphaned archive may be one that
2177        // peer is about to reference.
2178        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2179        let repair_wal = self.open_opts.repair_wal && may_repair;
2180        let db = self;
2181        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2182        db.archive_genesis_chain = db.fs.has_genesis_marker();
2183        // Opening cleanup: remove orphaned archives — archives whose frames all
2184        // fall below the horizon floor.  Orphans arise when a crash interrupted
2185        // the retention-prune sequence after the floor was written but before
2186        // all surplus archives were deleted.  Safe to delete: floor already
2187        // accounts for their frames.
2188        if may_repair {
2189            db.cleanup_orphaned_archives()?;
2190        }
2191        let _t0 = std::time::Instant::now();
2192        // Peek 6 bytes to determine snapshot version without reading the full
2193        // file. For RealFs this is a true partial read (O(1)); for SimFs the
2194        // default impl reads all bytes and truncates (still correct).
2195        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2196        // V8 and V9 share the mmap-able container; V9 only adds section 12.
2197        let is_v8 = snap_header.len() >= 6
2198            && &snap_header[0..4] == b"GDB1"
2199            && matches!(
2200                u16::from_le_bytes([snap_header[4], snap_header[5]]),
2201                core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
2202            );
2203        if is_v8 {
2204            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2205            // No 2.4GB heap Vec is allocated on RealFs.
2206            let mapped = Arc::new(
2207                if let Some(snap_path) = db.fs.snapshot_path() {
2208                    core_storage::v8::MappedBase::map(&snap_path)
2209                } else {
2210                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2211                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
2212                }
2213                .map_err(|e| GraphError::Corrupt {
2214                    detail: format!("v8: mmap open: {e:?}"),
2215                })?,
2216            );
2217            db.restore_v8_base(Arc::clone(&mapped))?;
2218            trace_open!("restore_v8_base", _t0);
2219            db.base = Some(mapped);
2220            trace_open!("base assigned", _t0);
2221        } else if !snap_header.is_empty() {
2222            // Legacy V5-V7: full read required for decode.
2223            let snap_bytes = db.fs.read(FileId::Snapshot)?;
2224            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2225                db.restore_snapshot_state(state)?;
2226            }
2227        }
2228        // else: snap_header is empty = no snapshot file, fresh store.
2229        //
2230        // Seed commit_seq from the highest seq persisted in last_change so that
2231        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2232        // already stored in the snapshot.  Without this, a db with one snapshot
2233        // commit would save last_change["a"]=1, then on reopen the first WAL
2234        // frame would replay at seq=1 again — colliding and making WAL-tail
2235        // mutations indistinguishable from the snapshot baseline.
2236        //
2237        // Safety invariant (seq-recycling):
2238        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
2239        //   last_change because they belonged to a previous db lifetime — a new
2240        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
2241        //   CAS precondition can carry a recycled seq as its `expected` value
2242        //   and accidentally match a live node's last_change entry.
2243        //
2244        // `expected:0` on a deleted-then-reinserted node:
2245        //   After deletion, last_changed() returns None; callers that call
2246        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2247        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
2248        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
2249        //   a CasConflict would be a caller that invented expected=0 without ever
2250        //   calling last_changed() — unreachable via the documented API contract.
2251        if let Some(&max_seq) = db.last_change.values().max() {
2252            db.commit_seq = db.commit_seq.max(max_seq);
2253        }
2254        let bytes = db.fs.read(FileId::Wal)?;
2255        let (records, valid_len) = decode_all(&bytes);
2256        // The valid prefix is replayed either way; `repair_wal` only decides
2257        // whether the truncation is written back. A reader that races a live
2258        // appender must not persist a truncation the writer never asked for.
2259        if valid_len < bytes.len() && repair_wal {
2260            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2261        }
2262        // WAL-present path: build indexes eagerly BEFORE replay so that the
2263        // first replayed record does not trigger the lazy-init guard (which
2264        // would call reindex_all_load_state on an empty graph, defeating the
2265        // point of restoring IVF/HNSW blobs from the snapshot).
2266        if !records.is_empty() {
2267            db.ensure_v8_base_sections_loaded();
2268            trace_open!("lazy sections loaded (WAL path)", _t0);
2269        }
2270        let replayed = db.apply_frames(records)?;
2271        // The cursor sits at the end of the valid prefix, not the end of the
2272        // file: a torn or still-being-written tail is unconsumed by definition
2273        // and stays visible to `is_stale` until it decodes.
2274        db.wal_consumed = valid_len as u64;
2275        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2276        trace_open!("wal replay done", _t0);
2277        // Rebuild view values after WAL replay only when there is no V8 base.
2278        // With a V8 base, view values are correct in the snapshot and are updated
2279        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2280        // A full rebuild would read overlay-only props (empty after restore_v8_base)
2281        // and overwrite correct base values with wrong results (e.g. NeighborAgg
2282        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2283        // base value).
2284        if db.base.is_none() {
2285            let topo_view = TopologyView::owned(&db.topo);
2286            db.view_store
2287                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
2288        }
2289        // Rebuild full-text index after WAL replay.  Corrects drift from
2290        // per-record incremental apply during replay.
2291        db.fulltext.rebuild_all(
2292            &db.ids,
2293            &db.labels,
2294            &db.syms,
2295            build_props_view(&db.props, &db.base),
2296        );
2297        db.prop_index.rebuild_all(
2298            &db.ids,
2299            &db.labels,
2300            &db.syms,
2301            build_props_view(&db.props, &db.base),
2302        );
2303        // Namespaces: one pass over the `ns` column, after the snapshot is
2304        // restored and the WAL replayed. Replay maintains `node_ns` record by
2305        // record as well; this pass is what makes a snapshot-only open right,
2306        // and it reads nothing on a store with no `ns` column.
2307        db.rebuild_node_ns();
2308        // A mid-build snapshot's HNSW blob carries `complete == false`.
2309        // Register it so `serve`'s ticker sees work without waiting for a write.
2310        db.register_outstanding_index_builds();
2311        // Load roles sidecar. Missing file = no roles (Some(vec![])).
2312        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2313        db.roles = Self::load_roles_from_fs(&db.fs)?;
2314        // Capture the initial MVCC fold so reader() is ready immediately.
2315        db.fold_now();
2316        trace_open!("open_with complete", _t0);
2317        Ok(replayed)
2318    }
2319
2320    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2321    /// replay does — same `apply` calls, same per-frame delta drain, same
2322    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2323    /// edges appear identically whether a frame arrives at open, from a local
2324    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2325    ///
2326    /// Returns the number of frames applied.
2327    ///
2328    /// Deltas are drained and discarded per frame: replayed frames are already
2329    /// reflected on disk, so they are not news to a subscriber, and draining
2330    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2331    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2332        if records.is_empty() {
2333            return Ok(0);
2334        }
2335        // Materialize any state retained in the mmap base before the first
2336        // frame lands, so a replayed record cannot trip the lazy-init guard and
2337        // rebuild indexes from an empty graph. Both calls are idempotent.
2338        self.ensure_v8_base_sections_loaded();
2339        self.engine.consume_retained_state_eager(
2340            &self.ids,
2341            &self.syms,
2342            &self.labels,
2343            build_props_view(&self.props, &self.base),
2344        );
2345        let applied = records.len();
2346        for rec in records {
2347            self.apply(&rec)?;
2348            let _ = self.engine.drain_deltas();
2349            // Track commit_seq during replay so last_change entries are
2350            // consistent with the seqs assigned by log_then_apply_with on
2351            // subsequent live commits.  After N replayed frames, commit_seq=N;
2352            // live commits begin at N+1.
2353            self.commit_seq += 1;
2354            let replay_seq = self.commit_seq;
2355            self.update_last_change_from_rec(&rec, replay_seq);
2356        }
2357        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2358        // this assert catches the regression in debug builds immediately.
2359        debug_assert_eq!(
2360            self.engine.pending_delta_count(),
2361            0,
2362            "pending_deltas non-empty after replay — \
2363             per-frame drain must run inside the loop to keep memory O(1)"
2364        );
2365        // T2 note: the per-frame drain IS the suppression seam for replay.
2366        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2367        // replaying subscribers; the mechanism is already in place.
2368        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2369        Ok(applied)
2370    }
2371
2372    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2373    //
2374    // mushroomdb is many-readers / one-writer across processes. Writers take an
2375    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2376    // Every handle tracks how much of the WAL it has consumed, so it can pick
2377    // up another process's commits by decoding only the new tail rather than
2378    // reopening. See `docs/site/concurrency.md`.
2379
2380    /// Whether the store on disk has moved ahead of (or out from under) this
2381    /// handle's in-memory state.
2382    ///
2383    /// True when the WAL's length differs from this handle's cursor — another
2384    /// process committed, or is mid-append — or when the snapshot file's
2385    /// identity changed. Costs two metadata lookups and reads no file contents,
2386    /// so it is cheap enough for a read path to call.
2387    ///
2388    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2389    /// pinned to one commit and later commits are deliberately invisible to it.
2390    pub fn is_stale(&self) -> Result<bool> {
2391        if self.pinned {
2392            return Ok(false);
2393        }
2394        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2395            return Ok(true);
2396        }
2397        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2398    }
2399
2400    /// Bring this handle up to date with every commit other processes have made,
2401    /// and return how many frames were applied.
2402    ///
2403    /// The WAL tail is decoded from this handle's cursor and applied through the
2404    /// same path the open replay uses, so rules fire and derived edges appear
2405    /// exactly as they would on a fresh open. Interners, id maps and indexes
2406    /// stay valid for the same reason.
2407    ///
2408    /// A frame another process is still writing is left alone: a trailing
2409    /// partial frame is a wait, not a corruption, and the handle stays stale
2410    /// until that frame is complete. Nothing is written to disk, so a read-only
2411    /// handle can refresh freely.
2412    ///
2413    /// When the snapshot file's identity changed, or the WAL is shorter than
2414    /// this handle's cursor, the WAL no longer continues our state — another
2415    /// process snapshotted or archived. The handle is then rebuilt from disk
2416    /// with the options it was opened with, and the return value is the number
2417    /// of frames in the new WAL.
2418    ///
2419    /// Returns 0 for an as-of view, which never follows later commits.
2420    ///
2421    /// # Errors
2422    ///
2423    /// An error here leaves the handle **degraded**: it got partway through
2424    /// applying the tail, or partway through a reload, so its in-memory state
2425    /// no longer matches any point on disk. Further mutations are refused and
2426    /// the handle must be reopened. Nothing on disk was damaged — the store
2427    /// itself is fine, and a fresh open recovers it.
2428    pub fn refresh(&mut self) -> Result<u64> {
2429        if self.pinned {
2430            return Ok(0);
2431        }
2432        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2433        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2434        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2435            // The WAL no longer continues our state: rebuild from disk. State
2436            // is cleared first, so a failed load leaves an empty handle — mark
2437            // it degraded rather than let a caller read an empty graph as if
2438            // it were the store's contents.
2439            self.reset_for_reload();
2440            return match self.load_from_disk(LoadOrigin::Reload) {
2441                Ok(frames) => Ok(frames as u64),
2442                Err(e) => {
2443                    self.degraded = true;
2444                    Err(e)
2445                }
2446            };
2447        }
2448        if wal_len == self.wal_consumed {
2449            return Ok(0);
2450        }
2451        let tail = self
2452            .fs
2453            .read_range(FileId::Wal, self.wal_consumed)
2454            .map_err(GraphError::Io)?;
2455        let (records, valid_len) = decode_all(&tail);
2456        let applied = match self.apply_frames(records) {
2457            Ok(n) => n,
2458            Err(e) => {
2459                // Some frames landed and some did not, and the cursor cannot
2460                // say how many. Advancing it would skip the rest; leaving it
2461                // would replay what already applied. Neither is recoverable in
2462                // place, so refuse further writes and require a reopen.
2463                self.degraded = true;
2464                return Err(e);
2465            }
2466        };
2467        // Advance by the bytes actually decoded, never by the file length: an
2468        // incomplete trailing frame stays unconsumed for the next refresh.
2469        self.wal_consumed += valid_len as u64;
2470        if applied > 0 {
2471            // Peer commits must reach `reader()` snapshots taken from here on.
2472            // A full fold is what open does; refresh does not build per-commit
2473            // deltas, so there is nothing cheaper that stays correct.
2474            self.fold_now();
2475        }
2476        Ok(applied as u64)
2477    }
2478
2479    /// Byte offset of the WAL prefix this handle has applied.
2480    ///
2481    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2482    #[doc(hidden)]
2483    pub fn wal_consumed(&self) -> u64 {
2484        self.wal_consumed
2485    }
2486
2487    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2488    /// failed group off the tail, so the cursor still describes the file.
2489    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2490        self.wal_consumed = len;
2491    }
2492
2493    /// One non-blocking attempt at the cross-process write lock.
2494    ///
2495    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2496    /// in-process write guard. That ordering is what keeps a busy peer in
2497    /// another process from stalling this process's readers.
2498    ///
2499    /// A handle that owns the lock for its lifetime always succeeds.
2500    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2501        if self.holds_lifetime_lock {
2502            return Ok(true);
2503        }
2504        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2505    }
2506
2507    /// Poll for the cross-process write lock until `wait` elapses.
2508    ///
2509    /// One attempt is always made, so a zero wait is a single try. Returns
2510    /// `false` when the lock is still held elsewhere at the deadline; nothing
2511    /// has been written and retrying later is safe.
2512    ///
2513    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2514    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2515    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2516    /// that it holds no in-process guard while it waits.
2517    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2518        let deadline = std::time::Instant::now() + wait;
2519        loop {
2520            if self.try_cross_process_lock()? {
2521                return Ok(true);
2522            }
2523            let now = std::time::Instant::now();
2524            if now >= deadline {
2525                return Ok(false);
2526            }
2527            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2528        }
2529    }
2530
2531    /// Open a cross-process write scope, given the outcome of an already-made
2532    /// lock attempt.
2533    ///
2534    /// The caller polls for the lock first — outside any in-process guard — and
2535    /// passes what it got. On success this refreshes, so the writes about to
2536    /// happen land on top of every other process's commits. On failure the
2537    /// handle refuses WAL-appending mutations and `snapshot()` with
2538    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2539    /// closes the scope, so a caller holding a guard cannot write behind
2540    /// another process's back.
2541    ///
2542    /// A handle that already owns the lock for its lifetime skips the refresh:
2543    /// no other process can have written, so there is nothing to pick up.
2544    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
2545        self.lock_denied = !acquired;
2546        if !acquired || self.holds_lifetime_lock {
2547            return Ok(());
2548        }
2549        if let Err(e) = self.refresh() {
2550            // Do not hold a lock we cannot use: release it and let the caller
2551            // see the underlying failure.
2552            let _ = self.fs.unlock();
2553            self.lock_denied = true;
2554            return Err(e);
2555        }
2556        Ok(())
2557    }
2558
2559    /// Close a cross-process write scope opened by
2560    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
2561    /// clear the Busy latch. Safe to call when the lock was never taken.
2562    pub(crate) fn end_write_lock(&mut self) {
2563        self.lock_denied = false;
2564        if !self.holds_lifetime_lock {
2565            // Releasing a lock we do not hold is a no-op; a failure to release
2566            // is reported by the OS closing the descriptor at handle drop.
2567            let _ = self.fs.unlock();
2568        }
2569    }
2570
2571    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
2572    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
2573    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
2574    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
2575    /// Restore all persisted state from a decoded snapshot. Shared by
2576    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
2577    fn restore_snapshot_state(
2578        &mut self,
2579        state: core_storage::snapshot::SnapshotState,
2580    ) -> Result<()> {
2581        self.ids = state.ids;
2582        self.syms = state.syms;
2583        self.topo = state.topo;
2584        self.props = state.props;
2585        self.labels = state.labels;
2586        self.edge_props = state.edge_props;
2587        // Cross-section label integrity for V5/V7 snapshots: same invariants as
2588        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
2589        // out-of-range sym ids, or a sentinel label on a live node would otherwise
2590        // open successfully and panic later in `NodeRef::label()` or
2591        // `neighborhood_masked()`.  Catching it here turns those into typed
2592        // `GraphError::Corrupt` at open time.
2593        {
2594            let ids_len = self.ids.len();
2595            if self.labels.len() != ids_len {
2596                return Err(GraphError::Corrupt {
2597                    detail: format!(
2598                        "snapshot: labels vec has {} entries but id table has {} total slots",
2599                        self.labels.len(),
2600                        ids_len,
2601                    ),
2602                });
2603            }
2604            let syms_len = self.syms.len() as u32;
2605            for (i, &sym) in self.labels.iter().enumerate() {
2606                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2607                if sym == u32::MAX {
2608                    if !is_tombstoned {
2609                        return Err(GraphError::Corrupt {
2610                            detail: format!(
2611                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
2612                            ),
2613                        });
2614                    }
2615                } else if sym >= syms_len {
2616                    return Err(GraphError::Corrupt {
2617                        detail: format!(
2618                            "snapshot: label at id slot {i} references sym {sym} \
2619                             which is out of interner range ({syms_len})"
2620                        ),
2621                    });
2622                }
2623            }
2624        }
2625        let defs: Vec<RuleDef> = state
2626            .rule_defs
2627            .iter()
2628            .map(|b| {
2629                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2630                    detail: format!("snapshot rule_def deserialize: {e}"),
2631                })
2632            })
2633            .collect::<Result<Vec<_>>>()?;
2634        self.engine =
2635            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
2636        // Candidate indexes are rebuilt lazily on the first mutation (see
2637        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
2638        // snapshot are retained without deserializing so that:
2639        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
2640        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
2641        //     lazy-init guard which calls reindex_all_load_state (the scan
2642        //     skips the HNSW build for every side the blob supplies).
2643        //   - WAL-present: open_with calls consume_retained_state_eager before
2644        //     replay so HNSW/IVF are live before any record fires the hooks.
2645        let ivf_bytes = if state.ivf_state.is_empty() {
2646            Vec::new()
2647        } else {
2648            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
2649        };
2650        // Store blobs without eagerly deserializing them.
2651        // `self.ids` is the snapshot's id table at this point — WAL replay has
2652        // not run — so its length is the line an interrupted build is detected
2653        // against.
2654        let snapshot_ids = self.ids.len() as u32;
2655        self.engine
2656            .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
2657        // Restore view defs from snapshot (V5).
2658        // The ColumnStore already contains view values from the snapshot;
2659        // use restore_view (no collision check, no backfill) so the store
2660        // is aware of the definitions.  rebuild_all runs after WAL replay.
2661        for def_bytes in &state.view_defs {
2662            let def: ViewDef =
2663                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2664                    detail: format!("snapshot view_def deserialize: {e}"),
2665                })?;
2666            self.view_store
2667                .restore_view(def)
2668                .map_err(|e| GraphError::Corrupt {
2669                    detail: format!("snapshot view restore: {e}"),
2670                })?;
2671        }
2672        Ok(())
2673    }
2674
2675    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
2676    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
2677    ///
2678    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
2679    /// deserialization and view rebuild have access to all column data.
2680    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
2681        self.ids = archived_to_idmap(mapped.ids().map_err(|e| GraphError::Corrupt {
2682            detail: format!("v8: ids section: {e:?}"),
2683        })?);
2684        self.syms = archived_to_interner(mapped.syms().map_err(|e| GraphError::Corrupt {
2685            detail: format!("v8: syms section: {e:?}"),
2686        })?);
2687
2688        // C1: self.props is left as an empty overlay. Column reads go through
2689        // props_view() (ColumnsView::with_base), which consults the archived base
2690        // section zero-copy. This avoids the O(columns) heap copy at every open.
2691
2692        // self.topo deliberately left as Topology::new() — overlay path.
2693
2694        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
2695            detail: format!("v8: meta section: {e:?}"),
2696        })?)
2697        .map_err(|e| GraphError::Corrupt {
2698            detail: format!("v8: meta decode: {e:?}"),
2699        })?;
2700        self.labels = meta.labels;
2701        // Cross-section label integrity: labels must cover every id slot (live
2702        // and tombstoned), every non-sentinel sym must be within the interner's
2703        // bound, and no live (non-tombstoned) node may carry the u32::MAX
2704        // sentinel label.  Without this check, a crafted snapshot where the META
2705        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
2706        // sym ids, or a sentinel label on a live node, would open successfully
2707        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
2708        // related read paths.  Catching the inconsistency here converts those
2709        // panics into typed `GraphError::Corrupt` at open time.
2710        {
2711            let ids_len = self.ids.len();
2712            if self.labels.len() != ids_len {
2713                return Err(GraphError::Corrupt {
2714                    detail: format!(
2715                        "v8: labels section has {} entries but id table has {} total slots",
2716                        self.labels.len(),
2717                        ids_len,
2718                    ),
2719                });
2720            }
2721            let syms_len = self.syms.len() as u32;
2722            for (i, &sym) in self.labels.iter().enumerate() {
2723                let is_tombstoned = self.ids.is_tombstoned(i as u32);
2724                if sym == u32::MAX {
2725                    // Sentinel is only valid for tombstoned slots.
2726                    if !is_tombstoned {
2727                        return Err(GraphError::Corrupt {
2728                            detail: format!(
2729                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
2730                            ),
2731                        });
2732                    }
2733                } else if sym >= syms_len {
2734                    return Err(GraphError::Corrupt {
2735                        detail: format!(
2736                            "v8: label at id slot {i} references sym {sym} \
2737                             which is out of interner range ({syms_len})"
2738                        ),
2739                    });
2740                }
2741            }
2742        }
2743        // C3: self.edge_props stays as an empty overlay.  Reads go through
2744        // edge_props_view() which consults the mmap'd base section zero-copy
2745        // via EdgePropsView::with_base.  No heap decode at open time.
2746
2747        // Restore rule engine.
2748        let (rule_def_bytes, rule_tripped, rule_fires) =
2749            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
2750                GraphError::Corrupt {
2751                    detail: format!("v8: rules_meta section: {e:?}"),
2752                }
2753            })?);
2754        let defs: Vec<RuleDef> = rule_def_bytes
2755            .iter()
2756            .map(|b| {
2757                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
2758                    detail: format!("v8: rule_def deserialize: {e}"),
2759                })
2760            })
2761            .collect::<Result<Vec<_>>>()?;
2762        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
2763        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
2764        // `ensure_v8_base_sections_loaded` reads them on first use from
2765        // `self.base` (set by the caller immediately after this returns).
2766        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
2767
2768        // Restore view definitions.
2769        let view_defs =
2770            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
2771                detail: format!("v8: views section: {e:?}"),
2772            })?);
2773        for def_bytes in &view_defs {
2774            let def: ViewDef =
2775                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
2776                    detail: format!("v8: view_def deserialize: {e}"),
2777                })?;
2778            self.view_store
2779                .restore_view(def)
2780                .map_err(|e| GraphError::Corrupt {
2781                    detail: format!("v8: view restore: {e}"),
2782                })?;
2783        }
2784        // Load the last-change map from section 11 (small section; load eagerly).
2785        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
2786        // in that case and `decode_last_change_bytes` returns an empty map.
2787        let last_change_raw = mapped
2788            .last_change_bytes()
2789            .map_err(|e| GraphError::Corrupt {
2790                detail: format!("v8: last_change section: {e:?}"),
2791            })?;
2792        self.last_change = decode_last_change_bytes(last_change_raw);
2793
2794        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
2795        // the file.  Pure bounds check — no bytes read, no page faults triggered.
2796        // Catches truncated snapshots at open time before the lazy deferred reads.
2797        mapped.validate_section_bounds().map_err(|e| match e {
2798            GraphError::Corrupt { detail } => GraphError::Corrupt {
2799                detail: format!("v8: section bounds: {detail}"),
2800            },
2801            other => other,
2802        })?;
2803        Ok(())
2804    }
2805
2806    /// Read provenance, HNSW, and IVF sections from the mmap base into the
2807    /// engine's retained fields on first call.  Subsequent calls are a no-op
2808    /// (AtomicBool fast-path).
2809    ///
2810    /// Must be called before any code path that reads or mutates engine
2811    /// provenance, HNSW, or IVF state:
2812    /// - WAL replay (before `consume_retained_state_eager`)
2813    /// - First mutation (`log_then_apply_with`)
2814    /// - Read-only paths (`stats`, `explain`, `node_edges`)
2815    /// - Snapshot (`snapshot_with`)
2816    ///
2817    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
2818    fn ensure_v8_base_sections_loaded(&self) {
2819        use std::sync::atomic::Ordering;
2820        if self.v8_sections_loaded.load(Ordering::Acquire) {
2821            return;
2822        }
2823        let _guard = self
2824            .v8_sections_mutex
2825            .lock()
2826            .expect("v8 sections mutex poisoned");
2827        if self.v8_sections_loaded.load(Ordering::Acquire) {
2828            return; // another caller populated while we waited
2829        }
2830        let _t = std::time::Instant::now();
2831        if let Some(base) = &self.base {
2832            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
2833            // Bounds are already validated at open time (restore_v8_base →
2834            // validate_section_bounds) — unreachable post-validate_section_bounds;
2835            // unwrap_or_default is a safety belt against impossible errors.
2836            let prov_bytes = base
2837                .provenance_raw_bytes()
2838                .map(|b| b.to_vec())
2839                .unwrap_or_default();
2840            self.engine.store_provenance_bytes(prov_bytes);
2841            // HNSW: decode rkyv blobs into owned map.
2842            let hnsw_state = base
2843                .hnsw_section()
2844                .map(archived_hnsw_to_owned)
2845                .unwrap_or_default();
2846            // IVF: raw bincode bytes; deserialized on first mutation/query.
2847            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
2848            // Called before WAL replay on a WAL-present open (`open_with`) and
2849            // before any write on a clean one, so this is the snapshot's count.
2850            let snapshot_ids = self.ids.len() as u32;
2851            self.engine
2852                .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
2853        }
2854        self.v8_sections_loaded.store(true, Ordering::Release);
2855        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
2856            eprintln!(
2857                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
2858                _t.elapsed()
2859            );
2860        }
2861    }
2862
2863    /// Return a `TopologyView` that merges the mmap'd base (when present) with
2864    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
2865    /// the full merged topology without going through `self.view()`.
2866    fn topo_view(&self) -> TopologyView<'_> {
2867        match self.base {
2868            None => TopologyView::owned(&self.topo),
2869            Some(ref base) => {
2870                // SAFETY: base lives as long as self; section bounds validated at open.
2871                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
2872                let archived = base
2873                    .topology()
2874                    .expect("base topology section bounds validated at open");
2875                TopologyView::with_base(&self.topo, archived)
2876            }
2877        }
2878    }
2879
2880    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
2881    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
2882    /// overlay first, then fall through to the archived base section zero-copy.
2883    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
2884        match self.base {
2885            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
2886            Some(ref base) => {
2887                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
2888                let archived = base
2889                    .columns()
2890                    .expect("base columns section bounds validated at open");
2891                core_storage::v8::seam::ColumnsView::with_base_cached(
2892                    &self.props,
2893                    archived,
2894                    base.mixed_cache(),
2895                )
2896                .with_shared_strings(base_string_table(base))
2897            }
2898        }
2899    }
2900
2901    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
2902    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
2903    ///
2904    /// Reads consult the overlay first (for post-snapshot mutations), then fall
2905    /// through to the archived base section zero-copy.  Tombstones in the
2906    /// overlay mask deleted-from-base entries.
2907    fn edge_props_view(&self) -> EdgePropsView<'_> {
2908        match self.base {
2909            None => EdgePropsView::owned(&self.edge_props),
2910            Some(ref base) => {
2911                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
2912                let archived = base
2913                    .edge_props_section()
2914                    .expect("base edge_props section bounds validated at open");
2915                EdgePropsView::with_base(&self.edge_props, archived)
2916            }
2917        }
2918    }
2919
2920    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
2921        // An as-of view never writes and is pinned to one commit: it takes no
2922        // cross-process lock and does not follow later commits.
2923        let mut db = Self::new_empty(
2924            fs,
2925            OpenOptions {
2926                repair_wal: false,
2927                auto_migrate: false,
2928                read_only: true,
2929            },
2930        );
2931        db.pinned = true; // read_only is set after replay, but pinning is immediate
2932        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2933        db.archive_genesis_chain = db.fs.has_genesis_marker();
2934        // Same orphaned-archive cleanup as open_with: floor was written first
2935        // during pruning, so a crash may have left stale archives below floor.
2936        db.cleanup_orphaned_archives()?;
2937        // Collect archive frames (oldest-first) and live WAL frames.
2938        // Archives represent pre-snapshot history; the snapshot captures the
2939        // cumulative state at the time of archiving.  Crash-window guarantee:
2940        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
2941        //   B: crash after rename, before new WAL → archive present, WAL
2942        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
2943        //   C: crash after new baseline WAL written → normal post-archive.
2944        let archive_ns = db.fs.list_archives()?;
2945        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
2946        for n in &archive_ns {
2947            let arc_bytes = db.fs.read_archive(*n)?;
2948            let (arc_frames, _) = decode_all(&arc_bytes);
2949            archive_frames_all.extend(arc_frames);
2950        }
2951        let total_archive_frames = archive_frames_all.len() as u64;
2952
2953        let live_bytes = db.fs.read(FileId::Wal)?;
2954        let (live_records, _valid_len) = decode_all(&live_bytes);
2955        let total_surviving = total_archive_frames + live_records.len() as u64;
2956        // Global total including any pruned history below the horizon floor.
2957        let total = db.wal_horizon_floor + total_surviving;
2958
2959        // Horizon and range check.
2960        if commit < db.wal_horizon_floor {
2961            return Err(GraphError::CommitOutOfRange {
2962                commit,
2963                total,
2964                floor: db.wal_horizon_floor,
2965            });
2966        }
2967        if commit >= total {
2968            return Err(GraphError::CommitOutOfRange {
2969                commit,
2970                total,
2971                floor: db.wal_horizon_floor,
2972            });
2973        }
2974
2975        // Local index into surviving frames (0 = first frame of oldest archive).
2976        let local = commit - db.wal_horizon_floor;
2977
2978        if local < total_archive_frames {
2979            // Target commit is in an archive.  Correct replay from empty state
2980            // is only possible when the archive chain is an uninterrupted
2981            // genesis chain (first archive taken from a fresh store, no prior
2982            // WAL truncation) and no archives have been pruned (floor == 0).
2983            //
2984            // If either condition is violated the prefix needed to reconstruct
2985            // the requested state is gone; refuse rather than return wrong data.
2986            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
2987                return Err(GraphError::CommitOutOfRange {
2988                    commit,
2989                    total,
2990                    floor: db.wal_horizon_floor,
2991                });
2992            }
2993            // Replay all archive frames up to and including the target commit
2994            // from an empty database state.  Archives must be replayed in order
2995            // so that dense-id intern tables are built up correctly.
2996            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
2997                db.apply(&rec)?;
2998                let _ = db.engine.drain_deltas();
2999            }
3000        } else {
3001            // Target commit is in the live WAL: load snapshot as base, then
3002            // replay the needed live WAL prefix.
3003            //
3004            // Base state: a truncating snapshot (wal_truncated=true) compacts
3005            // all pre-truncation / pre-archive commits.  Dense-id records in
3006            // the live WAL reference ids/interns that the snapshot provides.
3007            // Peek 6 bytes (same pattern as open_with).
3008            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
3009            let is_v8 = snap_header.len() >= 6
3010                && &snap_header[0..4] == b"GDB1"
3011                && matches!(
3012                    u16::from_le_bytes([snap_header[4], snap_header[5]]),
3013                    core_storage::snapshot::VERSION_8 | core_storage::snapshot::VERSION_9
3014                );
3015            if is_v8 {
3016                let state = if let Some(snap_path) = db.fs.snapshot_path() {
3017                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
3018                        GraphError::Corrupt {
3019                            detail: format!("v8: open_at mmap: {e:?}"),
3020                        }
3021                    })?;
3022                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
3023                } else {
3024                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
3025                    core_storage::snapshot::decode(&snap_bytes)?
3026                };
3027                if let Some(state) = state {
3028                    if state.wal_truncated {
3029                        db.restore_snapshot_state(state)?;
3030                    }
3031                }
3032            } else if !snap_header.is_empty() {
3033                let snap_bytes = db.fs.read(FileId::Snapshot)?;
3034                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
3035                    if state.wal_truncated {
3036                        db.restore_snapshot_state(state)?;
3037                    }
3038                }
3039            }
3040            // else: snap_header empty = no snapshot file.
3041            let live_local = local - total_archive_frames;
3042            for rec in live_records.into_iter().take((live_local + 1) as usize) {
3043                db.apply(&rec)?;
3044                let _ = db.engine.drain_deltas();
3045            }
3046        }
3047        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
3048        // post-loop assert in open_with.
3049        debug_assert_eq!(
3050            db.engine.pending_delta_count(),
3051            0,
3052            "pending_deltas non-empty after open_at replay — \
3053             per-frame drain must run inside the loop to keep memory O(1)"
3054        );
3055        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
3056                                          // Rebuild view values after WAL replay so derived-edge-driven views
3057                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
3058                                          // base), so topo_view is always owned.
3059        {
3060            let topo_view = TopologyView::owned(&db.topo);
3061            db.view_store
3062                .rebuild_all(&mut db.props, &topo_view, &db.ids, &db.syms, &db.labels);
3063        }
3064        // Rebuild full-text index for as-of view (mirrors open_with pattern).
3065        db.fulltext.rebuild_all(
3066            &db.ids,
3067            &db.labels,
3068            &db.syms,
3069            build_props_view(&db.props, &db.base),
3070        );
3071        db.prop_index.rebuild_all(
3072            &db.ids,
3073            &db.labels,
3074            &db.syms,
3075            build_props_view(&db.props, &db.base),
3076        );
3077        // Namespaces on the temporal handle, built by the same pass the live
3078        // open uses, so an as-of mask narrows by the namespaces of that commit.
3079        db.rebuild_node_ns();
3080        // Load roles sidecar (current roles, not point-in-time).
3081        db.roles = Self::load_roles_from_fs(&db.fs)?;
3082        db.read_only = true;
3083        db.total_wal_commits = total;
3084        // Capture initial fold so reader() is immediately usable.
3085        db.fold_now();
3086        Ok(db)
3087    }
3088
3089    /// Whether this instance is a read-only as-of view.
3090    pub fn is_read_only(&self) -> bool {
3091        self.read_only
3092    }
3093
3094    // ── MVCC epoch reader ─────────────────────────────────────────────────────
3095
3096    /// Clone the current overlay state into a new `FrozenOverlay` and reset
3097    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
3098    /// the end of `open_with` / `open_at_with` to prime the reader.
3099    fn fold_now(&mut self) {
3100        let frozen = crate::reader::FrozenOverlay {
3101            ids: self.ids.clone(),
3102            syms: self.syms.clone(),
3103            topo: self.topo.clone(),
3104            props: self.props.clone(),
3105            labels: self.labels.clone(),
3106            edge_props: self.edge_props.clone(),
3107            roles: self.roles.clone(),
3108            fulltext: self.fulltext.clone(),
3109        };
3110        self.fold_overlay = Some(Arc::new(frozen));
3111        self.delta_tail.clear();
3112        self.commits_since_fold = 0;
3113    }
3114
3115    /// Capture a lock-free reader snapshot of the current db state.
3116    ///
3117    /// The read lock is held only for the duration of this call (to clone a
3118    /// handful of `Arc` handles). Subsequent query operations run without any
3119    /// lock.
3120    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3121        crate::reader::ReaderSnapshot::new(
3122            self.fold_overlay
3123                .clone()
3124                .expect("fold_overlay is always Some after open_with; call reader() after open"),
3125            self.base.clone(),
3126            self.delta_tail.clone(),
3127            // The snapshot's effective state is exactly this handle's state at
3128            // this commit, so it shares the memo and its version key.
3129            self.commit_seq,
3130            Arc::clone(&self.role_masks),
3131        )
3132    }
3133
3134    /// Total number of WAL commits at the time [`open_at`] was called.
3135    /// Returns 0 for normal (non-as-of) instances.
3136    pub fn total_wal_commits(&self) -> u64 {
3137        self.total_wal_commits
3138    }
3139
3140    /// Apply a record to in-memory state. Used by both live writes and replay,
3141    /// so replay is definitionally identical to the original execution.
3142    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3143        // Before the record mutates anything: a store restored from a snapshot
3144        // defers building its candidate indexes until the first write, and that
3145        // build is a full node scan. Left where it used to fire — inside the
3146        // engine hook, after `props.set` and the label assignment — the scan
3147        // read the half-applied record and took the in-flight node's vector for
3148        // one the snapshot should have carried, which read as an interrupted
3149        // vector-index build and cost a full `RebuildRule` on the first
3150        // embedded write after every reopen. Hoisted here the scan sees exactly
3151        // the persisted state; the record's own hook then files its vector
3152        // through the ordinary insert path a line later.
3153        self.populate_indexes_before_write();
3154        match rec {
3155            WalRecord::InsertNode { label, key, props } => {
3156                let id = self.ids.try_insert(key)?;
3157                let sym = self.syms.intern(label);
3158                if self.labels.len() <= id as usize {
3159                    // gap slots are sentinels, never valid label symbols
3160                    self.labels.resize(id as usize + 1, u32::MAX);
3161                }
3162                self.labels[id as usize] = sym;
3163                let mut ns_name = NS_DEFAULT.to_string();
3164                for (field, value) in props {
3165                    if field == NS_PROP {
3166                        ns_name = namespace_of_value(Some(value)).to_string();
3167                    }
3168                    self.props.set(id, field, value.clone());
3169                }
3170                self.set_node_ns(id, &ns_name);
3171                // Initialize view values for the new node before the engine runs so
3172                // delta-based increments start from a known zero baseline.
3173                self.view_store
3174                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3175                // Fire rules for the newly inserted node.
3176                let cursor = self.engine.pending_delta_count();
3177                let mut eng = std::mem::take(&mut self.engine);
3178                {
3179                    let mut gm = make_graph_mut(
3180                        &self.ids,
3181                        &mut self.syms,
3182                        &self.labels,
3183                        build_props_view(&self.props, &self.base),
3184                        &mut self.topo,
3185                        &self.base,
3186                        &mut self.edge_props,
3187                    );
3188                    eng.on_node_changed(id, None, &mut gm);
3189                }
3190                self.engine = eng;
3191                // Process derived-edge deltas for view maintenance.
3192                // Fast path: skip the O(delta_count) allocation when no views exist.
3193                if !self.view_store.is_empty() {
3194                    #[cfg(test)]
3195                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3196                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3197                    for d in &new_deltas {
3198                        self.view_store.on_edge_changed(
3199                            d.etype_sym,
3200                            d.src_id,
3201                            d.dst_id,
3202                            d.fired,
3203                            &mut self.props,
3204                            &build_topo_view(&self.topo, &self.base),
3205                            &self.ids,
3206                            &self.syms,
3207                            &self.labels,
3208                            base_columns(&self.base),
3209                        );
3210                    }
3211                }
3212                // Full-text index maintenance: index enabled fields for this label.
3213                if self.fulltext.has_label(label) {
3214                    for (field, value) in props {
3215                        if self.fulltext.is_enabled(label, field) {
3216                            self.fulltext.add_tokens(id, field, value);
3217                        }
3218                    }
3219                }
3220                // Property (equality) index maintenance.
3221                if self.prop_index.has_label(label) {
3222                    for (field, value) in props {
3223                        self.prop_index.set(label, field, id, value);
3224                    }
3225                }
3226            }
3227            WalRecord::InsertEdge {
3228                edge_type,
3229                src_key,
3230                dst_key,
3231            } => {
3232                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3233                    detail: format!("wal replay references unknown key {src_key}"),
3234                })?;
3235                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3236                    detail: format!("wal replay references unknown key {dst_key}"),
3237                })?;
3238                let etype = self.syms.intern(edge_type);
3239                // Skip if the edge is already visible in the merged base+overlay
3240                // view.  This keeps WAL replay idempotent when the WAL contains
3241                // pre-snapshot records that are already encoded in a V8 base
3242                // (keep_wal=true opens and crash-before-truncation scenarios).
3243                if self.base.is_some()
3244                    && self
3245                        .topo_view()
3246                        .neighbors(etype, Direction::Out, src)
3247                        .contains(&dst)
3248                {
3249                    return Ok(());
3250                }
3251                self.topo.add_edge(etype, src, dst);
3252                // View maintenance for manual edge insert.
3253                self.view_store.on_edge_changed(
3254                    etype,
3255                    src,
3256                    dst,
3257                    true,
3258                    &mut self.props,
3259                    &build_topo_view(&self.topo, &self.base),
3260                    &self.ids,
3261                    &self.syms,
3262                    &self.labels,
3263                    base_columns(&self.base),
3264                );
3265                // Rule engine: via-hop rules must update when user edges change.
3266                let cursor = self.engine.pending_delta_count();
3267                let mut eng = std::mem::take(&mut self.engine);
3268                {
3269                    let mut gm = make_graph_mut(
3270                        &self.ids,
3271                        &mut self.syms,
3272                        &self.labels,
3273                        build_props_view(&self.props, &self.base),
3274                        &mut self.topo,
3275                        &self.base,
3276                        &mut self.edge_props,
3277                    );
3278                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3279                }
3280                self.engine = eng;
3281                if !self.view_store.is_empty() {
3282                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3283                    for d in &new_deltas {
3284                        self.view_store.on_edge_changed(
3285                            d.etype_sym,
3286                            d.src_id,
3287                            d.dst_id,
3288                            d.fired,
3289                            &mut self.props,
3290                            &build_topo_view(&self.topo, &self.base),
3291                            &self.ids,
3292                            &self.syms,
3293                            &self.labels,
3294                            base_columns(&self.base),
3295                        );
3296                    }
3297                }
3298            }
3299            WalRecord::SetProp { key, field, value } => {
3300                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3301                    detail: format!("wal replay references unknown key {key}"),
3302                })?;
3303                let old_value = build_props_view(&self.props, &self.base)
3304                    .get(id, field)
3305                    .map(|vr| vr.into_value());
3306                self.props.set(id, field, value.clone());
3307                // Fire rules for the changed field.
3308                let cursor = self.engine.pending_delta_count();
3309                let mut eng = std::mem::take(&mut self.engine);
3310                {
3311                    let mut gm = make_graph_mut(
3312                        &self.ids,
3313                        &mut self.syms,
3314                        &self.labels,
3315                        build_props_view(&self.props, &self.base),
3316                        &mut self.topo,
3317                        &self.base,
3318                        &mut self.edge_props,
3319                    );
3320                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3321                }
3322                self.engine = eng;
3323                // Derived-edge deltas → view updates.
3324                if !self.view_store.is_empty() {
3325                    #[cfg(test)]
3326                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3327                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3328                    for d in &new_deltas {
3329                        self.view_store.on_edge_changed(
3330                            d.etype_sym,
3331                            d.src_id,
3332                            d.dst_id,
3333                            d.fired,
3334                            &mut self.props,
3335                            &build_topo_view(&self.topo, &self.base),
3336                            &self.ids,
3337                            &self.syms,
3338                            &self.labels,
3339                            base_columns(&self.base),
3340                        );
3341                    }
3342                }
3343                // Neighbor-aggregate views that read `field` must also update.
3344                self.view_store.on_prop_changed(
3345                    id,
3346                    field,
3347                    &mut self.props,
3348                    &build_topo_view(&self.topo, &self.base),
3349                    &self.ids,
3350                    &self.syms,
3351                    &self.labels,
3352                    base_columns(&self.base),
3353                );
3354                // Full-text index maintenance: update tokens for this field if indexed.
3355                if self.fulltext.field_indexed(field) {
3356                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3357                        if sym == u32::MAX {
3358                            None
3359                        } else {
3360                            self.syms.resolve(sym)
3361                        }
3362                    });
3363                    if let Some(label) = label_opt {
3364                        if self.fulltext.is_enabled(label, field) {
3365                            self.fulltext.remove_node_field(id, field);
3366                            self.fulltext.add_tokens(id, field, value);
3367                        }
3368                    }
3369                }
3370                // Property (equality) index maintenance: re-key this node's value.
3371                if self.prop_index.field_indexed(field) {
3372                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3373                        if sym == u32::MAX {
3374                            None
3375                        } else {
3376                            self.syms.resolve(sym)
3377                        }
3378                    });
3379                    if let Some(label) = label_opt {
3380                        self.prop_index.set(label, field, id, value);
3381                    }
3382                }
3383            }
3384            WalRecord::Intern { id, text } => {
3385                if let Some(existing) = self.syms.get(text) {
3386                    if existing != *id {
3387                        return Err(GraphError::Corrupt {
3388                            detail: format!(
3389                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
3390                            ),
3391                        });
3392                    }
3393                } else {
3394                    let got = self.syms.intern(text);
3395                    if got != *id {
3396                        return Err(GraphError::Corrupt {
3397                            detail: format!(
3398                                "wal intern assigned {got} for {text:?}, record wanted {id}"
3399                            ),
3400                        });
3401                    }
3402                }
3403            }
3404            WalRecord::InsertNodeId { label, key, props } => {
3405                let id = self.ids.try_insert(key)?;
3406                if self.labels.len() <= id as usize {
3407                    self.labels.resize(id as usize + 1, u32::MAX);
3408                }
3409                self.labels[id as usize] = *label;
3410                let label_str = self
3411                    .syms
3412                    .resolve(*label)
3413                    .ok_or_else(|| GraphError::Corrupt {
3414                        detail: format!("wal InsertNodeId unknown label intern {label}"),
3415                    })?
3416                    .to_string();
3417                let mut ns_name = NS_DEFAULT.to_string();
3418                for (field_sym, value) in props {
3419                    let field =
3420                        self.syms
3421                            .resolve(*field_sym)
3422                            .ok_or_else(|| GraphError::Corrupt {
3423                                detail: format!(
3424                                    "wal InsertNodeId unknown field intern {field_sym}"
3425                                ),
3426                            })?;
3427                    if field == NS_PROP {
3428                        ns_name = namespace_of_value(Some(value)).to_string();
3429                    }
3430                    self.props.set(id, field, value.clone());
3431                }
3432                self.set_node_ns(id, &ns_name);
3433                self.view_store
3434                    .init_node_views(id, &mut self.props, &self.syms, &self.labels);
3435                let cursor = self.engine.pending_delta_count();
3436                let mut eng = std::mem::take(&mut self.engine);
3437                {
3438                    let mut gm = make_graph_mut(
3439                        &self.ids,
3440                        &mut self.syms,
3441                        &self.labels,
3442                        build_props_view(&self.props, &self.base),
3443                        &mut self.topo,
3444                        &self.base,
3445                        &mut self.edge_props,
3446                    );
3447                    eng.on_node_changed(id, None, &mut gm);
3448                }
3449                self.engine = eng;
3450                if !self.view_store.is_empty() {
3451                    #[cfg(test)]
3452                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3453                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3454                    for d in &new_deltas {
3455                        self.view_store.on_edge_changed(
3456                            d.etype_sym,
3457                            d.src_id,
3458                            d.dst_id,
3459                            d.fired,
3460                            &mut self.props,
3461                            &build_topo_view(&self.topo, &self.base),
3462                            &self.ids,
3463                            &self.syms,
3464                            &self.labels,
3465                            base_columns(&self.base),
3466                        );
3467                    }
3468                }
3469                if self.fulltext.has_label(&label_str) {
3470                    for (field_sym, value) in props {
3471                        let Some(field) = self.syms.resolve(*field_sym) else {
3472                            continue;
3473                        };
3474                        if self.fulltext.is_enabled(&label_str, field) {
3475                            self.fulltext.add_tokens(id, field, value);
3476                        }
3477                    }
3478                }
3479                if self.prop_index.has_label(&label_str) {
3480                    for (field_sym, value) in props {
3481                        let Some(field) = self.syms.resolve(*field_sym) else {
3482                            continue;
3483                        };
3484                        self.prop_index.set(&label_str, field, id, value);
3485                    }
3486                }
3487            }
3488            WalRecord::InsertEdgeId { etype, src, dst } => {
3489                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
3490                // already be tombstoned. Skip rather than attaching edges to
3491                // dead ids (DeleteNode keys the live re-insert, not the old id).
3492                if self.ids.is_tombstoned(*src)
3493                    || self.ids.is_tombstoned(*dst)
3494                    || self.ids.key_of(*src).is_none()
3495                    || self.ids.key_of(*dst).is_none()
3496                {
3497                    return Ok(());
3498                }
3499                // Skip if already visible in the merged view (same idempotency
3500                // guard as InsertEdge above: prevents double-counting when
3501                // pre-snapshot WAL records are replayed over a V8 base).
3502                if self.base.is_some()
3503                    && self
3504                        .topo_view()
3505                        .neighbors(*etype, Direction::Out, *src)
3506                        .contains(dst)
3507                {
3508                    return Ok(());
3509                }
3510                self.topo.add_edge(*etype, *src, *dst);
3511                self.view_store.on_edge_changed(
3512                    *etype,
3513                    *src,
3514                    *dst,
3515                    true,
3516                    &mut self.props,
3517                    &build_topo_view(&self.topo, &self.base),
3518                    &self.ids,
3519                    &self.syms,
3520                    &self.labels,
3521                    base_columns(&self.base),
3522                );
3523                // Rule engine: via-hop rules fire when user via-edges are inserted.
3524                // Resolve etype back to string so on_edge_changed can match rules by name.
3525                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
3526                    let cursor = self.engine.pending_delta_count();
3527                    let mut eng = std::mem::take(&mut self.engine);
3528                    {
3529                        let mut gm = make_graph_mut(
3530                            &self.ids,
3531                            &mut self.syms,
3532                            &self.labels,
3533                            build_props_view(&self.props, &self.base),
3534                            &mut self.topo,
3535                            &self.base,
3536                            &mut self.edge_props,
3537                        );
3538                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
3539                    }
3540                    self.engine = eng;
3541                    if !self.view_store.is_empty() {
3542                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3543                        for d in &new_deltas {
3544                            self.view_store.on_edge_changed(
3545                                d.etype_sym,
3546                                d.src_id,
3547                                d.dst_id,
3548                                d.fired,
3549                                &mut self.props,
3550                                &build_topo_view(&self.topo, &self.base),
3551                                &self.ids,
3552                                &self.syms,
3553                                &self.labels,
3554                                base_columns(&self.base),
3555                            );
3556                        }
3557                    }
3558                }
3559            }
3560            WalRecord::SetPropId { id, field, value } => {
3561                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
3562                    return Ok(());
3563                }
3564                let field_str = self
3565                    .syms
3566                    .resolve(*field)
3567                    .ok_or_else(|| GraphError::Corrupt {
3568                        detail: format!("wal SetPropId unknown field intern {field}"),
3569                    })?
3570                    .to_string();
3571                let old_value = build_props_view(&self.props, &self.base)
3572                    .get(*id, &field_str)
3573                    .map(|vr| vr.into_value());
3574                self.props.set(*id, &field_str, value.clone());
3575                let cursor = self.engine.pending_delta_count();
3576                let mut eng = std::mem::take(&mut self.engine);
3577                {
3578                    let mut gm = make_graph_mut(
3579                        &self.ids,
3580                        &mut self.syms,
3581                        &self.labels,
3582                        build_props_view(&self.props, &self.base),
3583                        &mut self.topo,
3584                        &self.base,
3585                        &mut self.edge_props,
3586                    );
3587                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
3588                }
3589                self.engine = eng;
3590                if !self.view_store.is_empty() {
3591                    #[cfg(test)]
3592                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3593                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3594                    for d in &new_deltas {
3595                        self.view_store.on_edge_changed(
3596                            d.etype_sym,
3597                            d.src_id,
3598                            d.dst_id,
3599                            d.fired,
3600                            &mut self.props,
3601                            &build_topo_view(&self.topo, &self.base),
3602                            &self.ids,
3603                            &self.syms,
3604                            &self.labels,
3605                            base_columns(&self.base),
3606                        );
3607                    }
3608                }
3609                self.view_store.on_prop_changed(
3610                    *id,
3611                    &field_str,
3612                    &mut self.props,
3613                    &build_topo_view(&self.topo, &self.base),
3614                    &self.ids,
3615                    &self.syms,
3616                    &self.labels,
3617                    base_columns(&self.base),
3618                );
3619                if self.fulltext.field_indexed(&field_str) {
3620                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3621                        if sym == u32::MAX {
3622                            None
3623                        } else {
3624                            self.syms.resolve(sym)
3625                        }
3626                    });
3627                    if let Some(label) = label_opt {
3628                        if self.fulltext.is_enabled(label, &field_str) {
3629                            self.fulltext.remove_node_field(*id, &field_str);
3630                            self.fulltext.add_tokens(*id, &field_str, value);
3631                        }
3632                    }
3633                }
3634                if self.prop_index.field_indexed(&field_str) {
3635                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
3636                        if sym == u32::MAX {
3637                            None
3638                        } else {
3639                            self.syms.resolve(sym)
3640                        }
3641                    });
3642                    if let Some(label) = label_opt {
3643                        self.prop_index.set(label, &field_str, *id, value);
3644                    }
3645                }
3646            }
3647            WalRecord::CreateRule { def_bytes } => {
3648                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
3649                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
3650                })?;
3651                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
3652                // so the engine already has it; silently skip to avoid a spurious
3653                // RuleInvalid error in the crash window between snapshot write and WAL
3654                // truncation.
3655                if self.engine.rules().any(|r| r.name == def.name) {
3656                    return Ok(());
3657                }
3658                let cursor = self.engine.pending_delta_count();
3659                let mut eng = std::mem::take(&mut self.engine);
3660                let result = {
3661                    let mut gm = make_graph_mut(
3662                        &self.ids,
3663                        &mut self.syms,
3664                        &self.labels,
3665                        build_props_view(&self.props, &self.base),
3666                        &mut self.topo,
3667                        &self.base,
3668                        &mut self.edge_props,
3669                    );
3670                    eng.create_rule(def, &mut gm)
3671                };
3672                self.engine = eng;
3673                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
3674                // Derived-edge fires from backfill → view updates.
3675                // Fast path: skip O(edge_count) allocation when no views exist.
3676                if !self.view_store.is_empty() {
3677                    #[cfg(test)]
3678                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3679                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3680                    for d in &new_deltas {
3681                        self.view_store.on_edge_changed(
3682                            d.etype_sym,
3683                            d.src_id,
3684                            d.dst_id,
3685                            d.fired,
3686                            &mut self.props,
3687                            &build_topo_view(&self.topo, &self.base),
3688                            &self.ids,
3689                            &self.syms,
3690                            &self.labels,
3691                            base_columns(&self.base),
3692                        );
3693                    }
3694                }
3695            }
3696            WalRecord::DeleteRule { name } => {
3697                // Replay-over-snapshot idempotency: the snapshot already captured the
3698                // post-delete state so the rule is absent; silently skip to avoid a
3699                // spurious RuleNotFound error in the crash window between snapshot write
3700                // and WAL truncation.
3701                if !self.engine.rules().any(|r| r.name == *name) {
3702                    return Ok(());
3703                }
3704                let cursor = self.engine.pending_delta_count();
3705                let mut eng = std::mem::take(&mut self.engine);
3706                let result = {
3707                    let mut gm = make_graph_mut(
3708                        &self.ids,
3709                        &mut self.syms,
3710                        &self.labels,
3711                        build_props_view(&self.props, &self.base),
3712                        &mut self.topo,
3713                        &self.base,
3714                        &mut self.edge_props,
3715                    );
3716                    eng.delete_rule(name, &mut gm)
3717                };
3718                self.engine = eng;
3719                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
3720                // Derived-edge retractions → view updates.
3721                if !self.view_store.is_empty() {
3722                    #[cfg(test)]
3723                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3724                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3725                    for d in &new_deltas {
3726                        self.view_store.on_edge_changed(
3727                            d.etype_sym,
3728                            d.src_id,
3729                            d.dst_id,
3730                            d.fired,
3731                            &mut self.props,
3732                            &build_topo_view(&self.topo, &self.base),
3733                            &self.ids,
3734                            &self.syms,
3735                            &self.labels,
3736                            base_columns(&self.base),
3737                        );
3738                    }
3739                }
3740            }
3741            WalRecord::RemoveProp { key, field } => {
3742                // Recovery-safe: unknown key or already-absent field is a
3743                // clean no-op. Crash-window replay over a snapshot that
3744                // already applied this record must not Err.
3745                let Some(id) = self.ids.get(key) else {
3746                    return Ok(());
3747                };
3748                // Read old value through the seam for rule retraction.
3749                let old = build_props_view(&self.props, &self.base)
3750                    .get(id, field)
3751                    .map(|vr| vr.into_value());
3752                self.props.remove(id, field);
3753                // If the base still supplies the value after the overlay removal,
3754                // record a tombstone so ColumnsView::get does not resurrect it.
3755                // This covers both the base-only case AND the both-resident case:
3756                //   base-only (in_overlay=false): old prop was only in base, remove
3757                //     is a no-op on overlay, base still visible → tombstone needed.
3758                //   both-resident (in_overlay=true): overlay had v2, base has v1;
3759                //     removing overlay uncovers v1 → tombstone needed.
3760                // Idempotent on double-replay: second pass sees the tombstone →
3761                // get() returns None → condition is false → no duplicate tombstone.
3762                if build_props_view(&self.props, &self.base)
3763                    .get(id, field)
3764                    .is_some()
3765                {
3766                    self.props.record_prop_tombstone(id, field);
3767                }
3768                let cursor = self.engine.pending_delta_count();
3769                let mut eng = std::mem::take(&mut self.engine);
3770                {
3771                    let mut gm = make_graph_mut(
3772                        &self.ids,
3773                        &mut self.syms,
3774                        &self.labels,
3775                        build_props_view(&self.props, &self.base),
3776                        &mut self.topo,
3777                        &self.base,
3778                        &mut self.edge_props,
3779                    );
3780                    eng.on_node_changed(id, Some((field, old)), &mut gm);
3781                }
3782                self.engine = eng;
3783                // Derived-edge deltas → view updates.
3784                if !self.view_store.is_empty() {
3785                    #[cfg(test)]
3786                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3787                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3788                    for d in &new_deltas {
3789                        self.view_store.on_edge_changed(
3790                            d.etype_sym,
3791                            d.src_id,
3792                            d.dst_id,
3793                            d.fired,
3794                            &mut self.props,
3795                            &build_topo_view(&self.topo, &self.base),
3796                            &self.ids,
3797                            &self.syms,
3798                            &self.labels,
3799                            base_columns(&self.base),
3800                        );
3801                    }
3802                }
3803                // Neighbor-aggregate views that read `field` must also update.
3804                self.view_store.on_prop_changed(
3805                    id,
3806                    field,
3807                    &mut self.props,
3808                    &build_topo_view(&self.topo, &self.base),
3809                    &self.ids,
3810                    &self.syms,
3811                    &self.labels,
3812                    base_columns(&self.base),
3813                );
3814                // Full-text index maintenance: remove tokens for this field.
3815                if self.fulltext.field_indexed(field) {
3816                    self.fulltext.remove_node_field(id, field);
3817                }
3818                // Property (equality) index maintenance: drop this node's entry.
3819                if self.prop_index.field_indexed(field) {
3820                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
3821                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
3822                    }) {
3823                        self.prop_index.remove_node(label, field, id);
3824                    }
3825                }
3826            }
3827            WalRecord::DeleteEdge {
3828                edge_type,
3829                src_key,
3830                dst_key,
3831            } => {
3832                // Recovery-safe: unknown keys, unknown etype, or already-
3833                // absent edge is a clean no-op (remove_edge returns false).
3834                let Some(src) = self.ids.get(src_key) else {
3835                    return Ok(());
3836                };
3837                let Some(dst) = self.ids.get(dst_key) else {
3838                    return Ok(());
3839                };
3840                let Some(etype) = self.syms.get(edge_type) else {
3841                    return Ok(());
3842                };
3843                // I3: phantom-tombstone guard.  When a V8 base is present, a
3844                // DeleteEdge WAL record for an edge that was already absorbed into
3845                // the new base (i.e. neither in overlay nor in base) must be skipped.
3846                // Without this guard, remove_edge records a tombstone for an edge
3847                // that no longer exists, incorrectly understating edge_count.
3848                if self.base.is_some()
3849                    && !self
3850                        .topo_view()
3851                        .neighbors(etype, core_storage::topology::Direction::Out, src)
3852                        .contains(&dst)
3853                {
3854                    return Ok(());
3855                }
3856                self.topo.remove_edge(etype, src, dst);
3857                self.edge_props.remove_edge(etype, src, dst);
3858                // View maintenance for manual edge delete (topo already updated above).
3859                self.view_store.on_edge_changed(
3860                    etype,
3861                    src,
3862                    dst,
3863                    false,
3864                    &mut self.props,
3865                    &build_topo_view(&self.topo, &self.base),
3866                    &self.ids,
3867                    &self.syms,
3868                    &self.labels,
3869                    base_columns(&self.base),
3870                );
3871                // Rule engine: via-hop rules must retract when user via-edges are deleted.
3872                let cursor = self.engine.pending_delta_count();
3873                let mut eng = std::mem::take(&mut self.engine);
3874                {
3875                    let mut gm = make_graph_mut(
3876                        &self.ids,
3877                        &mut self.syms,
3878                        &self.labels,
3879                        build_props_view(&self.props, &self.base),
3880                        &mut self.topo,
3881                        &self.base,
3882                        &mut self.edge_props,
3883                    );
3884                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3885                }
3886                self.engine = eng;
3887                if !self.view_store.is_empty() {
3888                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3889                    for d in &new_deltas {
3890                        self.view_store.on_edge_changed(
3891                            d.etype_sym,
3892                            d.src_id,
3893                            d.dst_id,
3894                            d.fired,
3895                            &mut self.props,
3896                            &build_topo_view(&self.topo, &self.base),
3897                            &self.ids,
3898                            &self.syms,
3899                            &self.labels,
3900                            base_columns(&self.base),
3901                        );
3902                    }
3903                }
3904            }
3905            WalRecord::DeleteNode { key } => {
3906                // Recovery-safe: already-tombstoned / unknown key is a clean
3907                // no-op. Crash-window replay over a snapshot that already
3908                // applied this record cannot recover the retired id from the
3909                // key (`IdMap::get` is None), so every subsequent step is
3910                // skipped. Each step is independently idempotent if invoked
3911                // twice on a still-live id: retraction is a no-op on empty
3912                // provenance, `remove_edge` returns false, `remove_all` is a
3913                // no-op, `ids.delete` returns None, label sentinel is sticky.
3914                let Some(n) = self.ids.get(key) else {
3915                    return Ok(());
3916                };
3917
3918                // (1) Retract derived edges + de-index while props/labels live.
3919                let cursor = self.engine.pending_delta_count();
3920                let mut eng = std::mem::take(&mut self.engine);
3921                {
3922                    let mut gm = make_graph_mut(
3923                        &self.ids,
3924                        &mut self.syms,
3925                        &self.labels,
3926                        build_props_view(&self.props, &self.base),
3927                        &mut self.topo,
3928                        &self.base,
3929                        &mut self.edge_props,
3930                    );
3931                    eng.on_node_removed(n, &mut gm);
3932                }
3933                self.engine = eng;
3934                // Derived-edge retractions → view updates for neighbors.
3935                if !self.view_store.is_empty() {
3936                    #[cfg(test)]
3937                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3938                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3939                    for d in &new_deltas {
3940                        self.view_store.on_edge_changed(
3941                            d.etype_sym,
3942                            d.src_id,
3943                            d.dst_id,
3944                            d.fired,
3945                            &mut self.props,
3946                            &build_topo_view(&self.topo, &self.base),
3947                            &self.ids,
3948                            &self.syms,
3949                            &self.labels,
3950                            base_columns(&self.base),
3951                        );
3952                    }
3953                }
3954
3955                // (2) Sweep ALL remaining edges incident to n, both directions,
3956                // every etype. This cascade is intentionally mask-independent:
3957                // topology integrity requires removing every edge touching the
3958                // deleted node regardless of the caller's visibility scope.
3959                // (The mask limits which nodes a role's read phase can return;
3960                // the WAL delete always executes with full storage authority.)
3961                // Collect then remove so neighbor slices stay valid during
3962                // iteration. Remove from topo first, then call view maintenance
3963                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
3964                let etypes: Vec<u32> = self.topo.etypes().collect();
3965                let mut doomed = Vec::new();
3966                for et in &etypes {
3967                    for &dst in self.topo.neighbors(*et, Direction::Out, n).as_ref() {
3968                        doomed.push((*et, n, dst));
3969                    }
3970                    for &src in self.topo.neighbors(*et, Direction::In, n).as_ref() {
3971                        doomed.push((*et, src, n));
3972                    }
3973                }
3974                for (et, s, d) in doomed {
3975                    self.topo.remove_edge(et, s, d);
3976                    self.edge_props.remove_edge(et, s, d);
3977                    // View maintenance: n's own view values will be cleared by
3978                    // remove_all below; only update surviving neighbors.
3979                    self.view_store.on_edge_changed(
3980                        et,
3981                        s,
3982                        d,
3983                        false,
3984                        &mut self.props,
3985                        &build_topo_view(&self.topo, &self.base),
3986                        &self.ids,
3987                        &self.syms,
3988                        &self.labels,
3989                        base_columns(&self.base),
3990                    );
3991                }
3992
3993                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
3994                self.props.remove_all(n);
3995                // Full-text index maintenance: remove all tokens for this node.
3996                self.fulltext.remove_node(n);
3997                // Property (equality) index maintenance: drop all entries for n.
3998                self.prop_index.remove_node_all(n);
3999
4000                // (4) Retire the dense id and stamp the label sentinel.
4001                self.ids.delete(key);
4002                if let Some(slot) = self.labels.get_mut(n as usize) {
4003                    *slot = u32::MAX;
4004                }
4005            }
4006            WalRecord::Batch(inner) => {
4007                // Apply each inner record in order through the same apply path.
4008                // Inner records are validated free of nested Batch by encode_record.
4009                for rec in inner {
4010                    self.apply(rec)?;
4011                }
4012            }
4013            WalRecord::RebuildRule { name } => {
4014                // Replay-over-snapshot idempotency: the snapshot may already
4015                // reflect a later delete_rule, so the rule is absent; skip.
4016                if !self.engine.rules().any(|r| r.name == *name) {
4017                    return Ok(());
4018                }
4019                let cursor = self.engine.pending_delta_count();
4020                let mut eng = std::mem::take(&mut self.engine);
4021                let result = {
4022                    let mut gm = make_graph_mut(
4023                        &self.ids,
4024                        &mut self.syms,
4025                        &self.labels,
4026                        build_props_view(&self.props, &self.base),
4027                        &mut self.topo,
4028                        &self.base,
4029                        &mut self.edge_props,
4030                    );
4031                    eng.rebuild(name, &mut gm)
4032                };
4033                self.engine = eng;
4034                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4035                // Derived-edge delta changes → view updates.
4036                if !self.view_store.is_empty() {
4037                    #[cfg(test)]
4038                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4039                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4040                    for d in &new_deltas {
4041                        self.view_store.on_edge_changed(
4042                            d.etype_sym,
4043                            d.src_id,
4044                            d.dst_id,
4045                            d.fired,
4046                            &mut self.props,
4047                            &build_topo_view(&self.topo, &self.base),
4048                            &self.ids,
4049                            &self.syms,
4050                            &self.labels,
4051                            base_columns(&self.base),
4052                        );
4053                    }
4054                }
4055            }
4056            WalRecord::CreateView { def_bytes } => {
4057                let def: ViewDef =
4058                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
4059                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
4060                    })?;
4061                // Replay-over-snapshot idempotency: view already present → skip.
4062                if self.view_store.has_view(&def.name) {
4063                    return Ok(());
4064                }
4065                self.view_store
4066                    .create_view(
4067                        def,
4068                        &mut self.props,
4069                        &build_topo_view(&self.topo, &self.base),
4070                        &self.ids,
4071                        &self.syms,
4072                        &self.labels,
4073                    )
4074                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4075            }
4076            WalRecord::DeleteView { name } => {
4077                // Replay-over-snapshot idempotency: view already absent → skip.
4078                if !self.view_store.has_view(name) {
4079                    return Ok(());
4080                }
4081                self.view_store
4082                    .delete_view(name, &mut self.props, &self.ids, &self.labels, &self.syms)
4083                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4084            }
4085            WalRecord::EnableFulltext { label, field } => {
4086                // Replay-over-snapshot idempotency: already enabled → skip.
4087                if self.fulltext.is_enabled(label, field) {
4088                    return Ok(());
4089                }
4090                self.fulltext.enable(label, field);
4091                // Backfill: index all live nodes of this label that have the field.
4092                let n = self.ids.len() as u32;
4093                for id in 0..n {
4094                    let Some(&sym) = self.labels.get(id as usize) else {
4095                        continue;
4096                    };
4097                    if sym == u32::MAX {
4098                        continue; // tombstoned
4099                    }
4100                    let Some(lbl) = self.syms.resolve(sym) else {
4101                        continue;
4102                    };
4103                    if lbl != label {
4104                        continue;
4105                    }
4106                    if let Some(value) = build_props_view(&self.props, &self.base)
4107                        .get(id, field)
4108                        .map(|vr| vr.into_value())
4109                    {
4110                        self.fulltext.add_tokens(id, field, &value);
4111                    }
4112                }
4113            }
4114            WalRecord::DisableFulltext { label, field } => {
4115                // Replay-over-snapshot idempotency: already disabled → skip.
4116                if !self.fulltext.is_enabled(label, field) {
4117                    return Ok(());
4118                }
4119                // If another label still indexes this field, the postings column
4120                // is kept — but it must not contain node_ids from the now-disabled
4121                // label.  Remove them before calling disable() so the field_indexed
4122                // guard inside disable() sees the correct post-removal state.
4123                if self.fulltext.field_indexed_by_other(label, field) {
4124                    if let Some(label_sym) = self.syms.get(label) {
4125                        for (node_id, &lsym) in self.labels.iter().enumerate() {
4126                            if lsym == label_sym {
4127                                self.fulltext.remove_node_field(node_id as u32, field);
4128                            }
4129                        }
4130                    }
4131                }
4132                self.fulltext.disable(label, field);
4133            }
4134            WalRecord::EnableIndex { label, field } => {
4135                // Replay-over-snapshot idempotency: already enabled → skip.
4136                if self.prop_index.is_enabled(label, field) {
4137                    return Ok(());
4138                }
4139                self.prop_index.enable(label, field);
4140                // Backfill: index all live nodes of this label that have the field.
4141                let n = self.ids.len() as u32;
4142                for id in 0..n {
4143                    let Some(&sym) = self.labels.get(id as usize) else {
4144                        continue;
4145                    };
4146                    if sym == u32::MAX {
4147                        continue; // tombstoned
4148                    }
4149                    let Some(lbl) = self.syms.resolve(sym) else {
4150                        continue;
4151                    };
4152                    if lbl != label {
4153                        continue;
4154                    }
4155                    if let Some(value) = build_props_view(&self.props, &self.base)
4156                        .get(id, field)
4157                        .map(|vr| vr.into_value())
4158                    {
4159                        self.prop_index.set(label, field, id, &value);
4160                    }
4161                }
4162            }
4163            WalRecord::DisableIndex { label, field } => {
4164                self.prop_index.disable(label, field);
4165            }
4166            // History markers carry no replay state — rules re-derive edges
4167            // deterministically on open/replay. Skip unconditionally.
4168            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4169            // ── rename_node ──────────────────────────────────────────────────
4170            WalRecord::RenameNode { old_key, new_key } => {
4171                // Recovery-safe: if old_key is already gone (key was renamed
4172                // by a snapshot or a prior replay frame), skip cleanly.
4173                if self.ids.get(old_key).is_none() {
4174                    return Ok(());
4175                }
4176                // The rename only updates the key-table; the dense id, all
4177                // topo edges, props, labels, and rule state are id-indexed and
4178                // require no change.
4179                self.ids
4180                    .rename(old_key, new_key)
4181                    .map_err(|e| GraphError::Corrupt {
4182                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4183                    })?;
4184            }
4185        }
4186        Ok(())
4187    }
4188
4189    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4190    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4191    /// idempotent when the string is already bound. Always emit: after
4192    /// `snapshot()` the WAL is truncated and live intern is not on disk.
4193    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4194        let id = if let Some(id) = self.syms.get(s) {
4195            id
4196        } else {
4197            self.syms.intern(s)
4198        };
4199        (
4200            id,
4201            WalRecord::Intern {
4202                id,
4203                text: s.to_string(),
4204            },
4205        )
4206    }
4207
4208    /// Rewrite user-facing records into dense-id records. On `Err`, no live
4209    /// state is left mutated: speculative interns made while building the
4210    /// output are rolled back, so a later successful mutation cannot log an
4211    /// `Intern` record whose id replay would never reproduce.
4212    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4213        let syms_checkpoint = self.syms.len();
4214        let result = self.rewrite_wal_dense_inner(recs);
4215        if result.is_err() {
4216            self.syms.truncate(syms_checkpoint);
4217        }
4218        result
4219    }
4220
4221    fn rewrite_wal_dense_inner(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4222        let mut out = Vec::with_capacity(recs.len());
4223        // Node ids allocated by later apply(InsertNodeId) in this same batch.
4224        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4225        // Namespace of each node inserted earlier in this same frame, so a SET
4226        // on a node this frame created is measured against the namespace it was
4227        // created in rather than against the store, where it does not exist yet.
4228        let mut pending_ns: std::collections::HashMap<String, String> =
4229            std::collections::HashMap::new();
4230        let mut interned = std::collections::HashSet::<u32>::new();
4231        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4232            detail: "id space exhausted".into(),
4233        })?;
4234        let lookup = |ids: &IdMap,
4235                      pending: &std::collections::HashMap<String, u32>,
4236                      key: &str|
4237         -> Option<u32> { ids.get(key).or_else(|| pending.get(key).copied()) };
4238        for rec in recs {
4239            match rec {
4240                WalRecord::InsertNode { label, key, props } => {
4241                    // Namespace validation and normalisation, on the one seam
4242                    // every user-visible node insert passes through: insert_node,
4243                    // a batch, ingest, Cypher CREATE and MERGE all arrive here
4244                    // before the WAL append, and replay never does.
4245                    let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4246                    pending_ns.insert(key.clone(), ns_name);
4247                    let (label_id, intern) = self.intern_wal(&label);
4248                    if interned.insert(label_id) {
4249                        out.push(intern);
4250                    }
4251                    let mut props_id = Vec::with_capacity(props.len());
4252                    for (field, value) in props {
4253                        let (field_id, intern) = self.intern_wal(&field);
4254                        if interned.insert(field_id) {
4255                            out.push(intern);
4256                        }
4257                        props_id.push((field_id, value));
4258                    }
4259                    if lookup(&self.ids, &pending, &key).is_none() {
4260                        pending.insert(key.clone(), next);
4261                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4262                            detail: "id space exhausted".into(),
4263                        })?;
4264                    }
4265                    out.push(WalRecord::InsertNodeId {
4266                        label: label_id,
4267                        key,
4268                        props: props_id,
4269                    });
4270                }
4271                WalRecord::SetProp { key, field, value } => {
4272                    // A namespace is set at insert and fixed after: the write is
4273                    // refused when it would move the node, and dropped when it
4274                    // names the namespace the node is already in. Checked here
4275                    // so set_prop, a batch, Cypher SET/MERGE and every upsert
4276                    // that merges props get the same answer.
4277                    if field == NS_PROP {
4278                        let Value::Str(ref to) = value else {
4279                            return Err(GraphError::RuleInvalid {
4280                                detail: format!(
4281                                    "node {key}: {NS_PROP} must be a string naming a namespace, \
4282                                     got {value:?}"
4283                                ),
4284                            });
4285                        };
4286                        let from = pending_ns
4287                            .get(&key)
4288                            .cloned()
4289                            .or_else(|| self.namespace_of(&key))
4290                            .unwrap_or_else(|| NS_DEFAULT.to_string());
4291                        let to = to.clone();
4292                        if to != from {
4293                            return Err(GraphError::NamespaceImmutable {
4294                                key: key.clone(),
4295                                from,
4296                                to,
4297                            });
4298                        }
4299                        continue;
4300                    }
4301                    let id =
4302                        lookup(&self.ids, &pending, &key).ok_or_else(|| GraphError::Corrupt {
4303                            detail: format!("dense WAL rewrite missing key {key}"),
4304                        })?;
4305                    let (field_id, intern) = self.intern_wal(&field);
4306                    if interned.insert(field_id) {
4307                        out.push(intern);
4308                    }
4309                    out.push(WalRecord::SetPropId {
4310                        id,
4311                        field: field_id,
4312                        value,
4313                    });
4314                }
4315                WalRecord::InsertEdge {
4316                    edge_type,
4317                    src_key,
4318                    dst_key,
4319                } => {
4320                    let (etype, intern) = self.intern_wal(&edge_type);
4321                    if interned.insert(etype) {
4322                        out.push(intern);
4323                    }
4324                    let src = lookup(&self.ids, &pending, &src_key).ok_or_else(|| {
4325                        GraphError::Corrupt {
4326                            detail: format!("dense WAL rewrite missing src {src_key}"),
4327                        }
4328                    })?;
4329                    let dst = lookup(&self.ids, &pending, &dst_key).ok_or_else(|| {
4330                        GraphError::Corrupt {
4331                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
4332                        }
4333                    })?;
4334                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
4335                }
4336                WalRecord::RenameNode {
4337                    ref old_key,
4338                    ref new_key,
4339                } => {
4340                    // Track the rename in `pending` so subsequent InsertEdge /
4341                    // SetProp records in this batch can resolve the new key.
4342                    let id = lookup(&self.ids, &pending, old_key).ok_or_else(|| {
4343                        GraphError::Corrupt {
4344                            detail: format!(
4345                                "dense WAL rewrite: RenameNode old key {old_key} not found"
4346                            ),
4347                        }
4348                    })?;
4349                    pending.remove(old_key.as_str());
4350                    pending.insert(new_key.clone(), id);
4351                    out.push(rec);
4352                }
4353                // # Symbol-order invariant (load-bearing)
4354                //
4355                // Write-time and replay-time symbol assignment must agree: every
4356                // symbol in a `Batch` frame has to receive the same dense id when
4357                // the frame's records are replayed in order as it received when
4358                // the frame was written.
4359                //
4360                // A rule's backfill interns its `edge_type` lazily
4361                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
4362                // site), and that backfill runs from `apply` — during the
4363                // `CreateRule` record itself, and again from any later
4364                // `InsertNodeId` in the same frame that makes the rule fire. At
4365                // write time the whole batch is rewritten before any of it is
4366                // applied, so a later `InsertEdge` in the same batch would win the
4367                // lower id for its edge type; on replay the rule's lazy intern
4368                // gets there first and steals it, and the `Intern` record fails at
4369                // the `wal intern assigned …` check in `apply`.
4370                //
4371                // Pre-interning the rule's `edge_type` here, and emitting its
4372                // `Intern` record ahead of the `CreateRule` record, makes both
4373                // orders identical. `weight_prop` needs no pre-intern:
4374                // `EdgeProps::set` keys props by `String`, never through the
4375                // interner. `via_edge` needs none either: via-hop rules resolve it
4376                // with `syms.get` and skip when it is absent.
4377                //
4378                // `RebuildRule` and `DeleteRule` need no such handling here:
4379                // `RebuildRule` has no `BatchOp` variant, so it never appears
4380                // inside a `Batch` today — it is only ever issued as its own
4381                // standalone commit (`rebuild_rule`, or the auto-rebuild path
4382                // that logs it as a second commit after the triggering op).
4383                // `DeleteRule` does have a `BatchOp` variant and can appear
4384                // inside a `Batch`, but it carries only a rule `name` — no
4385                // `edge_type` or other symbol that needs pre-interning — so
4386                // only `CreateRule` needs this arm.
4387                WalRecord::CreateRule { ref def_bytes } => {
4388                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4389                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4390                    })?;
4391                    let (etype, intern) = self.intern_wal(&def.edge_type);
4392                    if interned.insert(etype) {
4393                        out.push(intern);
4394                    }
4395                    out.push(rec);
4396                }
4397                other => out.push(other),
4398            }
4399        }
4400        Ok(out)
4401    }
4402
4403    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
4404        let recs = self.rewrite_wal_dense(recs)?;
4405        match recs.len() {
4406            0 => Ok(()),
4407            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
4408            _ => self.log_then_apply(WalRecord::Batch(recs)),
4409        }
4410    }
4411
4412    /// Durable write, then notify the event sink. Replay (`apply` during
4413    /// `open`) never enters this function, so it is the replay-silent seam.
4414    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
4415        self.log_then_apply_with(rec, None, self.fsync)
4416    }
4417
4418    /// Whether this frame must fsync under `policy`.
4419    ///
4420    /// Batched contract: user-visible batches (>1 mutation) fsync; single
4421    /// mutations do not. The dense rewrite wraps a single mutation in a
4422    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
4423    /// from the count — removing that filter would make every single-op write
4424    /// fsync under Batched (or, if the threshold were raised instead, skip a
4425    /// needed fsync for real two-op batches).
4426    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
4427        match policy {
4428            FsyncPolicy::Relaxed => false,
4429            FsyncPolicy::Strict => true,
4430            FsyncPolicy::Batched => match rec {
4431                // Intern + one mutation is the single-op rewrite, not a user batch.
4432                WalRecord::Batch(inner) => {
4433                    inner
4434                        .iter()
4435                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4436                        .count()
4437                        > 1
4438                }
4439                _ => false,
4440            },
4441        }
4442    }
4443
4444    /// # Apply-infallibility invariant (load-bearing)
4445    ///
4446    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
4447    /// for a `Batch` frame after a successful WAL write, the WAL would contain
4448    /// the full frame while in-memory state would reflect only the ops before
4449    /// the failure. On reopen, WAL replay would then apply the entire batch —
4450    /// diverging permanently from what the pre-crash process had in memory.
4451    ///
4452    /// For `Batch` frames this situation cannot arise because:
4453    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
4454    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
4455    ///   use, with no concurrent mutation between validation exit and apply entry.
4456    /// - Every `apply` arm for a validated op is either infallible by construction
4457    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
4458    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
4459    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
4460    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
4461    ///
4462    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
4463    /// `Err` for a `Batch` frame, making any future regression immediately visible
4464    /// in tests rather than silently diverging crash-recovery behaviour.
4465    fn log_then_apply_with(
4466        &mut self,
4467        rec: WalRecord,
4468        ingest: Option<(String, usize)>,
4469        policy: FsyncPolicy,
4470    ) -> Result<()> {
4471        // Read-only guard: as-of instances must never write the WAL.
4472        if self.read_only {
4473            return Err(GraphError::ReadOnly);
4474        }
4475        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
4476        // partway; in-memory state is ahead of (or out of step with) the
4477        // on-disk WAL, so further mutations would deepen the divergence.
4478        // Reopen the database to recover.  Checked before the lock guard: this
4479        // is the more serious condition and the more useful error.
4480        if self.degraded {
4481            return Err(GraphError::Io(std::io::Error::other(
4482                "database degraded after group-commit fsync failure; reopen required",
4483            )));
4484        }
4485        // Cross-process guard: this write scope asked for the store's write
4486        // lock and did not get it. Writing anyway would append frames on top of
4487        // a WAL another process is extending, so refuse instead.
4488        if self.lock_denied {
4489            return Err(GraphError::Busy { holder: None });
4490        }
4491        // Ensure retained provenance bytes are decoded into the live mutable
4492        // fields before any mutation touches self.engine.provenance.  This is a
4493        // no-op if provenance was never stored (fresh store) or has already been
4494        // consumed (subsequent mutations).  WAL replay calls apply() directly
4495        // and is covered by consume_retained_state_eager before replay.
4496        self.ensure_v8_base_sections_loaded();
4497        self.engine.ensure_provenance_loaded_mut();
4498        // Invariant (I-1): no stale deltas may enter from a previous apply.
4499        // If any engine method ever accumulates deltas before erroring, they would
4500        // contaminate the *next* commit's event stream. This assert fires in debug
4501        // builds, making any future regression visible at the earliest point.
4502        debug_assert_eq!(
4503            self.engine.pending_delta_count(),
4504            0,
4505            "stale engine deltas at log_then_apply_with entry — \
4506             a previous apply arm may have accumulated deltas before erroring; \
4507             the caller must drain_deltas() on any error path before returning"
4508        );
4509        let frame = encode_record(&rec);
4510        self.fs.append(FileId::Wal, &frame)?;
4511        // The cursor advances by exactly the bytes appended: these frames are
4512        // ours and already applied, so a later refresh must not replay them.
4513        self.wal_consumed += frame.len() as u64;
4514        if Self::wal_needs_sync(policy, &rec) {
4515            self.fs.sync(FileId::Wal)?;
4516        }
4517        // Marker writing always needs the engine deltas, but the engine only
4518        // accumulates them when emit_deltas is true (normally gated on subscribers
4519        // or views being present).  Enable emission for this apply if it is
4520        // currently off, then restore the original state unconditionally via an
4521        // RAII guard — this prevents a panic in apply() from leaking the flag.
4522        // The same guard resets the engine's transient chaining state. A panic
4523        // unwinding out of a rule hook would otherwise leave `chain_depth`
4524        // non-zero, which makes every later `begin_chain` decide chaining is
4525        // already running and silently switch it off for good.
4526        struct RestoreEmitDeltas(*mut RuleEngine, bool);
4527        impl Drop for RestoreEmitDeltas {
4528            fn drop(&mut self) {
4529                // SAFETY: pointer into self (GraphDb); guard is dropped within
4530                // this frame before log_then_apply_with returns.
4531                unsafe {
4532                    (*self.0).set_emit_deltas(self.1);
4533                    (*self.0).reset_chain_state();
4534                }
4535            }
4536        }
4537        let original_emit = self.engine.emit_deltas();
4538        if !original_emit {
4539            self.engine.set_emit_deltas(true);
4540        }
4541        // SAFETY: raw pointer into self; guard dropped within this frame.
4542        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
4543
4544        let apply_result = self.apply(&rec);
4545        // For Batch frames, post-validation apply must be infallible (see above).
4546        // A debug_assert here catches any future change that makes apply fallible
4547        // before the caller notices via silent WAL/memory divergence.
4548        if matches!(&rec, WalRecord::Batch(_)) {
4549            debug_assert!(
4550                apply_result.is_ok(),
4551                "Batch apply returned Err after successful WAL write — \
4552                 the validate-then-apply invariant has been violated; \
4553                 see log_then_apply_with invariant doc"
4554            );
4555        }
4556        if apply_result.is_err() {
4557            // Discard any partial deltas accumulated by the failed apply.
4558            // They must not ride the next commit's event stream (I-1).
4559            // _emit_guard restores emit_deltas on drop automatically.
4560            let _ = self.engine.drain_deltas();
4561            let _ = self.engine.take_rebuild_needed();
4562            apply_result?;
4563        }
4564        self.commit_seq += 1;
4565        let seq = self.commit_seq;
4566        // Update per-node last-change map for the committed record.
4567        // Must happen after commit_seq is incremented so the seq is correct.
4568        self.update_last_change_from_rec(&rec, seq);
4569        // Drain engine deltas and distribute to subscribers before the existing
4570        // MutationEvent sink fires — both happen post-fsync, post-apply.
4571        // _emit_guard restores emit_deltas after this line when it drops.
4572        let engine_deltas = self.engine.drain_deltas();
4573
4574        // Append history-marker WAL records for any derived-edge changes so
4575        // that `edge_history` and `was_linked` can surface rule-attributed
4576        // events. Markers are STATE NO-OPS during replay; they are written
4577        // without an additional fsync (the triggering commit's sync already
4578        // happened; the next commit's sync covers these lazily).
4579        if !engine_deltas.is_empty() {
4580            let markers: Vec<WalRecord> = engine_deltas
4581                .iter()
4582                .map(|d| {
4583                    if d.fired {
4584                        WalRecord::DerivedEdgeAdded {
4585                            rule: d.rule.clone(),
4586                            edge_type: d.edge_type.clone(),
4587                            src_key: d.src_key.clone(),
4588                            dst_key: d.dst_key.clone(),
4589                        }
4590                    } else {
4591                        WalRecord::DerivedEdgeRetracted {
4592                            rule: d.rule.clone(),
4593                            edge_type: d.edge_type.clone(),
4594                            src_key: d.src_key.clone(),
4595                            dst_key: d.dst_key.clone(),
4596                        }
4597                    }
4598                })
4599                .collect();
4600            let marker_frame = if markers.len() == 1 {
4601                markers.into_iter().next().unwrap()
4602            } else {
4603                WalRecord::Batch(markers)
4604            };
4605            // Ignore append errors: markers are best-effort history
4606            // annotations. Losing them does not affect state correctness.
4607            // The cursor only advances when the bytes actually landed.
4608            let marker_bytes = encode_record(&marker_frame);
4609            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
4610                self.wal_consumed += marker_bytes.len() as u64;
4611            }
4612        }
4613
4614        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
4615        // stored as-is (including any nested Batch / Intern records); the
4616        // ReaderSnapshot's apply_one function handles all variants.
4617        {
4618            let derived_inserts = engine_deltas
4619                .iter()
4620                .filter(|d| d.fired)
4621                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4622                .collect();
4623            let derived_deletes = engine_deltas
4624                .iter()
4625                .filter(|d| !d.fired)
4626                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
4627                .collect();
4628            let delta = Arc::new(crate::reader::CommitDelta {
4629                records: vec![rec.clone()],
4630                derived_inserts,
4631                derived_deletes,
4632            });
4633            self.delta_tail.push(delta);
4634            self.commits_since_fold += 1;
4635            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
4636                self.fold_now();
4637            }
4638        }
4639
4640        if self.defer_events {
4641            // Group-commit drain thread: hold events until after the group
4642            // fsync so subscribers only observe durable data (R2).
4643            self.deferred_events.push(DeferredEvent {
4644                rec: rec.clone(),
4645                engine_deltas,
4646                seq,
4647                ingest,
4648            });
4649        } else {
4650            self.distribute_events(&rec, &engine_deltas, seq);
4651            self.emit_committed(&rec, ingest);
4652        }
4653        // Drift is only known after apply, so auto-rebuild cannot join the
4654        // triggering op's WAL frame. Issue RebuildRule as a second commit.
4655        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
4656        // retrigger loop is impossible if the fit succeeded, but we still
4657        // drain the flag so a leftover cannot re-enter.
4658        // One slice of any outstanding vector-index build rides here too, so a
4659        // store that is being written to finishes its build without anyone
4660        // calling `pump_index_build`. A rule that becomes whole joins the same
4661        // RebuildRule loop below.
4662        let mut rebuilds = self.engine.take_rebuild_needed();
4663        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
4664            // Not after `CreateRule`: that record's own apply already did the
4665            // rule's first slice, and pumping again here would make one
4666            // `create_rule` call do two slices' work under one lock.
4667            // Nothing pending is the overwhelmingly common case and must cost
4668            // a map lookup, not an engine swap: a store being written to has
4669            // long since populated its indexes, so the `pump_index_build`
4670            // entry point owns the not-yet-populated case on its own.
4671            if !matches!(&rec, WalRecord::CreateRule { .. })
4672                && !self.engine.builds_in_progress().is_empty()
4673            {
4674                rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
4675            }
4676            let mut failed = Vec::new();
4677            for name in rebuilds {
4678                if self.engine.rules().any(|r| r.name == name) {
4679                    // User op is already durable. A failed second commit must
4680                    // not surface as the caller's error.
4681                    if let Err(e) =
4682                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
4683                    {
4684                        eprintln!(
4685                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
4686                        );
4687                        failed.push(name);
4688                    }
4689                }
4690            }
4691            for name in failed {
4692                self.engine.queue_rebuild_needed(name);
4693            }
4694        }
4695        Ok(())
4696    }
4697
4698    /// Install a post-commit hook. Replaces any previous sink.
4699    ///
4700    /// The sink runs inside `log_then_apply` after a successful
4701    /// durable commit, while the caller still holds `&mut self`. When this
4702    /// database is behind a [`crate::SharedDb`], that means the **write
4703    /// guard is held**. The sink must never call `read` / `write` (or any
4704    /// other method) on the same `SharedDb` — the `RwLock` is not
4705    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
4706    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
4707    /// Intended examples: `std::sync::mpsc::SyncSender`,
4708    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
4709    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
4710    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
4711        self.event_sink = Some(sink);
4712    }
4713
4714    /// Whether a post-commit event sink is currently installed.
4715    pub fn has_event_sink(&self) -> bool {
4716        self.event_sink.is_some()
4717    }
4718
4719    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
4720    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
4721        self.fsync = p;
4722    }
4723
4724    /// Return the current WAL fsync cadence.
4725    pub fn fsync_policy(&self) -> FsyncPolicy {
4726        self.fsync
4727    }
4728
4729    // ── Group-commit event deferral ───────────────────────────────────────────
4730
4731    /// Enable or disable deferred event mode.
4732    ///
4733    /// When `true`, event notifications (subscription `DbEvent`s and legacy
4734    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
4735    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
4736    /// or [`discard_deferred_events`] if the fsync failed and the group must
4737    /// be treated as lost.
4738    pub fn set_deferred_events_mode(&mut self, defer: bool) {
4739        self.defer_events = defer;
4740    }
4741
4742    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
4743    /// was set to true.  Clears the buffer.
4744    ///
4745    /// Called by the drain thread AFTER a successful group fsync, so
4746    /// subscribers observe only data that is durably on disk.
4747    pub fn flush_deferred_events(&mut self) {
4748        let events = std::mem::take(&mut self.deferred_events);
4749        for de in events {
4750            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
4751            self.emit_committed(&de.rec, de.ingest);
4752        }
4753    }
4754
4755    /// Discard all buffered events without firing them.
4756    ///
4757    /// Called by the drain thread when a group fsync fails: the WAL has been
4758    /// truncated back to the pre-group offset, so the committed-but-unsynced
4759    /// ops must not be observable to subscribers.
4760    pub fn discard_deferred_events(&mut self) {
4761        self.deferred_events.clear();
4762    }
4763
4764    // ── Degraded state ────────────────────────────────────────────────────────
4765
4766    /// Mark this database as degraded.
4767    ///
4768    /// Called by the group-commit drain thread after a group fsync failure and
4769    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
4770    /// further mutations would deepen the divergence.  All subsequent calls to
4771    /// [`log_then_apply_with`] return `Err` until the database is reopened.
4772    pub fn set_degraded(&mut self) {
4773        self.degraded = true;
4774    }
4775
4776    fn emit(&self, ev: MutationEvent) {
4777        if let Some(sink) = &self.event_sink {
4778            sink(ev);
4779        }
4780    }
4781
4782    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
4783        match rec {
4784            WalRecord::Batch(inner) => {
4785                for r in inner {
4786                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
4787                        self.emit(ev);
4788                    }
4789                }
4790                match ingest {
4791                    Some((label, inserted)) => {
4792                        self.emit(MutationEvent::Ingested { label, inserted })
4793                    }
4794                    None => {
4795                        let ops = inner
4796                            .iter()
4797                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
4798                            .count();
4799                        if ops > 1 {
4800                            self.emit(MutationEvent::BatchApplied { ops });
4801                        }
4802                    }
4803                }
4804            }
4805            other => {
4806                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
4807                    self.emit(ev);
4808                }
4809            }
4810        }
4811    }
4812
4813    // -----------------------------------------------------------------------
4814    // Subscription API
4815    // -----------------------------------------------------------------------
4816
4817    /// Distribute post-commit events to all live subscribers.
4818    ///
4819    /// Build a row-key → row-data map from a [`ResultSet`].
4820    ///
4821    /// Each row is serialized to JSON to form its key; a debug fallback is used
4822    /// if serialization fails. Used by both the initial-seed path in
4823    /// [`Self::subscribe_query`] and the per-commit diff path in
4824    /// [`Self::distribute_events`] to keep the two in sync.
4825    fn result_to_row_map(
4826        result: &core_query::ResultSet,
4827    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
4828        (0..result.len())
4829            .map(|i| {
4830                let row = result.row(i).to_vec();
4831                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
4832                (key, row)
4833            })
4834            .collect()
4835    }
4836
4837    /// Collect the set of label syms touched by a WAL record.
4838    ///
4839    /// Returns `Some(set)` when every record in this commit can be attributed to
4840    /// a known label sym. Returns `None` when the commit must not be skipped:
4841    /// edge records, unresolvable key→label lookups, or any record type not in
4842    /// the explicit handled set.
4843    ///
4844    /// Handled record types and their actions:
4845    /// - `InsertNode`   → look up label in interner (fails → None)
4846    /// - `InsertNodeId` → label sym is carried directly
4847    /// - `SetProp`      → resolve key→id→label (fails → None)
4848    /// - `DeleteNode`   → resolve key→id→label (fails → None)
4849    /// - `Batch`        → recurse into every inner record
4850    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
4851    /// - everything else → None (conservative)
4852    fn commit_touched_labels(
4853        rec: &WalRecord,
4854        syms: &Interner,
4855        ids: &IdMap,
4856        labels: &[u32],
4857    ) -> Option<BTreeSet<u32>> {
4858        let mut out = BTreeSet::new();
4859        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
4860            Some(out)
4861        } else {
4862            None
4863        }
4864    }
4865
4866    fn collect_touched_labels(
4867        rec: &WalRecord,
4868        syms: &Interner,
4869        ids: &IdMap,
4870        labels: &[u32],
4871        out: &mut BTreeSet<u32>,
4872    ) -> bool {
4873        match rec {
4874            // String-key insert: the dense rewrite converts this to
4875            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
4876            // records written before the dense path was added.
4877            WalRecord::InsertNode { label, .. } => {
4878                if let Some(sym) = syms.get(label) {
4879                    out.insert(sym);
4880                    true
4881                } else {
4882                    false
4883                }
4884            }
4885            // Dense-id insert (produced by rewrite_wal_dense for every
4886            // insert_node call in the current codebase).
4887            WalRecord::InsertNodeId { label, .. } => {
4888                out.insert(*label);
4889                true
4890            }
4891            // String-key prop set: dense path converts to [Intern, SetPropId].
4892            WalRecord::SetProp { key, .. } => {
4893                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4894                    out.insert(sym);
4895                    true
4896                } else {
4897                    false
4898                }
4899            }
4900            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
4901            WalRecord::SetPropId { id, .. } => {
4902                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
4903                    out.insert(sym);
4904                    true
4905                } else {
4906                    false
4907                }
4908            }
4909            WalRecord::DeleteNode { key } => {
4910                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
4911                    out.insert(sym);
4912                    true
4913                } else {
4914                    false
4915                }
4916            }
4917            WalRecord::Batch(inner) => inner
4918                .iter()
4919                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
4920            // Intern is a pure metadata record — it does not touch any node's
4921            // label and is safe to skip for the label-skip predicate.
4922            WalRecord::Intern { .. } => true,
4923            // Edge records: always re-execute (edges can change join results).
4924            WalRecord::InsertEdge { .. }
4925            | WalRecord::DeleteEdge { .. }
4926            | WalRecord::InsertEdgeId { .. } => false,
4927            _ => false,
4928        }
4929    }
4930
4931    /// Resolve a node key to its label sym via the dense id table.
4932    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
4933    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
4934        let id = ids.get(key)?;
4935        let sym = labels.get(id as usize).copied()?;
4936        (sym != u32::MAX).then_some(sym)
4937    }
4938
4939    /// Distribute post-commit events to all live subscribers.
4940    ///
4941    /// Called from `log_then_apply_with` after apply + fsync, before the
4942    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
4943    ///
4944    /// Query subscriptions (subscribe_query) re-execute their plan on every
4945    /// call and diff the result against the previous run. Zero overhead when
4946    /// no query subscriptions are active.
4947    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
4948        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
4949            return;
4950        }
4951
4952        if !self.subscriptions.is_empty() {
4953            // Build write events from the WAL record.
4954            let write_events: Vec<DbEvent> =
4955                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
4956
4957            // Build edge events from engine deltas.  Weight is looked up from
4958            // edge_props at distribution time (after apply), so it's always fresh.
4959            let edge_events: Vec<DbEvent> = engine_deltas
4960                .iter()
4961                .map(|d| {
4962                    if d.fired {
4963                        // The score lives under the rule's declared weight_prop,
4964                        // which is not always the literal "weight".
4965                        let prop = self
4966                            .engine
4967                            .rules()
4968                            .find(|r| r.name == d.rule)
4969                            .and_then(|r| r.weight_prop.as_deref());
4970                        let weight = prop.and_then(|p| {
4971                            self.edge_props
4972                                .get(d.etype_sym, d.src_id, d.dst_id, p)
4973                                .and_then(|v| {
4974                                    if let core_storage::Value::Float(f) = v {
4975                                        Some(*f)
4976                                    } else {
4977                                        None
4978                                    }
4979                                })
4980                        });
4981                        DbEvent::EdgeFired {
4982                            rule: d.rule.clone(),
4983                            src_key: d.src_key.clone(),
4984                            dst_key: d.dst_key.clone(),
4985                            edge_type: d.edge_type.clone(),
4986                            weight,
4987                            commit_seq: seq,
4988                        }
4989                    } else {
4990                        DbEvent::EdgeRetracted {
4991                            rule: d.rule.clone(),
4992                            src_key: d.src_key.clone(),
4993                            dst_key: d.dst_key.clone(),
4994                            edge_type: d.edge_type.clone(),
4995                            commit_seq: seq,
4996                        }
4997                    }
4998                })
4999                .collect();
5000
5001            // Prune dead entries; push matching events to live ones.
5002            self.subscriptions.retain(|entry| {
5003                let Some(inner) = entry.inner.upgrade() else {
5004                    return false;
5005                };
5006                for ev in &write_events {
5007                    if event_matches(ev, &entry.filter) {
5008                        inner.push(ev.clone());
5009                    }
5010                }
5011                for ev in &edge_events {
5012                    if event_matches(ev, &entry.filter) {
5013                        inner.push(ev.clone());
5014                    }
5015                }
5016                true
5017            });
5018
5019            // Turn off delta accumulation if all subscribers dropped and no views remain.
5020            if self.subscriptions.is_empty() && self.view_store.is_empty() {
5021                self.engine.set_emit_deltas(false);
5022            }
5023        }
5024
5025        // Query subscriptions: full re-run per commit, then diff rows.
5026        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
5027        // Differential evaluation is roadmap / Phase 5.
5028        if !self.query_subscriptions.is_empty() {
5029            // Take the list out so we can call self.view() without borrow conflict.
5030            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
5031            let empty_params = BTreeMap::new();
5032            query_subs.retain_mut(|entry| {
5033                let Some(inner) = entry.inner.upgrade() else {
5034                    return false; // subscriber dropped — prune
5035                };
5036                // Label-skip: if the plan has a known scan label and this commit
5037                // can be proven to touch only different labels (and no rule-derived
5038                // edge deltas fired), the result set cannot have changed — skip.
5039                if let Some(scan_sym) = entry.scan_label {
5040                    if engine_deltas.is_empty() {
5041                        let touched =
5042                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
5043                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
5044                            return true; // safe to skip — result set unchanged
5045                        }
5046                    }
5047                }
5048                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
5049                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
5050                    Ok(r) => r,
5051                    Err(e) => {
5052                        // Keep the subscription alive; skip the diff for this commit.
5053                        // Re-run errors are transient (e.g., planner change) and
5054                        // self-heal when the next commit succeeds.
5055                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
5056                        return true;
5057                    }
5058                };
5059                // Build new row map: serialized-key → row data.
5060                let new_row_map = Self::result_to_row_map(&result);
5061                // Removed rows: in prev but not in new.
5062                for (key, row) in &entry.prev_row_map {
5063                    if !new_row_map.contains_key(key) {
5064                        inner.push(DbEvent::QueryRowRemoved {
5065                            columns: entry.columns.clone(),
5066                            row: row.clone(),
5067                        });
5068                    }
5069                }
5070                // Added rows: in new but not in prev.
5071                for (key, row) in &new_row_map {
5072                    if !entry.prev_row_map.contains_key(key) {
5073                        inner.push(DbEvent::QueryRowAdded {
5074                            columns: entry.columns.clone(),
5075                            row: row.clone(),
5076                        });
5077                    }
5078                }
5079                entry.prev_row_map = new_row_map;
5080                true
5081            });
5082            self.query_subscriptions = query_subs;
5083        }
5084    }
5085
5086    /// Returns `true` if any live subscriber or view definition requires delta
5087    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
5088    fn needs_emit_deltas(&self) -> bool {
5089        !self.view_store.is_empty()
5090            || self
5091                .subscriptions
5092                .iter()
5093                .any(|e| e.inner.upgrade().is_some())
5094    }
5095
5096    /// Convert a WAL record into `DbEvent` write events with the given seq.
5097    fn write_events_from_record(
5098        rec: &WalRecord,
5099        seq: u64,
5100        intern: &Interner,
5101        ids: &IdMap,
5102    ) -> Vec<DbEvent> {
5103        match rec {
5104            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
5105                label: label.clone(),
5106                key: key.clone(),
5107                commit_seq: seq,
5108            }],
5109            // *Id arms run after a successful apply, so resolution can only
5110            // fail on a programming error. Skip the event rather than emit a
5111            // fabricated "" that clients can't tell from a real empty value
5112            // (mirrors event_from_record returning None).
5113            WalRecord::InsertNodeId { label, key, .. } => intern
5114                .resolve(*label)
5115                .map(|label| DbEvent::NodeInserted {
5116                    label: label.to_string(),
5117                    key: key.clone(),
5118                    commit_seq: seq,
5119                })
5120                .into_iter()
5121                .collect(),
5122            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
5123                key: key.clone(),
5124                field: field.clone(),
5125                commit_seq: seq,
5126            }],
5127            WalRecord::SetPropId { id, field, .. } => ids
5128                .key_of(*id)
5129                .zip(intern.resolve(*field))
5130                .map(|(key, field)| DbEvent::PropSet {
5131                    key: key.to_string(),
5132                    field: field.to_string(),
5133                    commit_seq: seq,
5134                })
5135                .into_iter()
5136                .collect(),
5137            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
5138                key: key.clone(),
5139                field: field.clone(),
5140                commit_seq: seq,
5141            }],
5142            WalRecord::InsertEdge {
5143                edge_type,
5144                src_key,
5145                dst_key,
5146            } => vec![DbEvent::EdgeInserted {
5147                edge_type: edge_type.clone(),
5148                src: src_key.clone(),
5149                dst: dst_key.clone(),
5150                commit_seq: seq,
5151            }],
5152            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
5153                Some(DbEvent::EdgeInserted {
5154                    edge_type: intern.resolve(*etype)?.to_string(),
5155                    src: ids.key_of(*src)?.to_string(),
5156                    dst: ids.key_of(*dst)?.to_string(),
5157                    commit_seq: seq,
5158                })
5159            })()
5160            .into_iter()
5161            .collect(),
5162            WalRecord::DeleteEdge {
5163                edge_type,
5164                src_key,
5165                dst_key,
5166            } => vec![DbEvent::EdgeDeleted {
5167                edge_type: edge_type.clone(),
5168                src: src_key.clone(),
5169                dst: dst_key.clone(),
5170                commit_seq: seq,
5171            }],
5172            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
5173                key: key.clone(),
5174                commit_seq: seq,
5175            }],
5176            WalRecord::Batch(inner) => inner
5177                .iter()
5178                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
5179                .collect(),
5180            WalRecord::CreateRule { .. }
5181            | WalRecord::DeleteRule { .. }
5182            | WalRecord::RebuildRule { .. }
5183            | WalRecord::CreateView { .. }
5184            | WalRecord::DeleteView { .. }
5185            | WalRecord::EnableFulltext { .. }
5186            | WalRecord::DisableFulltext { .. }
5187            | WalRecord::EnableIndex { .. }
5188            | WalRecord::DisableIndex { .. }
5189            | WalRecord::Intern { .. }
5190            // History markers produce no DbEvent — the engine delta already
5191            // fired the EdgeFired/EdgeRetracted subscription events.
5192            | WalRecord::DerivedEdgeAdded { .. }
5193            | WalRecord::DerivedEdgeRetracted { .. }
5194            | WalRecord::RenameNode { .. } => vec![],
5195        }
5196    }
5197
5198    /// Subscribe to edge-fire and edge-retract events for one named rule.
5199    ///
5200    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
5201    /// currently registered. Dropping the returned [`Subscription`] handle
5202    /// unregisters the subscriber — no further events are queued, no
5203    /// resources leak.
5204    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
5205        if self.read_only {
5206            return Err(core_storage::GraphError::ReadOnly);
5207        }
5208        if !self.engine.rules().any(|r| r.name == rule_name) {
5209            return Err(core_storage::GraphError::RuleNotFound {
5210                name: rule_name.to_string(),
5211            });
5212        }
5213        let inner = SubInner::new(self.sub_capacity());
5214        self.subscriptions.push(SubEntry {
5215            filter: SubFilter::Rule(rule_name.to_string()),
5216            inner: std::sync::Arc::downgrade(&inner),
5217        });
5218        self.engine.set_emit_deltas(true);
5219        Ok(Subscription(inner))
5220    }
5221
5222    /// Subscribe to edge-fire and edge-retract events for **all** rules.
5223    ///
5224    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5225    /// as-of instances never commit, so `distribute_events` never runs and the
5226    /// subscription would never deliver events.
5227    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
5228        if self.read_only {
5229            return Err(core_storage::GraphError::ReadOnly);
5230        }
5231        let inner = SubInner::new(self.sub_capacity());
5232        self.subscriptions.push(SubEntry {
5233            filter: SubFilter::AllRules,
5234            inner: std::sync::Arc::downgrade(&inner),
5235        });
5236        self.engine.set_emit_deltas(true);
5237        Ok(Subscription(inner))
5238    }
5239
5240    /// Subscribe to write events: node insert/delete, prop set/remove.
5241    ///
5242    /// Does not include edge-fire / edge-retract (rule-derived edge events).
5243    ///
5244    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5245    /// as-of instances never commit, so `distribute_events` never runs and the
5246    /// subscription would never deliver events.
5247    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
5248        if self.read_only {
5249            return Err(core_storage::GraphError::ReadOnly);
5250        }
5251        let inner = SubInner::new(self.sub_capacity());
5252        self.subscriptions.push(SubEntry {
5253            filter: SubFilter::Writes,
5254            inner: std::sync::Arc::downgrade(&inner),
5255        });
5256        self.engine.set_emit_deltas(true);
5257        Ok(Subscription(inner))
5258    }
5259
5260    /// Subscribe to incremental Cypher query results.
5261    ///
5262    /// Parses and plans `cypher`; rejects the query if the plan is not in the
5263    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
5264    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
5265    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
5266    ///
5267    /// SKIP is not supported — it shifts the result window on every commit,
5268    /// causing spurious Added/Removed churn for rows whose data never changed.
5269    /// Multi-hop Expand chains are not supported; each additional MATCH clause
5270    /// widens scope beyond the documented single-scan / single-hop subset.
5271    ///
5272    /// After each successful commit, the plan is **fully re-executed** and the
5273    /// result is diffed against the previous run. Added rows produce
5274    /// [`DbEvent::QueryRowAdded`]; removed rows produce
5275    /// [`DbEvent::QueryRowRemoved`].
5276    ///
5277    /// **Full re-run per commit; use LIMIT to bound execution cost.**
5278    /// The existing 1 M intermediate-row cap applies. Differential evaluation
5279    /// is roadmap / Phase 5.
5280    ///
5281    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
5282    /// as-of instances never commit, so `distribute_events` never runs and the
5283    /// subscription would never deliver events.
5284    ///
5285    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
5286    /// or if the plan shape is not in the allowlist.
5287    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
5288        if self.read_only {
5289            return Err(GraphError::ReadOnly);
5290        }
5291        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
5292            detail: format!("lex: {e}"),
5293        })?;
5294        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
5295            detail: format!("parse: {e}"),
5296        })?;
5297        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
5298            detail: format!("plan: {e}"),
5299        })?;
5300        if !is_subscribable(&ops) {
5301            return Err(GraphError::QueryError {
5302                detail: "subscribe_query only supports allowlisted plan shapes: \
5303                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
5304                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
5305                         Not supported: multi-hop Expand chains, SKIP (creates \
5306                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
5307                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
5308                         Use LIMIT to bound re-execution cost."
5309                    .to_string(),
5310            });
5311        }
5312        // Execute once to capture initial state (initial rows are not emitted as
5313        // events — the subscriber learns the baseline via the first query call).
5314        let empty_params = BTreeMap::new();
5315        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
5316            GraphError::QueryError {
5317                detail: format!("execute: {e}"),
5318            }
5319        })?;
5320        let columns = initial.columns().to_vec();
5321        let prev_row_map = Self::result_to_row_map(&initial);
5322        let inner = SubInner::new(self.sub_capacity());
5323        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
5324        // or unrecognized leading scan → None (always re-execute).
5325        let scan_label = extract_scan_label(&ops, &mut self.syms);
5326        self.query_subscriptions.push(QuerySubEntry {
5327            ops,
5328            columns,
5329            prev_row_map,
5330            inner: std::sync::Arc::downgrade(&inner),
5331            scan_label,
5332        });
5333        Ok(Subscription(inner))
5334    }
5335
5336    /// Queue capacity used for new subscriptions.
5337    fn sub_capacity(&self) -> usize {
5338        self.sub_capacity
5339    }
5340
5341    /// Override per-subscriber queue capacity for subsequently created
5342    /// subscriptions on this db instance.
5343    ///
5344    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
5345    /// value in tests to exercise the [`DbEvent::Lagged`] path without
5346    /// generating tens of thousands of events.
5347    ///
5348    /// This is a test-support escape hatch. Calling it in production reduces
5349    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
5350    /// to discourage accidental production use.
5351    #[doc(hidden)]
5352    pub fn set_sub_capacity(&mut self, capacity: usize) {
5353        self.sub_capacity = capacity;
5354    }
5355
5356    // -----------------------------------------------------------------------
5357
5358    /// Start an atomic batch.
5359    ///
5360    /// The returned [`BatchBuilder`] borrows `self` mutably until
5361    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
5362    /// validation, no WAL I/O. `commit` validates every queued op against
5363    /// live state plus preceding ops in this batch (duplicate key inside
5364    /// the batch is `Err`; an edge between two nodes created earlier in
5365    /// the batch is valid; `delete_node` then insert of the same key is a
5366    /// fresh identity). Validation never mutates the database. Any failure
5367    /// leaves WAL bytes and in-memory state identical to before `commit`.
5368    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
5369    /// and each inner record is applied in order so rules fire per record.
5370    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
5371    ///
5372    /// **Rule-window limitation:** batch validation cannot see edges that a
5373    /// rule created earlier in the *same* batch will derive at apply time, so
5374    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
5375    /// where sequential calls would return `Err(RuleOwned)`. State integrity
5376    /// is unaffected (idempotent apply, provenance intact). Create rules in
5377    /// their own batch, or sequentially, when later ops may touch derived
5378    /// edges.
5379    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
5380        BatchBuilder {
5381            db: self,
5382            ops: Vec::new(),
5383        }
5384    }
5385
5386    /// Closure-style atomic write batch.
5387    ///
5388    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
5389    /// then committing. All ops queued inside `build` are validated in order and
5390    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
5391    /// once per inner record, in order, after commit — semantically identical to
5392    /// sequential single-op writes.
5393    ///
5394    /// **Error semantics — validate-then-apply.** `build` queues ops without
5395    /// touching the database. [`BatchBuilder::commit`] validates every op against
5396    /// live state plus earlier ops in this batch before writing anything. If op N
5397    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
5398    /// entire batch is rejected: no WAL bytes are written and no in-memory state
5399    /// changes. The database is identical to its state before `write_batch` was
5400    /// called.
5401    ///
5402    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
5403    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
5404    /// either fully applied or not at all. However, while applying a committed
5405    /// batch, concurrent readers may observe intermediate states as ops are applied
5406    /// sequentially in memory. There is no interactive transaction isolation in v1.
5407    /// This is documented as "crash-atomic write batches; no interactive
5408    /// transactions or read isolation."
5409    ///
5410    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
5411    /// writes zero WAL bytes and returns `(0, 0)`.
5412    ///
5413    /// # Example
5414    ///
5415    /// ```rust,ignore
5416    /// let (nodes, edges) = db.write_batch(|b| {
5417    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
5418    ///     b.insert_node("Person", "bob", vec![]);
5419    ///     b.insert_edge("KNOWS", "alice", "bob");
5420    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
5421    ///     b.delete_node("old_key");
5422    /// })?;
5423    /// // One fsync; on crash replay: all five ops land or none do.
5424    /// ```
5425    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
5426    where
5427        C: FnOnce(&mut BatchBuilder<'_, F>),
5428    {
5429        let mut b = self.batch();
5430        build(&mut b);
5431        b.commit()
5432    }
5433
5434    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
5435    /// auto-declared KeyMatch rules (if any) first, then the accepted node
5436    /// inserts, so incremental fire sees the new rules. Per-row key problems
5437    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
5438    /// `Err` means nothing was applied.
5439    ///
5440    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
5441    /// distinct source labels sharing an FK field each get their own rule.
5442    pub fn ingest(
5443        &mut self,
5444        label: &str,
5445        rows: Vec<BTreeMap<String, Value>>,
5446        opts: &IngestOptions,
5447    ) -> Result<IngestReport> {
5448        self.ingest_with_edges(label, rows, opts, &[])
5449    }
5450
5451    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
5452    /// A failing edge rejects the whole request; nothing is applied.
5453    pub fn ingest_with_edges(
5454        &mut self,
5455        label: &str,
5456        rows: Vec<BTreeMap<String, Value>>,
5457        opts: &IngestOptions,
5458        edges: &[(String, String, String)],
5459    ) -> Result<IngestReport> {
5460        crate::ingest::run(self, label, rows, opts, edges)
5461    }
5462
5463    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
5464    ///
5465    /// JSON `null` fields are silently omitted (not stored, not a row error).
5466    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
5467    /// Parse failures and a top-level value that is not an array of objects
5468    /// return [`GraphError::IngestError`].
5469    pub fn ingest_json(
5470        &mut self,
5471        label: &str,
5472        json: &str,
5473        opts: &IngestOptions,
5474    ) -> Result<IngestReport> {
5475        crate::ingest::run_json(self, label, json, opts)
5476    }
5477
5478    fn commit_logged_batch(
5479        &mut self,
5480        ops: Vec<BatchOp>,
5481        ingest: Option<(String, usize)>,
5482        // Two-source rule: write_batch_authz threads authz here directly (never
5483        // touches pending_write_authz); query_write_authz sets the field instead
5484        // and passes None.  Only one source is non-None per call.
5485        param_authz: Option<WriteAuthz>,
5486    ) -> Result<(usize, usize)> {
5487        // Read-only guard: catches empty-batch calls before the early-return
5488        // that skips log_then_apply_with, ensuring all mutation entry points fail.
5489        if self.read_only {
5490            return Err(GraphError::ReadOnly);
5491        }
5492        // Ensure provenance is decoded before MutPreview accesses it
5493        // (note_delete_rule / is_rule_owned may call engine.provenance()).
5494        self.engine.ensure_provenance_loaded_mut();
5495
5496        // ── Authz pre-check ──────────────────────────────────────────────────
5497        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
5498        // produces no WAL frame (all-or-nothing at the authz boundary extends
5499        // the existing validate-then-apply contract to role-scope checks).
5500        //
5501        // `batch_created` tracks key→label for nodes created by earlier ops in
5502        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
5503        // as visible without needing to call `self.ids.get` on not-yet-committed
5504        // keys (they won't be there yet).
5505        //
5506        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
5507        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
5508        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
5509        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
5510        if let Some(ref authz) = authz_opt {
5511            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
5512            for op in &ops {
5513                self.check_single_op_authz(authz, op, &batch_created)?;
5514                // Update batch_created after a passing authz check so that
5515                // subsequent ops in this batch see the nodes as "about to exist".
5516                match op {
5517                    BatchOp::InsertNode { label, key, .. } => {
5518                        // Only track genuinely new nodes (absent from the
5519                        // snapshot at authz-check time). A pre-existing visible
5520                        // key would be a DuplicateKey — not a real creation —
5521                        // so MutPreview handles it. Letting it into batch_created
5522                        // would allow a later SetProp to bypass update_labels
5523                        // via the "batch-created → always updatable" ruling
5524                        // (delete+recreate exploit, fix for I1 review round 2).
5525                        //
5526                        // Accepted edge: for a delete+recreate-with-different-
5527                        // label batch, node_status resolves the pre-delete
5528                        // (store) label for any subsequent update checks. This
5529                        // grants no net-new capability — a role that can delete+
5530                        // create can already place arbitrary props via
5531                        // InsertNode's own props field.
5532                        if self.ids.get(key.as_str()).is_none() {
5533                            batch_created.insert(key.clone(), label.clone());
5534                        }
5535                    }
5536                    BatchOp::InsertEdgeUpsert {
5537                        placeholder_label,
5538                        src_key,
5539                        dst_key,
5540                        ..
5541                    } => {
5542                        // Both endpoints will be created if not already in store.
5543                        for ep_key in [src_key, dst_key] {
5544                            if self.ids.get(ep_key.as_str()).is_none()
5545                                && !batch_created.contains_key(ep_key.as_str())
5546                            {
5547                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
5548                            }
5549                        }
5550                    }
5551                    _ => {}
5552                }
5553            }
5554        }
5555
5556        let recs = {
5557            let mut preview = MutPreview::new(self);
5558            let mut recs = Vec::with_capacity(ops.len());
5559            for op in ops {
5560                match op {
5561                    BatchOp::InsertNode { label, key, props } => {
5562                        preview.check_insert_node(&key)?;
5563                        preview.note_insert_node(&key, &props);
5564                        recs.push(WalRecord::InsertNode { label, key, props });
5565                    }
5566                    BatchOp::InsertEdge {
5567                        edge_type,
5568                        src_key,
5569                        dst_key,
5570                    } => {
5571                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5572                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5573                            recs.push(WalRecord::InsertEdge {
5574                                edge_type,
5575                                src_key,
5576                                dst_key,
5577                            });
5578                        }
5579                    }
5580                    BatchOp::SetProp { key, field, value } => {
5581                        if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
5582                            return Err(GraphError::ViewPropReadOnly {
5583                                view_name: view_name.to_string(),
5584                            });
5585                        }
5586                        preview.check_live_key(&key)?;
5587                        preview.note_set_prop(&key, &field, &value);
5588                        recs.push(WalRecord::SetProp { key, field, value });
5589                    }
5590                    BatchOp::RemoveProp { key, field } => {
5591                        if preview.prepare_remove_prop(&key, &field)? {
5592                            preview.note_remove_prop(&key, &field);
5593                            recs.push(WalRecord::RemoveProp { key, field });
5594                        }
5595                    }
5596                    BatchOp::DeleteEdge {
5597                        edge_type,
5598                        src_key,
5599                        dst_key,
5600                    } => {
5601                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
5602                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
5603                            recs.push(WalRecord::DeleteEdge {
5604                                edge_type,
5605                                src_key,
5606                                dst_key,
5607                            });
5608                        }
5609                    }
5610                    BatchOp::DeleteNode { key } => {
5611                        preview.check_live_key(&key)?;
5612                        preview.note_delete_node(&key);
5613                        recs.push(WalRecord::DeleteNode { key });
5614                    }
5615                    BatchOp::CreateRule(def) => {
5616                        preview.check_create_rule(&def)?;
5617                        let def_bytes =
5618                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5619                                detail: format!("serialize rule: {e}"),
5620                            })?;
5621                        preview.note_create_rule(&def);
5622                        recs.push(WalRecord::CreateRule { def_bytes });
5623                    }
5624                    BatchOp::DeleteRule { name } => {
5625                        preview.check_delete_rule(&name)?;
5626                        preview.note_delete_rule(&name);
5627                        recs.push(WalRecord::DeleteRule { name });
5628                    }
5629                    BatchOp::RenameNode { old_key, new_key } => {
5630                        preview.check_rename_node(&old_key, &new_key)?;
5631                        preview.note_rename_node(&old_key, &new_key);
5632                        recs.push(WalRecord::RenameNode { old_key, new_key });
5633                    }
5634                    BatchOp::InsertEdgeUpsert {
5635                        edge_type,
5636                        src_key,
5637                        dst_key,
5638                        placeholder_label,
5639                    } => {
5640                        // Auto-create any missing endpoints as plain InsertNode ops.
5641                        // Rules fire and last-change is updated for each created node.
5642                        for key in [&src_key, &dst_key] {
5643                            if !preview.has_key(key) {
5644                                preview.check_insert_node(key)?;
5645                                preview.note_insert_node(key, &[]);
5646                                recs.push(WalRecord::InsertNode {
5647                                    label: placeholder_label.clone(),
5648                                    key: key.clone(),
5649                                    props: vec![],
5650                                });
5651                            }
5652                        }
5653                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
5654                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
5655                            recs.push(WalRecord::InsertEdge {
5656                                edge_type,
5657                                src_key,
5658                                dst_key,
5659                            });
5660                        }
5661                    }
5662                }
5663            }
5664            recs
5665        };
5666        if recs.is_empty() {
5667            return Ok((0, 0));
5668        }
5669        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
5670        // *Id form, so only the dense variants can appear in `recs` here.
5671        let recs = self.rewrite_wal_dense(recs)?;
5672        // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
5673        // namespace the node is already in is a no-op and is dropped there. An
5674        // empty `Batch` frame would still take a commit sequence and a WAL
5675        // record, so a batch that turns out to be nothing writes nothing.
5676        if recs.is_empty() {
5677            return Ok((0, 0));
5678        }
5679        let nodes_inserted = recs
5680            .iter()
5681            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
5682            .count();
5683        let edges_inserted = recs
5684            .iter()
5685            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
5686            .count();
5687        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
5688        // under Strict.  Pass self.fsync directly so Strict stays Strict —
5689        // wal_needs_sync(Strict, _) always returns true regardless of op count.
5690        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
5691        // short-circuit on single-op batches and silently skip the fsync.
5692        // Batched fsyncs only for multi-op batches; Relaxed always skips.
5693        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
5694        Ok((nodes_inserted, edges_inserted))
5695    }
5696
5697    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5698        self.commit_logged_batch(ops, None, None)
5699    }
5700
5701    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
5702    /// and the group-commit drain thread, which do a single group fsync later.
5703    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
5704        // Restore fsync policy even on panic via a raw-pointer drop guard.
5705        // A panic here would poison the RwLock anyway, but the correct policy
5706        // must be in place if the guard is ever unwrapped.
5707        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
5708        impl Drop for RestoreFsync {
5709            fn drop(&mut self) {
5710                // SAFETY: the pointer is valid for the full duration of
5711                // commit_batch_nosync; the guard is dropped before the frame
5712                // returns, and GraphDb outlives this frame.
5713                unsafe {
5714                    *self.0 = self.1;
5715                }
5716            }
5717        }
5718        let saved = self.fsync;
5719        // SAFETY: raw pointer into self; guard dropped within this frame.
5720        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
5721        self.fsync = FsyncPolicy::Relaxed;
5722        self.commit_logged_batch(ops, None, None)
5723    }
5724
5725    /// Commit multiple op-batches as a **group**: each submission gets its own
5726    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
5727    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
5728    ///
5729    /// # Durability semantics
5730    ///
5731    /// A crash before the group fsync may lose **all** submissions in the group.
5732    /// A crash after the group fsync preserves all of them.  No submission is
5733    /// ever torn: each WAL frame is either fully applied on replay or dropped
5734    /// in its entirety (CRC-protected frame boundaries).
5735    ///
5736    /// Events and subscription notifications fire per-submission immediately
5737    /// after apply, which may be before the group fsync.  From a subscriber's
5738    /// perspective this is equivalent to the `Relaxed` durability window.
5739    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
5740    /// fsync, so from their perspective durability is fully guaranteed.
5741    ///
5742    /// # MVCC interplay
5743    ///
5744    /// Each submission records its own `CommitDelta`; the fold-every-K counter
5745    /// increments per submission (not per group), preserving existing reader
5746    /// snapshot semantics.
5747    ///
5748    /// # Returns
5749    ///
5750    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
5751    /// in order.  Failures are per-submission (validation errors); the group
5752    /// fsync error (if any) is returned as the second tuple element.
5753    pub fn commit_group(
5754        &mut self,
5755        groups: Vec<Vec<BatchOp>>,
5756    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
5757        let mut results = Vec::with_capacity(groups.len());
5758        for ops in groups {
5759            results.push(self.commit_batch_nosync(ops));
5760        }
5761        let any_ok = results.iter().any(|r| r.is_ok());
5762        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
5763            self.fs
5764                .sync(core_storage::fs::FileId::Wal)
5765                .map_err(GraphError::Io)
5766                .err()
5767        } else {
5768            None
5769        };
5770        (results, sync_err)
5771    }
5772
5773    /// Like [`commit_group`] but skips the group fsync entirely.
5774    ///
5775    /// Used by the drain thread to apply submissions under the write lock and
5776    /// then perform the single fsync OUTSIDE the lock (via
5777    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
5778    /// to concurrent readers.
5779    pub fn commit_group_nosync(
5780        &mut self,
5781        groups: Vec<Vec<BatchOp>>,
5782    ) -> Vec<Result<(usize, usize)>> {
5783        let mut results = Vec::with_capacity(groups.len());
5784        for ops in groups {
5785            results.push(self.commit_batch_nosync(ops));
5786        }
5787        results
5788    }
5789
5790    pub fn insert_node(
5791        &mut self,
5792        label: &str,
5793        key: &str,
5794        props: Vec<(String, Value)>,
5795    ) -> Result<()> {
5796        if self.read_only {
5797            return Err(GraphError::ReadOnly);
5798        }
5799        MutPreview::new(self).check_insert_node(key)?;
5800        self.log_dense(vec![WalRecord::InsertNode {
5801            label: label.into(),
5802            key: key.into(),
5803            props,
5804        }])
5805    }
5806
5807    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5808        if self.read_only {
5809            return Err(GraphError::ReadOnly);
5810        }
5811        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
5812            return Ok(false);
5813        }
5814        self.log_dense(vec![WalRecord::InsertEdge {
5815            edge_type: edge_type.into(),
5816            src_key: src_key.into(),
5817            dst_key: dst_key.into(),
5818        }])?;
5819        Ok(true)
5820    }
5821
5822    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
5823        if self.read_only {
5824            return Err(GraphError::ReadOnly);
5825        }
5826        if let Some(view_name) = self.view_store.view_for_prop(field) {
5827            return Err(GraphError::ViewPropReadOnly {
5828                view_name: view_name.to_string(),
5829            });
5830        }
5831        MutPreview::new(self).check_live_key(key)?;
5832        self.log_dense(vec![WalRecord::SetProp {
5833            key: key.into(),
5834            field: field.into(),
5835            value,
5836        }])
5837    }
5838
5839    /// Set several properties on one live node in a single WAL commit.
5840    ///
5841    /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
5842    /// names, live key, the `ns` immutability rule and its type — is evaluated
5843    /// for the whole list before any record is logged. The first refusal
5844    /// returns and the node is unchanged. An empty list writes nothing.
5845    pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
5846        if self.read_only {
5847            return Err(GraphError::ReadOnly);
5848        }
5849        MutPreview::new(self).check_live_key(key)?;
5850        for (field, _) in &props {
5851            if let Some(view_name) = self.view_store.view_for_prop(field) {
5852                return Err(GraphError::ViewPropReadOnly {
5853                    view_name: view_name.to_string(),
5854                });
5855            }
5856        }
5857        if props.is_empty() {
5858            return Ok(());
5859        }
5860        self.write_batch(|b| {
5861            for (field, value) in props {
5862                b.set_prop(key, &field, value);
5863            }
5864        })
5865        .map(|_| ())
5866    }
5867
5868    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
5869    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
5870    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
5871        if self.read_only {
5872            return Err(GraphError::ReadOnly);
5873        }
5874        if let Some(view_name) = self.view_store.view_for_prop(field) {
5875            return Err(GraphError::ViewPropReadOnly {
5876                view_name: view_name.to_string(),
5877            });
5878        }
5879        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
5880            return Ok(false);
5881        }
5882        self.log_then_apply(WalRecord::RemoveProp {
5883            key: key.into(),
5884            field: field.into(),
5885        })?;
5886        Ok(true)
5887    }
5888
5889    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
5890    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
5891    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
5892    /// (the rule would just put the edge back; delete or change the rule).
5893    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
5894        if self.read_only {
5895            return Err(GraphError::ReadOnly);
5896        }
5897        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
5898            return Ok(false);
5899        }
5900        self.log_then_apply(WalRecord::DeleteEdge {
5901            edge_type: edge_type.into(),
5902            src_key: src_key.into(),
5903            dst_key: dst_key.into(),
5904        })?;
5905        Ok(true)
5906    }
5907
5908    /// Delete a live node. Unknown or already-tombstoned keys are
5909    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
5910    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
5911    /// (crash window) is a clean no-op.
5912    ///
5913    /// Returns a [`DeleteReport`] with counts of manual and derived edges
5914    /// removed (computed from live state before the deletion is applied).
5915    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
5916        if self.read_only {
5917            return Err(GraphError::ReadOnly);
5918        }
5919        // Provenance must be loaded before we query provenance_touching.
5920        self.engine.ensure_provenance_loaded_mut();
5921        let id = self
5922            .ids
5923            .get(key)
5924            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
5925
5926        // Count edges before the delete is applied so we can report counts.
5927        let derived_set: BTreeSet<(u32, u32, u32)> = self
5928            .engine
5929            .provenance_touching(id)
5930            .map(|(_, etype, src, dst)| (etype, src, dst))
5931            .collect();
5932        let derived_edges = derived_set.len() as u64;
5933
5934        let mut total_topo = 0u64;
5935        let tv = self.topo_view();
5936        for et in tv.etypes() {
5937            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
5938                + tv.neighbors(et, Direction::In, id).len() as u64;
5939        }
5940        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
5941        // triples in both the topo scan (Out and In from id) and in provenance_touching.
5942        // The subtraction remains correct because both counts include both directions.
5943        let manual_edges = total_topo.saturating_sub(derived_edges);
5944
5945        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
5946        Ok(DeleteReport {
5947            manual_edges,
5948            derived_edges,
5949        })
5950    }
5951
5952    /// Rename a live node's key.  The dense id (and therefore all edges,
5953    /// props, history, and last-change tracking) is unaffected.
5954    ///
5955    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
5956    /// Returns `Err(DuplicateKey)` if `new` is already live.
5957    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
5958        if self.read_only {
5959            return Err(GraphError::ReadOnly);
5960        }
5961        MutPreview::new(self).check_rename_node(old, new)?;
5962        self.log_then_apply(WalRecord::RenameNode {
5963            old_key: old.into(),
5964            new_key: new.into(),
5965        })
5966    }
5967
5968    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
5969    /// `None` if the rule does not exist or is not approximate.
5970    ///
5971    /// The drift counter increments on IVF insert/remove after the last fit.
5972    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
5973    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
5974    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
5975        // SideIvfExport = (centroids, node→cluster, drift)
5976        self.engine
5977            .export_ivf_state()
5978            .remove(rule)
5979            .map(|(_src, dst)| dst.2)
5980    }
5981
5982    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
5983    /// Validation and duplicate-name check run before logging so invalid rules
5984    /// never enter the WAL.
5985    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
5986        if self.read_only {
5987            return Err(GraphError::ReadOnly);
5988        }
5989        MutPreview::new(self).check_create_rule(&def)?;
5990        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
5991            detail: format!("serialize rule: {e}"),
5992        })?;
5993        self.log_then_apply(WalRecord::CreateRule { def_bytes })
5994    }
5995
5996    /// Override this handle's HNSW build-slice size, or `None` to restore
5997    /// [`core_rules::HNSW_BUILD_BATCH`].
5998    ///
5999    /// Exposed for tests that need a small slice without a large corpus; not
6000    /// part of the stable surface.
6001    #[doc(hidden)]
6002    pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
6003        self.engine.set_hnsw_build_batch(batch);
6004    }
6005
6006    /// Rules whose vector index is still being built, in name order.
6007    ///
6008    /// The same list [`GraphDb::stats`] reports per rule in `building`.
6009    /// After a clean open this includes a build a snapshot cut short, so
6010    /// `serve`'s ticker can pump it without a write.
6011    pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
6012        self.engine.builds_in_progress()
6013    }
6014
6015    /// Advance any vector index still building and backfill each rule that
6016    /// finishes. Returns what is still outstanding.
6017    ///
6018    /// A map lookup when nothing is pending, so it is cheap to call on a timer.
6019    /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
6020    /// inserts per pending rule per call, so a caller can drive a large build
6021    /// to completion without ever holding the lock for more than a slice.
6022    ///
6023    /// A rule that finishes here is backfilled through the same
6024    /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
6025    /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
6026    /// and appear all at once.
6027    ///
6028    /// Every ordinary write pumps one slice on its own (see the post-commit
6029    /// hook in `log_then_apply_with`), so this is for quiescent stores and for
6030    /// operators who want the build finished before traffic arrives.
6031    pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
6032        Ok(self.pump_index_build_reporting()?.1)
6033    }
6034
6035    /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
6036    /// call finished, so a progress display can say so.
6037    ///
6038    /// A build can be registered and completed inside a single call — that is
6039    /// what a mid-build snapshot looks like on reopen, where the index scan
6040    /// finishes the graph and only the backfill is outstanding — and the
6041    /// outstanding list alone cannot show that anything happened.
6042    pub fn pump_index_build_reporting(
6043        &mut self,
6044    ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
6045        // A read-only handle cannot issue the `RebuildRule` a finished build
6046        // needs, so it would advance the index and then silently fail to
6047        // produce the edges. Refusing is the honest answer.
6048        if self.read_only {
6049            return Err(GraphError::ReadOnly);
6050        }
6051        let finished = self.pump_one_slice();
6052        for done in &finished {
6053            // The index is whole but the rule still owns no edges. A failed
6054            // second commit must leave the rule re-pumpable rather than
6055            // silently edge-less, so the error is surfaced here — unlike the
6056            // post-commit hook, this call is not riding someone else's commit.
6057            self.log_then_apply(WalRecord::RebuildRule {
6058                name: done.rule.clone(),
6059            })?;
6060        }
6061        Ok((finished, self.engine.builds_in_progress()))
6062    }
6063
6064    /// Run the deferred candidate-index build, if it is still owed, against the
6065    /// graph as it stands *now* — before the caller applies anything.
6066    ///
6067    /// A no-op bool test once the indexes are populated, which is after the
6068    /// first write of the handle's life, and for a store with no rules at all.
6069    fn populate_indexes_before_write(&mut self) {
6070        if !self.engine.needs_index_population() {
6071            return;
6072        }
6073        // The retained snapshot blobs arrive with the V8 base sections; without
6074        // them the scan would rebuild every graph the snapshot already holds.
6075        self.ensure_v8_base_sections_loaded();
6076        if !self.engine.needs_index_population() {
6077            return;
6078        }
6079        let mut eng = std::mem::take(&mut self.engine);
6080        {
6081            let gm = make_graph_mut(
6082                &self.ids,
6083                &mut self.syms,
6084                &self.labels,
6085                build_props_view(&self.props, &self.base),
6086                &mut self.topo,
6087                &self.base,
6088                &mut self.edge_props,
6089            );
6090            eng.populate_indexes(&gm);
6091        }
6092        self.engine = eng;
6093    }
6094
6095    /// One slice of build work for every pending rule. Returns the rules whose
6096    /// index just became whole, which the caller must `RebuildRule`.
6097    ///
6098    /// Goes through the engine even with nothing pending when the indexes have
6099    /// not been populated yet: that call adopts the persisted graphs and, for
6100    /// an incomplete blob already registered at open, leaves the remainder to
6101    /// this slice rather than inserting it inline.
6102    fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
6103        // The retained snapshot blobs — and the id count an interrupted build
6104        // is recognised against — arrive with the V8 base sections, which a
6105        // clean open reads lazily. Without this a freshly opened handle pumps
6106        // against empty retained state and concludes there is nothing to do,
6107        // which is precisely the store `build-index` exists for.
6108        self.ensure_v8_base_sections_loaded();
6109        let mut eng = std::mem::take(&mut self.engine);
6110        let finished = {
6111            let mut gm = make_graph_mut(
6112                &self.ids,
6113                &mut self.syms,
6114                &self.labels,
6115                build_props_view(&self.props, &self.base),
6116                &mut self.topo,
6117                &self.base,
6118                &mut self.edge_props,
6119            );
6120            eng.pump_index_build(&mut gm)
6121        };
6122        self.engine = eng;
6123        finished
6124    }
6125
6126    /// Register a sliced build a snapshot cut short, from blobs with
6127    /// `complete == false`.
6128    ///
6129    /// Peeks the V8 mmap for incomplete entries without copying complete
6130    /// graphs. V5–V7 already hold the blobs in the engine from restore.
6131    fn register_outstanding_index_builds(&mut self) {
6132        if self.engine.indexes_populated() {
6133            return;
6134        }
6135        let extra = self.collect_incomplete_hnsw_blobs();
6136        let mut eng = std::mem::take(&mut self.engine);
6137        {
6138            let gm = make_graph_mut(
6139                &self.ids,
6140                &mut self.syms,
6141                &self.labels,
6142                build_props_view(&self.props, &self.base),
6143                &mut self.topo,
6144                &self.base,
6145                &mut self.edge_props,
6146            );
6147            eng.register_incomplete_hnsw_builds(&extra, &gm);
6148        }
6149        self.engine = eng;
6150    }
6151
6152    /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
6153    /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
6154    /// engine's retained map instead).
6155    fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
6156        let Some(base) = &self.base else {
6157            return BTreeMap::new();
6158        };
6159        let Ok(archived) = base.hnsw_section() else {
6160            return BTreeMap::new();
6161        };
6162        archived
6163            .rules
6164            .iter()
6165            .filter_map(|e| {
6166                let src = e.src_blob.as_slice();
6167                let dst = e.dst_blob.as_slice();
6168                if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
6169                    || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
6170                {
6171                    Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
6172                } else {
6173                    None
6174                }
6175            })
6176            .collect()
6177    }
6178
6179    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
6180    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
6181        if self.read_only {
6182            return Err(GraphError::ReadOnly);
6183        }
6184        MutPreview::new(self).check_delete_rule(name)?;
6185        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
6186    }
6187
6188    /// Return a snapshot of all registered rules.
6189    pub fn rules(&self) -> Vec<RuleDef> {
6190        self.engine.rules().cloned().collect()
6191    }
6192
6193    // -----------------------------------------------------------------------
6194    // Rule suggestion API
6195    // -----------------------------------------------------------------------
6196
6197    /// Profile the database and suggest linking rules with previewed edge counts.
6198    ///
6199    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
6200    /// sampling. Suggestions are sorted by estimated edge count (descending).
6201    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
6202    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
6203        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
6204    }
6205
6206    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
6207    /// reproducibility. Same seed + same data = identical output.
6208    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
6209        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
6210            .suggestions
6211    }
6212
6213    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
6214    ///
6215    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
6216    /// and a `truncated` flag indicating whether the global budget fired before all
6217    /// candidates were evaluated.
6218    pub fn suggest_rules_with_config(
6219        &self,
6220        config: &core_rules::suggest::SuggestConfig,
6221        seed: u64,
6222    ) -> core_rules::SuggestReport {
6223        use std::collections::BTreeMap;
6224
6225        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
6226        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
6227        for id in 0..self.ids.len() as u32 {
6228            let Some(key) = self.ids.key_of(id) else {
6229                continue;
6230            };
6231            let Some(&sym) = self.labels.get(id as usize) else {
6232                continue;
6233            };
6234            if sym == u32::MAX {
6235                continue; // tombstoned
6236            }
6237            let Some(label) = self.syms.resolve(sym) else {
6238                continue;
6239            };
6240            label_nodes
6241                .entry(label.to_string())
6242                .or_default()
6243                .push((id, key.to_string()));
6244        }
6245
6246        let existing = self.rules();
6247        let pv = build_props_view(&self.props, &self.base);
6248        let all_fields: Vec<String> = pv.field_names();
6249
6250        core_rules::suggest::suggest_rules(
6251            &label_nodes,
6252            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
6253            &all_fields,
6254            &existing,
6255            config,
6256            seed,
6257        )
6258    }
6259
6260    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
6261    /// plus later mutations replay identically (rebuild is a pure function
6262    /// of state).
6263    ///
6264    /// Only exit from the tripped latch: if the full desired set fits the
6265    /// budget, it is applied completely and `tripped` clears; if it still
6266    /// exceeds the budget, provenance is left untouched and `tripped` stays
6267    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
6268    /// Unknown rule → `RuleNotFound`, nothing logged.
6269    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
6270        if self.read_only {
6271            return Err(GraphError::ReadOnly);
6272        }
6273        if !self.engine.rules().any(|r| r.name == name) {
6274            return Err(GraphError::RuleNotFound { name: name.into() });
6275        }
6276        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
6277    }
6278
6279    // -----------------------------------------------------------------------
6280    // Materialized view API
6281    // -----------------------------------------------------------------------
6282
6283    /// Register a new materialized property view, backfill its values for all
6284    /// existing nodes, and WAL-log the definition.
6285    ///
6286    /// # Errors
6287    /// - `ReadOnly`: called on an as-of instance.
6288    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
6289    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
6290        if self.read_only {
6291            return Err(GraphError::ReadOnly);
6292        }
6293        // Pre-validate before WAL write.
6294        def.validate()
6295            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
6296        if self.view_store.has_view(&def.name) {
6297            return Err(GraphError::RuleInvalid {
6298                detail: format!("view {:?} already exists", def.name),
6299            });
6300        }
6301        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
6302            return Err(GraphError::RuleInvalid {
6303                detail: format!(
6304                    "view_prop {:?} is already used by view {:?}",
6305                    def.view_prop, existing
6306                ),
6307            });
6308        }
6309        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6310            detail: format!("serialize view: {e}"),
6311        })?;
6312        // Enable delta accumulation before the view is registered so subsequent
6313        // incremental edge events reach view maintenance from this point onward.
6314        // (The backfill inside create_view reads topo directly; it does not rely
6315        // on pending deltas.)
6316        self.engine.set_emit_deltas(true);
6317        self.log_then_apply(WalRecord::CreateView { def_bytes })
6318    }
6319
6320    /// Remove a named view and delete its values from every node.
6321    ///
6322    /// # Errors
6323    /// - `ReadOnly`: called on an as-of instance.
6324    /// - `RuleNotFound`: view does not exist.
6325    pub fn delete_view(&mut self, name: &str) -> Result<()> {
6326        if self.read_only {
6327            return Err(GraphError::ReadOnly);
6328        }
6329        if !self.view_store.has_view(name) {
6330            return Err(GraphError::RuleNotFound { name: name.into() });
6331        }
6332        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
6333        // After deletion, disable accumulation if no listeners remain.
6334        if !self.needs_emit_deltas() {
6335            self.engine.set_emit_deltas(false);
6336        }
6337        result
6338    }
6339
6340    /// Snapshot of all registered view definitions.
6341    pub fn views(&self) -> Vec<ViewDef> {
6342        self.view_store.views().cloned().collect()
6343    }
6344
6345    // -----------------------------------------------------------------------
6346    // Full-text-lite API
6347    // -----------------------------------------------------------------------
6348
6349    /// Enable full-text indexing for all nodes of `label` on property `field`.
6350    ///
6351    /// After this call, every subsequent write to `(label, field)` is reflected
6352    /// in the index incrementally.  Existing nodes are backfilled immediately.
6353    /// The declaration is persisted as a WAL record; the index itself is rebuilt
6354    /// from scratch on re-open (no snapshot format changes).
6355    ///
6356    /// # Errors
6357    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6358    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6359    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6360        if self.read_only {
6361            return Err(GraphError::ReadOnly);
6362        }
6363        if self.fulltext.is_enabled(label, field) {
6364            return Err(GraphError::RuleInvalid {
6365                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
6366            });
6367        }
6368        self.log_then_apply(WalRecord::EnableFulltext {
6369            label: label.into(),
6370            field: field.into(),
6371        })
6372    }
6373
6374    /// Disable full-text indexing for `(label, field)` and drop its postings.
6375    ///
6376    /// # Errors
6377    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6378    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6379    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
6380        if self.read_only {
6381            return Err(GraphError::ReadOnly);
6382        }
6383        if !self.fulltext.is_enabled(label, field) {
6384            return Err(GraphError::RuleNotFound {
6385                name: format!("fulltext({label},{field})"),
6386            });
6387        }
6388        self.log_then_apply(WalRecord::DisableFulltext {
6389            label: label.into(),
6390            field: field.into(),
6391        })
6392    }
6393
6394    /// Whether `(label, field)` is currently indexed for full-text search.
6395    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
6396        self.fulltext.is_enabled(label, field)
6397    }
6398
6399    /// Every `(label, field)` pair with a live full-text index, sorted.
6400    ///
6401    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
6402    /// declares which nodes are *indexed*, so callers that want to search
6403    /// everything indexed should query each distinct field once.
6404    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
6405        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
6406        v.sort();
6407        v
6408    }
6409
6410    /// Enable an equality index for all nodes of `label` on scalar property
6411    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
6412    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
6413    /// declaration persists via WAL and the postings rebuild on re-open.
6414    ///
6415    /// # Errors
6416    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6417    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
6418    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
6419        if self.read_only {
6420            return Err(GraphError::ReadOnly);
6421        }
6422        if self.prop_index.is_enabled(label, field) {
6423            return Err(GraphError::RuleInvalid {
6424                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
6425            });
6426        }
6427        self.log_then_apply(WalRecord::EnableIndex {
6428            label: label.into(),
6429            field: field.into(),
6430        })
6431    }
6432
6433    /// Disable the equality index for `(label, field)` and drop its postings.
6434    ///
6435    /// # Errors
6436    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6437    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
6438    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
6439        if self.read_only {
6440            return Err(GraphError::ReadOnly);
6441        }
6442        if !self.prop_index.is_enabled(label, field) {
6443            return Err(GraphError::RuleNotFound {
6444                name: format!("index({label},{field})"),
6445            });
6446        }
6447        self.log_then_apply(WalRecord::DisableIndex {
6448            label: label.into(),
6449            field: field.into(),
6450        })
6451    }
6452
6453    /// Whether `(label, field)` currently has an equality index.
6454    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
6455        self.prop_index.is_enabled(label, field)
6456    }
6457
6458    /// Search a full-text-indexed field.
6459    ///
6460    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
6461    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
6462    ///
6463    /// **Query syntax:**
6464    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
6465    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
6466    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
6467    /// - `AND` keyword is accepted explicitly and is the default.
6468    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
6469    ///
6470    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
6471    /// Pin: this is the documented, tested, stable behavior for v1.
6472    ///
6473    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
6474    /// in-memory and proportional to total indexed text across all enabled fields.
6475    ///
6476    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
6477    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
6478    /// key ascending for deterministic tiebreaking.
6479    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6480        // Resolve node_ids to keys (excluding tombstones) then re-sort by
6481        // (score DESC, key ASC) to give a deterministic, key-lexicographic
6482        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
6483        // which diverges from key order when nodes were not inserted in key-lex order.
6484        let mut results: Vec<(String, f64)> = self
6485            .fulltext
6486            .search(field, query, 0)
6487            .into_iter()
6488            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6489            .collect();
6490        results.sort_by(|a, b| {
6491            b.1.partial_cmp(&a.1)
6492                .unwrap_or(std::cmp::Ordering::Equal)
6493                .then(a.0.cmp(&b.0))
6494        });
6495        results
6496    }
6497
6498    /// [`search`](Self::search), stopping at the `k` best hits.
6499    ///
6500    /// Same ranking and the same deterministic tiebreak, but the index drops
6501    /// everything past `k` before any key is resolved, so a caller that wants
6502    /// the top few out of a field that matched thousands does not pay to
6503    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
6504    /// [`search`](Self::search) behaves.
6505    ///
6506    /// The BM25 scoring itself is not bounded by `k` — every candidate is
6507    /// scored either way — so this trims the resolve and the sort, not the
6508    /// search.
6509    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
6510        // A tombstoned id resolves to nothing, so asking the index for exactly
6511        // `k` could return fewer. Over-fetching a little and truncating after
6512        // the filter keeps the count right without unbounding the call.
6513        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
6514        let mut results: Vec<(String, f64)> = self
6515            .fulltext
6516            .search(field, query, want)
6517            .into_iter()
6518            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
6519            .collect();
6520        results.sort_by(|a, b| {
6521            b.1.partial_cmp(&a.1)
6522                .unwrap_or(std::cmp::Ordering::Equal)
6523                .then(a.0.cmp(&b.0))
6524        });
6525        if k > 0 {
6526            results.truncate(k);
6527        }
6528        results
6529    }
6530
6531    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
6532    ///
6533    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
6534    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
6535    /// them with RRF using a fixed constant of 60.
6536    ///
6537    /// ```text
6538    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
6539    /// ```
6540    ///
6541    /// Returns the top `k` nodes by fused score, ties broken by node key
6542    /// ascending (deterministic).
6543    ///
6544    /// # Vector leg fallback
6545    ///
6546    /// When `query_vec` is empty the vector leg is skipped entirely and
6547    /// results are ranked by the text list alone through the same RRF path
6548    /// (each text result scores `1/(60 + rank)` from that single list).
6549    ///
6550    /// When `label` is `None`, the vector leg **always** returns empty results.
6551    /// Internally `label` is mapped to `""`, which does not match any rule-created
6552    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
6553    /// the brute-force fallback finds no nodes with an empty label.  The fused
6554    /// ranking is therefore text-only in this case.
6555    pub fn search_hybrid(
6556        &self,
6557        text_field: &str,
6558        query_text: &str,
6559        vector_field: &str,
6560        query_vec: &[f64],
6561        label: Option<&str>,
6562        k: usize,
6563    ) -> Vec<(String, f64)> {
6564        use std::collections::HashMap;
6565
6566        const RRF_K: f64 = 60.0;
6567        let pool = 4 * k;
6568
6569        // Accumulate per-node RRF scores.
6570        let mut scores: HashMap<String, f64> = HashMap::new();
6571
6572        // Text leg.
6573        let text_hits = self.search(text_field, query_text);
6574        for (rank0, (key, _count)) in text_hits.into_iter().take(pool).enumerate() {
6575            let rank = (rank0 + 1) as f64;
6576            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6577        }
6578
6579        // Vector leg (skipped when query_vec is empty).
6580        if !query_vec.is_empty() {
6581            let vec_hits = self.find_similar_vector(vector_field, label, query_vec, pool, 0.0);
6582            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
6583                let rank = (rank0 + 1) as f64;
6584                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
6585            }
6586        }
6587
6588        // Sort: score DESC, then key ASC for deterministic tie-breaking.
6589        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
6590        ranked.sort_by(|a, b| {
6591            b.1.partial_cmp(&a.1)
6592                .unwrap_or(std::cmp::Ordering::Equal)
6593                .then(a.0.cmp(&b.0))
6594        });
6595        ranked.truncate(k);
6596        ranked
6597    }
6598
6599    /// For DST/testing: scratch BM25 search over live nodes without the index.
6600    /// Walks every live node, re-stems field tokens, computes corpus stats, and
6601    /// returns BM25-ranked results.
6602    ///
6603    /// The oracle: the ordered key list of `search(field, q)` must equal that of
6604    /// `scratch_search(field, q)` at every quiescent state.
6605    #[doc(hidden)]
6606    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
6607        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
6608        use std::collections::BTreeMap;
6609
6610        let groups = parse_query(query);
6611        if groups.is_empty() {
6612            return vec![];
6613        }
6614
6615        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
6616        struct NodeData {
6617            key: String,
6618            /// stemmed_token → positions (sorted)
6619            tokens: BTreeMap<String, Vec<u32>>,
6620            dl: u32,
6621        }
6622
6623        let mut nodes: Vec<NodeData> = Vec::new();
6624        for id in 0..self.ids.len() as u32 {
6625            let Some(key) = self.ids.key_of(id) else {
6626                continue;
6627            };
6628            let Some(&sym) = self.labels.get(id as usize) else {
6629                continue;
6630            };
6631            if sym == u32::MAX {
6632                continue;
6633            }
6634            let label = match self.syms.resolve(sym) {
6635                Some(l) => l,
6636                None => continue,
6637            };
6638            if !self.fulltext.is_enabled(label, field) {
6639                continue;
6640            }
6641            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
6642                continue;
6643            };
6644            // Use value_tokens_stemmed_with_positions so list elements are
6645            // separated by POSITION_GAP — identical to the index path, which
6646            // prevents phrase queries from matching across element boundaries.
6647            let stemmed_with_pos = match &value {
6648                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
6649                _ => continue,
6650            };
6651            let dl = stemmed_with_pos.len() as u32;
6652            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
6653            for (tok, pos) in stemmed_with_pos {
6654                tok_map.entry(tok).or_default().push(pos);
6655            }
6656            nodes.push(NodeData {
6657                key: key.to_string(),
6658                tokens: tok_map,
6659                dl,
6660            });
6661        }
6662
6663        if nodes.is_empty() {
6664            return vec![];
6665        }
6666
6667        // --- BM25 corpus stats ---
6668        let n = nodes.len() as f64;
6669        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
6670        // df per stemmed token across all live indexed nodes.
6671        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
6672        for nd in &nodes {
6673            for tok in nd.tokens.keys() {
6674                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
6675            }
6676        }
6677
6678        const K1: f64 = 1.2;
6679        const B: f64 = 0.75;
6680
6681        // --- Pass 2: score each node against each OR-group ---
6682        let mut results: Vec<(String, f64)> = Vec::new();
6683        for nd in &nodes {
6684            let dl = nd.dl as f64;
6685            let mut total_score = 0.0f64;
6686
6687            'group: for group in &groups {
6688                let mut group_score = 0.0f64;
6689
6690                for term in group {
6691                    if term.negated {
6692                        // Negated: if doc has this stemmed token → group fails.
6693                        let present = if term.prefix {
6694                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
6695                        } else {
6696                            nd.tokens.contains_key(term.token.as_str())
6697                        };
6698                        if present {
6699                            continue 'group;
6700                        }
6701                        continue;
6702                    }
6703                    if term.prefix {
6704                        // Prefix: sum BM25 for all matching stemmed tokens.
6705                        let mut prefix_matched = false;
6706                        for (tok, positions) in &nd.tokens {
6707                            if tok.starts_with(term.token.as_str()) {
6708                                let tf = positions.len() as f64;
6709                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
6710                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6711                                let tf_norm =
6712                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6713                                group_score += idf * tf_norm;
6714                                prefix_matched = true;
6715                            }
6716                        }
6717                        if !prefix_matched {
6718                            continue 'group;
6719                        }
6720                    } else {
6721                        // term.token is already stemmed by parse_query; use directly.
6722                        match nd.tokens.get(term.token.as_str()) {
6723                            None => continue 'group,
6724                            Some(positions) => {
6725                                let tf = positions.len() as f64;
6726                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
6727                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
6728                                let tf_norm =
6729                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
6730                                group_score += idf * tf_norm;
6731                            }
6732                        }
6733                    }
6734                }
6735
6736                if group_score > 0.0 {
6737                    total_score += group_score;
6738                }
6739            }
6740
6741            if total_score > 0.0 {
6742                results.push((nd.key.clone(), total_score));
6743            }
6744        }
6745
6746        results.sort_by(|a, b| {
6747            b.1.partial_cmp(&a.1)
6748                .unwrap_or(std::cmp::Ordering::Equal)
6749                .then(a.0.cmp(&b.0))
6750        });
6751        results
6752    }
6753
6754    /// Return the current view-maintained value of `view_prop` for node `key`.
6755    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
6756    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
6757        let id = self.ids.get(key)?;
6758        self.props_view()
6759            .get(id, view_prop)
6760            .map(|vr| vr.into_value())
6761    }
6762
6763    /// For testing / DST oracle: scratch recompute of a view value for one node.
6764    ///
6765    /// Returns `None` if the node does not exist, the view does not exist, or
6766    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
6767    #[doc(hidden)]
6768    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
6769        let node = self.ids.get(key)?;
6770        let def = self.view_store.views().find(|v| v.name == view_name)?;
6771        // Use TopologyView so that NeighborAgg sees base + overlay edges
6772        // without materialising a temporary Topology (I1).
6773        let topo_view = self.topo_view();
6774        core_rules::views::compute_view_value(
6775            def,
6776            node,
6777            self.props_view(),
6778            &topo_view,
6779            &self.ids,
6780            &self.syms,
6781            &self.labels,
6782        )
6783    }
6784
6785    // -----------------------------------------------------------------------
6786    // Graph algorithm API
6787    // -----------------------------------------------------------------------
6788
6789    /// Run PageRank over the unified topology (manual + derived edges).
6790    ///
6791    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
6792    /// ascending).  Set `config.edge_type` to restrict to one edge type.
6793    /// `config.converged` is `true` only when the power iteration converged
6794    /// within `config.max_iters` and within any time budget.
6795    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
6796        let topo = build_topo_view(&self.topo, &self.base);
6797        let edge_props = self.edge_props_view();
6798        crate::algo::pagerank(
6799            &topo,
6800            &self.ids,
6801            &self.syms,
6802            &self.labels,
6803            &edge_props,
6804            config,
6805        )
6806    }
6807
6808    /// Weakly-connected components over the unified topology (treated as
6809    /// undirected regardless of how edges were inserted).
6810    ///
6811    /// Component IDs are the key of the smallest member in the component
6812    /// (deterministic).  Result sorted by (component_id, key).
6813    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
6814        let topo = build_topo_view(&self.topo, &self.base);
6815        let edge_props = self.edge_props_view();
6816        crate::algo::wcc(
6817            &topo,
6818            &self.ids,
6819            &self.syms,
6820            &self.labels,
6821            &edge_props,
6822            config,
6823        )
6824    }
6825
6826    /// Degree centrality for every live node.
6827    ///
6828    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
6829    /// `AlgoDir::Both` = out + in (total directed degree).
6830    ///
6831    /// For one-shot ranking use this; for a live property updated on every
6832    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
6833    pub fn degree_centrality(
6834        &self,
6835        config: &crate::algo::DegreeConfig,
6836    ) -> crate::algo::DegreeReport {
6837        let topo = build_topo_view(&self.topo, &self.base);
6838        let edge_props = self.edge_props_view();
6839        crate::algo::degree_centrality(
6840            &topo,
6841            &self.ids,
6842            &self.syms,
6843            &self.labels,
6844            &edge_props,
6845            config,
6846        )
6847    }
6848
6849    /// Louvain community detection over the unified topology (undirected).
6850    ///
6851    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
6852    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
6853    /// result (communities sorted size-desc, then smallest member key asc).
6854    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
6855        let topo = build_topo_view(&self.topo, &self.base);
6856        let edge_props = self.edge_props_view();
6857        crate::algo::louvain(
6858            &topo,
6859            &self.ids,
6860            &self.syms,
6861            &self.labels,
6862            &edge_props,
6863            config,
6864        )
6865    }
6866
6867    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
6868    /// atomically via a single write-batch (one WAL frame, one fsync).
6869    ///
6870    /// # Errors
6871    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
6872    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
6873    ///   (collision check mirrors `create_view`).
6874    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
6875    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
6876        if self.read_only {
6877            return Err(GraphError::ReadOnly);
6878        }
6879        // Collision check: refuse if prop_name is view-managed.
6880        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
6881            return Err(GraphError::RuleInvalid {
6882                detail: format!(
6883                    "prop {:?} is managed by view {:?} and cannot be written as scores",
6884                    prop_name, view_name
6885                ),
6886            });
6887        }
6888        // Refuse if prop_name is a view name itself (confusing namespace collision).
6889        if self.view_store.has_view(prop_name) {
6890            return Err(GraphError::RuleInvalid {
6891                detail: format!(
6892                    "prop_name {:?} collides with an existing view name",
6893                    prop_name
6894                ),
6895            });
6896        }
6897        // Write all scores in a single crash-atomic batch.
6898        self.write_batch(|b| {
6899            for (key, score) in scores {
6900                b.set_prop(key, prop_name, Value::Float(*score));
6901            }
6902        })?;
6903        Ok(())
6904    }
6905
6906    /// Return the value of `field` for the node with key `key`, or `None` if
6907    /// the node or field is absent.  Reads through the overlay-over-base
6908    /// `ColumnsView`, materialising base values on demand (zero heap cost for
6909    /// overlay hits; one clone per base hit).
6910    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
6911        let id = self.ids.get(key)?;
6912        self.props_view().get(id, field).map(|vr| vr.into_value())
6913    }
6914
6915    pub fn has_node(&self, key: &str) -> bool {
6916        self.ids.get(key).is_some()
6917    }
6918
6919    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
6920    pub(crate) fn ids(&self) -> &IdMap {
6921        &self.ids
6922    }
6923
6924    // -----------------------------------------------------------------------
6925    // Namespaces
6926    // -----------------------------------------------------------------------
6927
6928    /// The index `name` already has in `ns_names`, if any.
6929    fn ns_index_of(&self, name: &str) -> Option<u32> {
6930        self.ns_names
6931            .iter()
6932            .position(|n| n == name)
6933            .map(|i| i as u32)
6934    }
6935
6936    /// The index for `name`, appending it to `ns_names` when it is new.
6937    ///
6938    /// The table holds one entry per distinct namespace in the store — a
6939    /// tenant count, not a node count — so the linear scan is cheaper than a
6940    /// map and keeps `namespaces()` allocation-free of a second index.
6941    fn ns_index_for(&mut self, name: &str) -> u32 {
6942        match self.ns_index_of(name) {
6943            Some(i) => i,
6944            None => {
6945                self.ns_names.push(name.to_string());
6946                (self.ns_names.len() - 1) as u32
6947            }
6948        }
6949    }
6950
6951    /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
6952    /// does not know (unreachable; the default is the narrowing answer).
6953    fn ns_name(&self, idx: u32) -> &str {
6954        self.ns_names
6955            .get(idx as usize)
6956            .map(String::as_str)
6957            .unwrap_or(NS_DEFAULT)
6958    }
6959
6960    /// The namespace index of dense node `id`, defaulting for an id with no
6961    /// entry (a node inserted before this handle rebuilt the array cannot
6962    /// exist: every insert path maintains it).
6963    fn node_ns_idx(&self, id: u32) -> u32 {
6964        self.node_ns
6965            .get(id as usize)
6966            .copied()
6967            .unwrap_or(NS_DEFAULT_IDX)
6968    }
6969
6970    /// File node `id` under namespace `name`, growing `node_ns` as `labels`
6971    /// grows. Called from `apply` for every node insert, live and replayed.
6972    fn set_node_ns(&mut self, id: u32, name: &str) {
6973        let idx = if name == NS_DEFAULT {
6974            NS_DEFAULT_IDX
6975        } else {
6976            self.ns_index_for(name)
6977        };
6978        if self.node_ns.len() <= id as usize {
6979            self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
6980        }
6981        self.node_ns[id as usize] = idx;
6982    }
6983
6984    /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
6985    /// open or a reload, after the snapshot is restored and the WAL replayed.
6986    ///
6987    /// A store with no `ns` column reads nothing: the column-name check fails
6988    /// and the vector is filled with one constant.
6989    fn rebuild_node_ns(&mut self) {
6990        let total = self.ids.len();
6991        self.ns_names.truncate(1);
6992        self.node_ns.clear();
6993        self.node_ns.resize(total, NS_DEFAULT_IDX);
6994        let has_ns_column = {
6995            let cv = self.props_view();
6996            cv.field_names().iter().any(|f| f == NS_PROP)
6997        };
6998        if !has_ns_column {
6999            return;
7000        }
7001        // Collected first so the props view is released before `ns_index_for`
7002        // takes `&mut self`.
7003        let named: Vec<(u32, String)> = {
7004            let cv = self.props_view();
7005            (0..total as u32)
7006                .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
7007                    Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
7008                    _ => None,
7009                })
7010                .collect()
7011        };
7012        for (id, name) in named {
7013            let idx = self.ns_index_for(&name);
7014            self.node_ns[id as usize] = idx;
7015        }
7016    }
7017
7018    /// Every namespace with at least one live node, in name order.
7019    ///
7020    /// `["default"]` on any store that has never named a namespace, including
7021    /// an empty one: a store is always at least its default namespace.
7022    pub fn namespaces(&self) -> Vec<String> {
7023        let mut out: BTreeSet<&str> = BTreeSet::new();
7024        out.insert(NS_DEFAULT);
7025        for (id, &idx) in self.node_ns.iter().enumerate() {
7026            if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
7027                continue;
7028            }
7029            out.insert(self.ns_name(idx));
7030        }
7031        out.into_iter().map(str::to_string).collect()
7032    }
7033
7034    /// The namespace of `key`, or `None` when the key names no live node.
7035    pub fn namespace_of(&self, key: &str) -> Option<String> {
7036        let id = self.ids.get(key)?;
7037        if !self.is_live_node(id) {
7038            return None;
7039        }
7040        Some(self.ns_name(self.node_ns_idx(id)).to_string())
7041    }
7042
7043    /// Every live node in `namespace`, as a visibility mask.
7044    ///
7045    /// Built off `node_ns` on whichever handle this is, so on a temporal handle
7046    /// it is the namespace's membership at that commit. A name no node uses
7047    /// gives an empty mask — a namespace scope never widens.
7048    pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
7049        let Some(idx) = self.ns_index_of(namespace) else {
7050            return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
7051        };
7052        let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
7053            .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
7054            .collect();
7055        crate::mask::NodeMask::from_ids(visible)
7056    }
7057
7058    /// Live-node test used by the namespace accessors: a deleted node keeps its
7059    /// dense id and its `node_ns` slot, and the label sentinel is what marks it
7060    /// gone — the same test `mask_for_role`'s label leg applies implicitly.
7061    fn is_live_node(&self, id: u32) -> bool {
7062        self.labels
7063            .get(id as usize)
7064            .is_some_and(|&sym| sym != u32::MAX)
7065            && self.ids.key_of(id).is_some()
7066    }
7067
7068    /// Per-namespace live node counts for [`Stats`], in name order.
7069    fn namespace_stats(&self) -> Vec<NamespaceStats> {
7070        let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
7071        counts.insert(NS_DEFAULT, 0);
7072        for id in 0..self.ids.len() as u32 {
7073            if !self.is_live_node(id) {
7074                continue;
7075            }
7076            *counts
7077                .entry(self.ns_name(self.node_ns_idx(id)))
7078                .or_insert(0) += 1;
7079        }
7080        counts
7081            .into_iter()
7082            .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
7083            .map(|(name, nodes_live)| NamespaceStats {
7084                name: name.to_string(),
7085                nodes_live,
7086            })
7087            .collect()
7088    }
7089
7090    /// The namespace a create-class op would put its node in: the `ns` entry of
7091    /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
7092    fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
7093        Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
7094    }
7095
7096    /// The one `ns` entry in a node's props, or `None` when it carries none.
7097    ///
7098    /// A props list naming `ns` twice is refused. Without that refusal the
7099    /// write path and the authorisation path can read the same list
7100    /// differently — one taking the first entry, the other the last — and
7101    /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
7102    /// the role was checked against the other of. One entry is the only shape
7103    /// where "the node's namespace" is a single fact, so it is the only shape
7104    /// accepted, and every reader of it agrees by construction.
7105    fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
7106        let mut found: Option<&'a Value> = None;
7107        for (field, value) in props {
7108            if field != NS_PROP {
7109                continue;
7110            }
7111            if found.is_some() {
7112                return Err(GraphError::RuleInvalid {
7113                    detail: format!(
7114                        "node {key}: {NS_PROP} is given more than once; a node has exactly \
7115                         one namespace"
7116                    ),
7117                });
7118            }
7119            found = Some(value);
7120        }
7121        Ok(found)
7122    }
7123
7124    /// The definition of the role a write authorisation names.
7125    ///
7126    /// `None` when `roles.json` was corrupt at open or the role has since been
7127    /// removed — neither can reach a write, because the authorisation carries a
7128    /// mask `mask_for_role` already resolved for that name.
7129    fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
7130        self.roles.as_ref()?.iter().find(|r| r.name == role)
7131    }
7132
7133    /// Validate the `ns` entry of a node's props and drop an explicit default.
7134    ///
7135    /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
7136    /// a record that reached the WAL was already accepted here.
7137    fn normalise_insert_ns(
7138        key: &str,
7139        props: Vec<(String, Value)>,
7140    ) -> Result<(Vec<(String, Value)>, String)> {
7141        // One `ns` or none: this is where that is enforced, so every later
7142        // reader of the list — the authorisation gate, the two `apply` arms,
7143        // `node_ns` — is looking at a single entry and cannot disagree about
7144        // which one counts.
7145        Self::sole_ns_entry(key, &props)?;
7146        let mut name = NS_DEFAULT.to_string();
7147        let mut out = Vec::with_capacity(props.len());
7148        for (field, value) in props {
7149            if field != NS_PROP {
7150                out.push((field, value));
7151                continue;
7152            }
7153            let Value::Str(ref s) = value else {
7154                return Err(GraphError::RuleInvalid {
7155                    detail: format!(
7156                        "node {key}: {NS_PROP} must be a string naming a namespace, \
7157                         got {value:?}"
7158                    ),
7159                });
7160            };
7161            if !valid_namespace(s) {
7162                return Err(GraphError::RuleInvalid {
7163                    detail: format!(
7164                        "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
7165                         characters of [A-Za-z0-9_.-]"
7166                    ),
7167                });
7168            }
7169            name = s.clone();
7170            // An explicit default stores nothing, so a single-tenant store
7171            // never grows an `ns` column.
7172            if name != NS_DEFAULT {
7173                out.push((field, value));
7174            }
7175        }
7176        Ok((out, name))
7177    }
7178
7179    // -----------------------------------------------------------------------
7180    // RBAC role resolution
7181    // -----------------------------------------------------------------------
7182
7183    /// Parse `roles.json` bytes from `fs`.
7184    ///
7185    /// Return values:
7186    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
7187    ///                       and valid; in both cases `mask_for_role` uses the
7188    ///                       list normally (an absent file means no roles defined).
7189    ///   `Ok(None)`        — file present but corrupt or unrecognised version
7190    ///                       → poisoned state; `mask_for_role` returns `Err` for
7191    ///                       any role name until the file is fixed and the DB
7192    ///                       re-opened (or `apply_schema` is called to repair it).
7193    ///
7194    /// Note: `None` signals corruption, not absence — the opposite of what an
7195    /// optional "file missing" convention would suggest.  The open path stores
7196    /// this result on `db.roles` directly.
7197    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
7198        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
7199        if bytes.is_empty() {
7200            // Empty bytes means either the file is absent or zero-byte — both
7201            // are treated identically as "no roles defined".  A zero-byte
7202            // roles.json does NOT widen access: an absent file and a zero-byte
7203            // file both resolve to an empty role list (sees nothing by default).
7204            return Ok(Some(vec![]));
7205        }
7206        match serde_json::from_slice::<RolesFile>(&bytes) {
7207            Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
7208            // Corrupt or unrecognised version (>4): poison the roles state.
7209            // Never widen: a version this binary does not know may carry a
7210            // narrowing this binary would not apply.
7211            _ => Ok(None),
7212        }
7213    }
7214
7215    /// Resolve a role to a node-visibility mask against the current graph state.
7216    ///
7217    /// Returns `Err` when:
7218    /// - `roles.json` was present but corrupt at open (poisoned state), or
7219    /// - `role` does not match any defined role name.
7220    ///
7221    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
7222    /// all live nodes carrying any label in `labels` that also pass the role's
7223    /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
7224    /// has one.  Label resolution is live — new nodes of an allowed label are
7225    /// visible without re-applying the schema, and a property edited out of the
7226    /// predicate takes its node out of the mask on the next read.  An empty
7227    /// union = empty mask = sees nothing.
7228    ///
7229    /// This is the one resolver every read path calls, live and as-of alike, so
7230    /// the predicate applies everywhere at once.  On an as-of handle the role
7231    /// *definition* is the current one and the graph is the historical one: the
7232    /// predicate is evaluated against the property values at the commit being
7233    /// read.
7234    ///
7235    /// The result is memoised per `(role, commit_seq)`, so a scoped reader
7236    /// between two writes resolves the role once.  See
7237    /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
7238    /// stale.
7239    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7240        self.role_masks
7241            .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
7242            .map(|m| (*m).clone())
7243    }
7244
7245    /// The mask an [`AsOfScope`] names, resolved against this handle.
7246    ///
7247    /// Shared by [`GraphDb::query_at_scoped`] and
7248    /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
7249    /// however the namespace leg is added.
7250    fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
7251        // One resolver answers "what may this role see" — `mask_for_role` — and
7252        // it runs against this handle, so on a temporal one the answer is the
7253        // as-of one.
7254        Ok(match scope {
7255            AsOfScope::Role(role) => self.mask_for_role(role)?,
7256            AsOfScope::Keys(keys) => {
7257                crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
7258            }
7259            AsOfScope::RoleAndKeys(role, keys) => {
7260                self.mask_for_role(role)?
7261                    .intersect(&crate::mask::NodeMask::from_keys(
7262                        self,
7263                        keys.iter().map(String::as_str),
7264                    ))
7265            }
7266            AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
7267        })
7268    }
7269
7270    /// Resolve `role` against the current graph, ignoring the memo.
7271    fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
7272        let roles = self.roles.as_ref().ok_or_else(|| GraphError::Corrupt {
7273            detail:
7274                "roles.json was corrupt at open; fix the file and re-open to restore role access"
7275                    .into(),
7276        })?;
7277        let def = roles
7278            .iter()
7279            .find(|r| r.name == role)
7280            .ok_or_else(|| GraphError::KeyNotFound {
7281                key: format!("role:{role}"),
7282            })?;
7283
7284        let mut visible = std::collections::HashSet::new();
7285
7286        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
7287        // An administrative grant, never narrowed by the predicate.
7288        for key in &def.keys {
7289            if let Some(id) = self.ids.get(key) {
7290                visible.insert(id);
7291            }
7292        }
7293
7294        // Label leg: live scan — iterate labels vec for matching symbol, and
7295        // when the role carries a predicate, test the property as well.  The
7296        // property comes from the store's own merged view (overlay over the
7297        // mmap'd base), so an as-of handle reads the values of its own commit.
7298        let props = def.visible_where.as_ref().map(|_| self.props_view());
7299        for label_name in &def.labels {
7300            if let Some(sym) = self.syms.get(label_name) {
7301                for (i, &s) in self.labels.iter().enumerate() {
7302                    if s != sym {
7303                        continue;
7304                    }
7305                    let id = i as u32;
7306                    match (&def.visible_where, &props) {
7307                        (Some(pred), Some(view)) => {
7308                            let value = view.get(id, &pred.field).map(|vr| vr.into_value());
7309                            if pred.holds(value.as_ref()) {
7310                                visible.insert(id);
7311                            }
7312                        }
7313                        _ => {
7314                            visible.insert(id);
7315                        }
7316                    }
7317                }
7318            }
7319        }
7320
7321        // Namespace leg: an intersection over the whole union, the key leg
7322        // included. A namespace is a tenancy boundary, so a key naming a node in
7323        // another tenant's namespace is not an administrative grant — and
7324        // `apply_schema` has already refused that role, so this only has to be
7325        // right about the node that moved into existence afterwards.
7326        if def.namespaces.is_some() {
7327            visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
7328        }
7329
7330        Ok(crate::mask::NodeMask::from_ids(visible))
7331    }
7332
7333    /// Return the current list of role definitions.
7334    ///
7335    /// Returns an empty list when no roles are defined or when `roles.json`
7336    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
7337    /// the fail-loud error in that case).
7338    pub fn roles(&self) -> Vec<RoleDef> {
7339        self.roles.as_deref().unwrap_or(&[]).to_vec()
7340    }
7341
7342    // ── Role-scoped write authz ───────────────────────────────────────────────
7343
7344    /// Execute `ops` with optional role-scoped write authorization.
7345    ///
7346    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
7347    ///   (zero-cost bypass of all authz checks).
7348    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
7349    ///   record is built.  A denial returns an error with no WAL frame written
7350    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
7351    ///
7352    /// See the plan's "authz decision table" section for the full semantics.
7353    pub fn write_batch_authz(
7354        &mut self,
7355        authz: Option<&WriteAuthz>,
7356        ops: Vec<BatchOp>,
7357    ) -> Result<(usize, usize)> {
7358        // Thread authz as a direct parameter — never touches pending_write_authz.
7359        self.commit_logged_batch(ops, None, authz.cloned())
7360    }
7361
7362    /// Execute a Cypher write statement with role-scoped write authorization.
7363    ///
7364    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
7365    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
7366    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
7367    /// call so that all inner `batch.commit()` calls are authz-checked.
7368    ///
7369    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
7370    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
7371    /// timing-oracle item (hidden ≡ absent for unscoped roles).
7372    ///
7373    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
7374    /// "this endpoint is not permitted".
7375    pub fn query_write_authz(
7376        &mut self,
7377        role: &str,
7378        cypher: &str,
7379        params: &BTreeMap<String, Value>,
7380    ) -> Result<ResultSet> {
7381        // Resolve scope (fails fast if role has no write scope).
7382        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7383        let scope =
7384            {
7385                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7386                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7387                })?;
7388                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7389                    GraphError::KeyNotFound {
7390                        key: format!("role:{role}"),
7391                    }
7392                })?;
7393                def.write
7394                    .clone()
7395                    .ok_or_else(|| GraphError::RoleWriteDenied {
7396                        reason: "role-bound token: writes are not permitted".into(),
7397                    })?
7398            };
7399        // Resolve mask inside the call (same guard, §5 coherence).
7400        let mask = self.mask_for_role(role)?;
7401        self.pending_write_authz = Some(WriteAuthz {
7402            role: role.into(),
7403            scope,
7404            mask,
7405        });
7406        // RAII guard: always clears pending_write_authz on scope exit, including
7407        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7408        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7409        impl Drop for ClearPendingAuthzOnDrop {
7410            fn drop(&mut self) {
7411                // SAFETY: pointer into the owning GraphDb; guard is dropped
7412                // within this function's frame before it returns.
7413                unsafe { *self.0 = None };
7414            }
7415        }
7416        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7417        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7418        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7419            detail: format!("lex: {e}"),
7420        })?;
7421        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
7422            detail: format!("parse: {e}"),
7423        })?;
7424        self.exec_write_stmt(stmt, params)
7425    }
7426
7427    /// Execute `ops` with optional role-scoped write authorization, suppressing
7428    /// fsync (for use inside the group-commit drain thread, which performs one
7429    /// group fsync after releasing the write lock).
7430    ///
7431    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
7432    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
7433    /// contract established by [`commit_batch_nosync`].
7434    pub(crate) fn write_batch_authz_nosync(
7435        &mut self,
7436        authz: Option<&WriteAuthz>,
7437        ops: Vec<BatchOp>,
7438    ) -> Result<(usize, usize)> {
7439        let saved = self.fsync;
7440        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7441        impl Drop for RestoreFsync {
7442            fn drop(&mut self) {
7443                // SAFETY: pointer into the owning GraphDb; guard is dropped
7444                // within the enclosing function's frame before it returns.
7445                unsafe { *self.0 = self.1 };
7446            }
7447        }
7448        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7449        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7450        self.fsync = FsyncPolicy::Relaxed;
7451        self.commit_logged_batch(ops, None, authz.cloned())
7452    }
7453
7454    /// Execute a `/ingest` request with role-scoped write authorization.
7455    ///
7456    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
7457    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
7458    /// Sets `pending_write_authz` for the duration of the call so that the
7459    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
7460    /// and evaluates the decision table per-op before any WAL write.
7461    ///
7462    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
7463    /// denied by the decision table with the appropriate §4.3 scope reason;
7464    /// no special HTTP-layer check is needed.
7465    ///
7466    /// Roles with `write: None` return `RoleWriteDenied` with
7467    /// "writes are not permitted" (byte-identical to v1 blanket 403).
7468    pub fn ingest_with_edges_authz(
7469        &mut self,
7470        role: &str,
7471        label: &str,
7472        rows: Vec<std::collections::BTreeMap<String, Value>>,
7473        opts: &crate::ingest::IngestOptions,
7474        edges: &[(String, String, String)],
7475    ) -> Result<crate::ingest::IngestReport> {
7476        // Resolve scope (fails fast if role has no write scope).
7477        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
7478        let scope =
7479            {
7480                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
7481                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
7482                })?;
7483                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
7484                    GraphError::KeyNotFound {
7485                        key: format!("role:{role}"),
7486                    }
7487                })?;
7488                def.write
7489                    .clone()
7490                    .ok_or_else(|| GraphError::RoleWriteDenied {
7491                        reason: "role-bound token: writes are not permitted".into(),
7492                    })?
7493            };
7494        let mask = self.mask_for_role(role)?;
7495        self.pending_write_authz = Some(WriteAuthz {
7496            role: role.into(),
7497            scope,
7498            mask,
7499        });
7500        // RAII guard: always clears pending_write_authz on scope exit, including
7501        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
7502        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
7503        impl Drop for ClearPendingAuthzOnDrop {
7504            fn drop(&mut self) {
7505                // SAFETY: pointer into the owning GraphDb; guard is dropped
7506                // within this function's frame before it returns.
7507                unsafe { *self.0 = None };
7508            }
7509        }
7510        // SAFETY: raw pointer into self; guard dropped before this fn returns.
7511        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
7512        self.ingest_with_edges(label, rows, opts, edges)
7513    }
7514
7515    /// Evaluate the write-authz decision table for one `BatchOp`.
7516    ///
7517    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
7518    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
7519    /// the remaining ops are not evaluated and no WAL frame is written.
7520    ///
7521    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
7522    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
7523    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
7524    /// batch creates counts as visible if its label passed the create-class gate").
7525    fn check_single_op_authz(
7526        &self,
7527        authz: &WriteAuthz,
7528        op: &BatchOp,
7529        batch_created: &BTreeMap<String, String>,
7530    ) -> Result<()> {
7531        // Helper: 3-way node status under the authz mask.
7532        //
7533        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
7534        // as Visible with their recorded label — their create gate already passed
7535        // and they are not yet in self.ids (not committed).  This fixes the
7536        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
7537        // the SetProp must not see the node as Absent.
7538        let node_status = |key: &str| -> NodeAuthzStatus {
7539            if let Some(label) = batch_created.get(key) {
7540                return NodeAuthzStatus::Visible(label.clone());
7541            }
7542            match self.ids.get(key) {
7543                None => NodeAuthzStatus::Absent,
7544                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
7545                Some(id) => {
7546                    let label = self
7547                        .labels
7548                        .get(id as usize)
7549                        .and_then(|&sym| {
7550                            if sym == u32::MAX {
7551                                None
7552                            } else {
7553                                self.syms.resolve(sym).map(str::to_string)
7554                            }
7555                        })
7556                        .unwrap_or_default();
7557                    NodeAuthzStatus::Visible(label)
7558                }
7559            }
7560        };
7561
7562        // Helper: is an InsertEdgeUpsert endpoint visible?
7563        // A same-batch placeholder counts as visible if its label passed
7564        // the create-class gate (spec "upsert placeholder-counts-as-visible").
7565        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
7566            // In store and visible?
7567            if let Some(id) = self.ids.get(ep_key) {
7568                return authz.mask.contains_id(id);
7569            }
7570            // Created by an earlier op in this batch?
7571            if let Some(created_label) = batch_created.get(ep_key) {
7572                return authz.scope.create_labels.contains(created_label);
7573            }
7574            // Will be created by THIS InsertEdgeUpsert: placeholder_label
7575            // must pass the create-class gate.
7576            authz
7577                .scope
7578                .create_labels
7579                .contains(&placeholder_label.to_string())
7580        };
7581
7582        match op {
7583            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
7584            // These ops are never routed to role-scoped paths by the HTTP layer,
7585            // but we 403 them here to close any future bypass route.
7586            BatchOp::RenameNode { .. } | BatchOp::CreateRule(_) | BatchOp::DeleteRule { .. } => {
7587                return Err(GraphError::RoleWriteDenied {
7588                    reason: "role-bound token: this endpoint is not permitted".into(),
7589                });
7590            }
7591
7592            // ── CREATE-class: InsertNode ─────────────────────────────────────
7593            //
7594            // Decision table row 1 (scope-before-lookup): check label in
7595            // create_labels BEFORE any key lookup.  This is the structural
7596            // closure of the §6.2 timing-oracle item — the denial fires even
7597            // when the store is EMPTY (see test_create_scope_denied_empty_store).
7598            BatchOp::InsertNode { label, key, props } => {
7599                if !authz.scope.create_labels.contains(label) {
7600                    return Err(GraphError::RoleWriteDenied {
7601                        reason: format!(
7602                            "role-bound token: label '{}' not in write scope (create_labels)",
7603                            label
7604                        ),
7605                    });
7606                }
7607                // A role bound to namespaces may only create inside them. The
7608                // never-widen rule is about what a write makes visible to *any*
7609                // party, not only to the writer: a node this role could never
7610                // read back is a write into somebody else's tenancy. Also a
7611                // scope check, so it runs before the key lookup — it discloses
7612                // nothing about the store. Covers Cypher `CREATE` and the node
7613                // `MERGE` creates, both of which arrive as this op.
7614                // Resolved before the role lookup so a props list naming `ns`
7615                // twice is refused for every role, scoped or not: it is the same
7616                // malformed write the seam refuses, and leaving it to the seam
7617                // would mean the gate had already read one of the two.
7618                let target = Self::created_namespace(key, props)?;
7619                if let Some(def) = self.role_def_for(&authz.role) {
7620                    if !def.sees_namespace(target) {
7621                        return Err(GraphError::RoleWriteDenied {
7622                            reason: format!(
7623                                "role-bound token: namespace '{target}' not in the role's \
7624                                 namespaces"
7625                            ),
7626                        });
7627                    }
7628                }
7629                // Row 2/3: key lookup.
7630                match self.ids.get(key.as_str()) {
7631                    Some(id) if authz.mask.contains_id(id) => {
7632                        // Visible: DuplicateKey — let MutPreview handle this.
7633                    }
7634                    Some(_) => {
7635                        // Hidden: indistinguishable from absent to the role.
7636                        return Err(GraphError::RoleWriteDenied {
7637                            reason: "role-bound token: target node not visible".into(),
7638                        });
7639                    }
7640                    None => {
7641                        // Absent: proceed (create).
7642                    }
7643                }
7644            }
7645
7646            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
7647            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
7648                if batch_created.contains_key(key.as_str()) {
7649                    // Batch-created node: create gate already passed this batch.
7650                    // Updating it in the same batch is always allowed, regardless
7651                    // of update_labels (ruling §3.5: "writer just created it").
7652                } else {
7653                    let label = match node_status(key) {
7654                        NodeAuthzStatus::Visible(lbl) => lbl,
7655                        _ => {
7656                            return Err(GraphError::RoleWriteDenied {
7657                                reason: "role-bound token: target node not visible".into(),
7658                            });
7659                        }
7660                    };
7661                    if !authz.scope.update_labels.contains(&label) {
7662                        return Err(GraphError::RoleWriteDenied {
7663                            reason: format!(
7664                                "role-bound token: label '{}' not in write scope (update_labels)",
7665                                label
7666                            ),
7667                        });
7668                    }
7669                }
7670            }
7671
7672            // ── DELETE-class: DeleteNode ─────────────────────────────────────
7673            BatchOp::DeleteNode { key } => {
7674                let label = match node_status(key) {
7675                    NodeAuthzStatus::Visible(lbl) => lbl,
7676                    _ => {
7677                        return Err(GraphError::RoleWriteDenied {
7678                            reason: "role-bound token: target node not visible".into(),
7679                        });
7680                    }
7681                };
7682                if !authz.scope.delete_labels.contains(&label) {
7683                    return Err(GraphError::RoleWriteDenied {
7684                        reason: format!(
7685                            "role-bound token: label '{}' not in write scope (delete_labels)",
7686                            label
7687                        ),
7688                    });
7689                }
7690            }
7691
7692            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
7693            //
7694            // Derived-edge rejection runs BEFORE the delete_edge_types scope
7695            // check (spec §3.5: "existing derived-edge rejection precedes
7696            // delete_edge_types check").
7697            BatchOp::DeleteEdge {
7698                edge_type,
7699                src_key,
7700                dst_key,
7701            } => {
7702                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
7703                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
7704                    self.ids.get(src_key.as_str()),
7705                    self.ids.get(dst_key.as_str()),
7706                    self.syms.get(edge_type.as_str()),
7707                ) {
7708                    if self.engine.is_owned(et_sym, src_id, dst_id) {
7709                        return Err(GraphError::RuleOwned {
7710                            detail: format!(
7711                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7712                                 delete or change the owning rule"
7713                            ),
7714                        });
7715                    }
7716                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
7717                    let preview = MutPreview::new(self);
7718                    if preview.would_derive(edge_type, src_key, dst_key) {
7719                        return Err(GraphError::RuleOwned {
7720                            detail: format!(
7721                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
7722                                 delete or change the owning rule, or a live rule would \
7723                                 re-derive it"
7724                            ),
7725                        });
7726                    }
7727                }
7728                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
7729                if !authz.scope.delete_edge_types.contains(edge_type) {
7730                    return Err(GraphError::RoleWriteDenied {
7731                        reason: format!(
7732                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
7733                            edge_type
7734                        ),
7735                    });
7736                }
7737                // Both endpoints must be visible.
7738                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7739                    match self.ids.get(ep_key) {
7740                        None => {
7741                            return Err(GraphError::RoleWriteDenied {
7742                                reason: "role-bound token: edge endpoint not visible".into(),
7743                            });
7744                        }
7745                        Some(id) if !authz.mask.contains_id(id) => {
7746                            return Err(GraphError::RoleWriteDenied {
7747                                reason: "role-bound token: edge endpoint not visible".into(),
7748                            });
7749                        }
7750                        _ => {}
7751                    }
7752                }
7753            }
7754
7755            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
7756            //
7757            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
7758            BatchOp::InsertEdge {
7759                edge_type,
7760                src_key,
7761                dst_key,
7762            } => {
7763                if !authz.scope.create_edge_types.contains(edge_type) {
7764                    return Err(GraphError::RoleWriteDenied {
7765                        reason: format!(
7766                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7767                            edge_type
7768                        ),
7769                    });
7770                }
7771                // Both endpoints must be visible. A node created by an earlier
7772                // InsertNode in the same batch (tracked in batch_created) counts
7773                // as visible if its label passed the create-class gate.
7774                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7775                    if batch_created.contains_key(ep_key) {
7776                        // Created earlier this batch — already scope-checked.
7777                        continue;
7778                    }
7779                    match self.ids.get(ep_key) {
7780                        None => {
7781                            return Err(GraphError::RoleWriteDenied {
7782                                reason: "role-bound token: edge endpoint not visible".into(),
7783                            });
7784                        }
7785                        Some(id) if !authz.mask.contains_id(id) => {
7786                            return Err(GraphError::RoleWriteDenied {
7787                                reason: "role-bound token: edge endpoint not visible".into(),
7788                            });
7789                        }
7790                        _ => {}
7791                    }
7792                }
7793            }
7794
7795            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
7796            //
7797            // Scope check first; then endpoint visibility using same-batch
7798            // placeholder awareness (spec: "a placeholder endpoint the SAME
7799            // batch creates counts as visible if its label passed the
7800            // create-class gate").
7801            BatchOp::InsertEdgeUpsert {
7802                edge_type,
7803                src_key,
7804                dst_key,
7805                placeholder_label,
7806            } => {
7807                if !authz.scope.create_edge_types.contains(edge_type) {
7808                    return Err(GraphError::RoleWriteDenied {
7809                        reason: format!(
7810                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
7811                            edge_type
7812                        ),
7813                    });
7814                }
7815                // Check placeholder label against create_labels (create-class gate).
7816                // This ensures the auto-created endpoints are scope-allowed.
7817                for ep_key in [src_key.as_str(), dst_key.as_str()] {
7818                    if !upsert_ep_visible(ep_key, placeholder_label) {
7819                        return Err(GraphError::RoleWriteDenied {
7820                            reason: "role-bound token: edge endpoint not visible".into(),
7821                        });
7822                    }
7823                }
7824                // A placeholder is created with no props, so it lands in the
7825                // default namespace. A role that cannot read `default` must not
7826                // create one there, for the same reason it may not create a node
7827                // there outright.
7828                //
7829                // The refusal is byte-identical to the hidden-endpoint one above,
7830                // and deliberately so: this arm fires only for an endpoint that
7831                // does **not** exist, and the one above only for an endpoint that
7832                // does. Two different strings would make the pair an existence
7833                // oracle — ask for an upsert and read off whether the key is
7834                // taken. Hidden ≡ absent is the rule everywhere else in this
7835                // table and it holds here too.
7836                if let Some(def) = self.role_def_for(&authz.role) {
7837                    if !def.sees_namespace(NS_DEFAULT) {
7838                        for ep_key in [src_key.as_str(), dst_key.as_str()] {
7839                            if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
7840                            {
7841                                return Err(GraphError::RoleWriteDenied {
7842                                    reason: "role-bound token: edge endpoint not visible".into(),
7843                                });
7844                            }
7845                        }
7846                    }
7847                }
7848            }
7849        }
7850        Ok(())
7851    }
7852
7853    /// Write `roles` to `roles.json` atomically and update the in-memory list.
7854    ///
7855    /// Called by `apply_schema` when roles change. Never called on unchanged
7856    /// re-apply — this preserves byte-identical idempotency.
7857    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
7858        let file = RolesFile::new_versioned(roles.clone());
7859        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
7860            detail: format!("roles serialization: {e}"),
7861        })?;
7862        self.fs
7863            .write_atomic(FileId::Roles, &bytes)
7864            .map_err(GraphError::Io)?;
7865        self.roles = Some(roles);
7866        // Rewriting the sidecar is not a commit, so `commit_seq` does not move
7867        // and a memoised mask would still match its version. Install a fresh
7868        // cache instead of clearing the shared one: a reader snapshot frozen
7869        // against the old definitions keeps the old `Arc` to itself and can
7870        // never publish an answer this handle would read back.
7871        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
7872        // Refresh the MVCC frozen overlay so that reader() immediately sees the
7873        // updated role definitions without waiting for the next K-commit fold.
7874        self.fold_now();
7875        Ok(())
7876    }
7877
7878    fn view(&self) -> GraphView<'_> {
7879        GraphView {
7880            ids: &self.ids,
7881            syms: &self.syms,
7882            labels: &self.labels,
7883            props: self.props_view(),
7884            topo: self.topo_view(),
7885            edge_props: self.edge_props_view(),
7886            mask: None,
7887            prop_index: Some(&self.prop_index),
7888        }
7889    }
7890
7891    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
7892        GraphView {
7893            ids: &self.ids,
7894            syms: &self.syms,
7895            labels: &self.labels,
7896            props: self.props_view(),
7897            topo: self.topo_view(),
7898            edge_props: self.edge_props_view(),
7899            mask: Some(&mask.visible),
7900            prop_index: Some(&self.prop_index),
7901        }
7902    }
7903
7904    /// Execute a read-only Cypher query with a node visibility mask.
7905    ///
7906    /// Only nodes whose key is in `mask` are accessible: label scans, key
7907    /// lookups, and neighbor expansions all respect the mask. Edges where
7908    /// either endpoint is hidden are silently dropped.
7909    ///
7910    /// Returns `Err` with a "masked queries are read-only" message when
7911    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
7912    pub fn query_masked(
7913        &self,
7914        cypher: &str,
7915        params: &std::collections::BTreeMap<String, Value>,
7916        mask: &crate::mask::NodeMask,
7917    ) -> Result<ResultSet> {
7918        // Reject write statements up front.
7919        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
7920            detail: format!("lex: {e}"),
7921        })?;
7922        if is_write_tokens(&tokens) {
7923            return Err(GraphError::MaskedReadOnly);
7924        }
7925        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
7926            detail: format!("parse: {e}"),
7927        })?;
7928        // Each UNION part executes against the same masked view, so the mask
7929        // applies uniformly across the chain.
7930        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
7931            GraphError::QueryError {
7932                detail: format!("execute: {e}"),
7933            }
7934        })
7935    }
7936
7937    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
7938        let id = self.ids.get(key)?;
7939        Some(NodeRef { db: self, id })
7940    }
7941
7942    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
7943    ///
7944    /// Hidden nodes are never used as traversal intermediaries in either
7945    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
7946    /// only through a hidden node will not appear in results.
7947    ///
7948    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
7949    /// a visited visible node are appended to the result as stub rows
7950    /// (`label` column is `null`, same key+depth columns as visible rows).
7951    /// They are NOT added to the BFS frontier.
7952    ///
7953    /// Returns `None` when `key` does not exist (caller should 404).
7954    ///
7955    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
7956    /// stub rows are never produced on the role path.
7957    pub fn neighborhood_masked(
7958        &self,
7959        key: &str,
7960        depth: u32,
7961        edge_types: Option<&[&str]>,
7962        dir: Dir,
7963        mask: &crate::mask::NodeMask,
7964    ) -> Option<ResultSet> {
7965        let start_id = self.ids.get(key)?;
7966        let view = self.view_masked(mask);
7967        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
7968            names
7969                .iter()
7970                .filter_map(|name| view.syms.get(name))
7971                .collect()
7972        });
7973        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
7974        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
7975        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
7976        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
7977        visited.push((start_id, 0));
7978        for (nid, d) in &nb.nodes {
7979            let k = view.key_of(*nid);
7980            let label = view
7981                .label_of(*nid)
7982                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
7983            rs.push_row(vec![
7984                Some(Value::Str(k.to_string())),
7985                Some(Value::Str(label.to_string())),
7986                Some(Value::Int(*d as i64)),
7987            ]);
7988            visited.push((*nid, *d));
7989        }
7990        // Stub mode: add hidden direct neighbours of each visited node as stubs.
7991        // Hidden nodes are edge-endpoints only — they are not added to the BFS
7992        // frontier, so the BFS never expands through them.
7993        if mask.mode() == crate::mask::MaskMode::Stub {
7994            let raw_view = self.view();
7995            let mut seen: std::collections::HashSet<u32> =
7996                visited.iter().map(|(id, _)| *id).collect();
7997            for (node_id, node_depth) in &visited {
7998                if *node_depth >= depth {
7999                    continue;
8000                }
8001                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
8002                    let nbr = if e.src == *node_id { e.dst } else { e.src };
8003                    if !mask.contains_id(nbr) && seen.insert(nbr) {
8004                        if let Some(k) = self.ids.key_of(nbr) {
8005                            rs.push_row(vec![
8006                                Some(Value::Str(k.to_string())),
8007                                None,
8008                                Some(Value::Int((*node_depth + 1) as i64)),
8009                            ]);
8010                        }
8011                    }
8012                }
8013            }
8014        }
8015        Some(rs)
8016    }
8017
8018    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
8019    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
8020        let n = self.node_ref(key)?;
8021        Some(NodeInfo {
8022            key: n.key().to_string(),
8023            label: n.label().to_string(),
8024            props: n.props(),
8025        })
8026    }
8027
8028    /// Look up a node with mask awareness.
8029    ///
8030    /// | Key state         | Omit mode       | Stub mode              |
8031    /// |-------------------|-----------------|------------------------|
8032    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
8033    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
8034    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
8035    ///
8036    /// **SECURITY**: only call from client-mask (full-token) paths.
8037    /// Role-token paths must use [`node_info`] after an explicit visibility check.
8038    pub fn node_info_masked(
8039        &self,
8040        key: &str,
8041        mask: &crate::mask::NodeMask,
8042    ) -> Option<MaskedNodeResult> {
8043        let id = self.ids.get(key)?;
8044        if mask.contains_id(id) {
8045            Some(MaskedNodeResult::Visible(self.node_info(key)?))
8046        } else {
8047            match mask.mode() {
8048                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
8049                crate::mask::MaskMode::Omit => None,
8050            }
8051        }
8052    }
8053
8054    /// Get edges for `key` with mask-aware hidden-endpoint handling.
8055    ///
8056    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
8057    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
8058    ///   is `true` for each hidden endpoint.
8059    ///
8060    /// Unknown key → [`GraphError::KeyNotFound`].
8061    ///
8062    /// **SECURITY**: only call from client-mask (full-token) paths.
8063    pub fn node_edges_masked(
8064        &self,
8065        key: &str,
8066        mask: &crate::mask::NodeMask,
8067    ) -> Result<Vec<MaskedEdge>> {
8068        self.ensure_v8_base_sections_loaded();
8069        let id = self
8070            .ids
8071            .get(key)
8072            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8073        let derived: BTreeSet<(u32, u32, u32)> = self
8074            .engine
8075            .provenance_touching(id)
8076            .map(|(_rule, etype, src, dst)| (etype, src, dst))
8077            .collect();
8078        let mut edges = Vec::new();
8079        let tv = self.topo_view();
8080        for etype in tv.etypes() {
8081            // etype comes from the archived CSR (access_unchecked, no eager CRC).
8082            // A bit-flip in the large TOPOLOGY section can produce an etype id
8083            // that is not in the interner.  Return Corrupt rather than panic.
8084            let edge_type = self
8085                .syms
8086                .resolve(etype)
8087                .ok_or_else(|| GraphError::Corrupt {
8088                    detail: format!("v8: topology etype {etype} not in interner"),
8089                })?
8090                .to_string();
8091            for dir in [Direction::Out, Direction::In] {
8092                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
8093                    let nbr_restricted = !mask.contains_id(nbr);
8094                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
8095                        continue;
8096                    }
8097                    let nbr_key = self
8098                        .ids
8099                        .key_of(nbr)
8100                        .ok_or_else(|| GraphError::Corrupt {
8101                            detail: format!("topology id {nbr} has no key"),
8102                        })?
8103                        .to_string();
8104                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
8105                        match dir {
8106                            Direction::Out => {
8107                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
8108                            }
8109                            Direction::In => {
8110                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
8111                            }
8112                        };
8113                    edges.push(MaskedEdge {
8114                        edge_type: edge_type.clone(),
8115                        src_key,
8116                        src_restricted,
8117                        dst_key,
8118                        dst_restricted,
8119                        derived: derived.contains(&(etype, src_id, dst_id)),
8120                    });
8121                }
8122            }
8123        }
8124        edges.sort_by(|a, b| {
8125            a.edge_type
8126                .cmp(&b.edge_type)
8127                .then(a.src_key.cmp(&b.src_key))
8128                .then(a.dst_key.cmp(&b.dst_key))
8129        });
8130        edges.dedup_by(|a, b| {
8131            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
8132        });
8133        Ok(edges)
8134    }
8135
8136    /// Every directed edge incident on `key`, both directions, every etype.
8137    ///
8138    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
8139    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
8140    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
8141    /// Unknown key → [`GraphError::KeyNotFound`].
8142    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
8143        self.ensure_v8_base_sections_loaded();
8144        let id = self
8145            .ids
8146            .get(key)
8147            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
8148        let derived: BTreeSet<(u32, u32, u32)> = self
8149            .engine
8150            .provenance_touching(id)
8151            .map(|(_rule, etype, src, dst)| (etype, src, dst))
8152            .collect();
8153        let mut edges = Vec::new();
8154        let tv = self.topo_view();
8155        for etype in tv.etypes() {
8156            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
8157            let edge_type = self
8158                .syms
8159                .resolve(etype)
8160                .ok_or_else(|| GraphError::Corrupt {
8161                    detail: format!("v8: topology etype {etype} not in interner"),
8162                })?
8163                .to_string();
8164            for dir in [Direction::Out, Direction::In] {
8165                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
8166                    let (src, dst, src_key, dst_key) = match dir {
8167                        Direction::Out => (
8168                            id,
8169                            nbr,
8170                            key.to_string(),
8171                            self.ids
8172                                .key_of(nbr)
8173                                .ok_or_else(|| GraphError::Corrupt {
8174                                    detail: format!("topology id {nbr} has no key"),
8175                                })?
8176                                .to_string(),
8177                        ),
8178                        Direction::In => (
8179                            nbr,
8180                            id,
8181                            self.ids
8182                                .key_of(nbr)
8183                                .ok_or_else(|| GraphError::Corrupt {
8184                                    detail: format!("topology id {nbr} has no key"),
8185                                })?
8186                                .to_string(),
8187                            key.to_string(),
8188                        ),
8189                    };
8190                    edges.push(EdgeInfo {
8191                        edge_type: edge_type.clone(),
8192                        src_key,
8193                        dst_key,
8194                        derived: derived.contains(&(etype, src, dst)),
8195                    });
8196                }
8197            }
8198        }
8199        edges.sort_by(|a, b| {
8200            a.edge_type
8201                .cmp(&b.edge_type)
8202                .then(a.src_key.cmp(&b.src_key))
8203                .then(a.dst_key.cmp(&b.dst_key))
8204        });
8205        // Self-loops appear in both Out and In; sort makes the pair adjacent
8206        // (sort key matches PartialEq for this case) so one pass drops the dup.
8207        edges.dedup();
8208        Ok(edges)
8209    }
8210
8211    // ── Backup ────────────────────────────────────────────────────────────────
8212
8213    /// Copy this store to `dest` as a consistent, verified snapshot.
8214    ///
8215    /// Copies every durable file in the database directory — `snapshot.bin`,
8216    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
8217    /// `roles.json` — into a freshly created `dest` directory using OS-level
8218    /// `copy` calls (no large in-process buffers).
8219    ///
8220    /// # Consistency guarantee
8221    ///
8222    /// The guarantee is **process-local**: the caller holds `&self`, which
8223    /// prevents any concurrent writer in the **same process** from modifying
8224    /// the files during the copy.  Running `mushroomdb backup` against a
8225    /// directory that is **concurrently being written by another process** (e.g.
8226    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
8227    /// `verified: true` result reduces but does not eliminate the risk of a
8228    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
8229    /// consistent mid-write snapshot).
8230    ///
8231    /// **The safe path for a live-served store is `POST /backup` on the HTTP
8232    /// server.** That handler acquires the read lock on the shared database
8233    /// before calling this method, which is the correct cross-process
8234    /// synchronisation point because the server is the single process writing
8235    /// the files.
8236    ///
8237    /// After copying, opens the destination read-only and runs the CRC section
8238    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
8239    /// `BackupReport::verified` reflects whether both checks passed.
8240    ///
8241    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
8242    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
8243        // Derive source directory from snapshot_path (RealFs only).
8244        let src_dir = match self.fs.snapshot_path() {
8245            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
8246                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
8247            })?,
8248            None => {
8249                return Err(GraphError::Io(std::io::Error::other(
8250                    "backup_to requires a real filesystem (RealFs)",
8251                )))
8252            }
8253        };
8254
8255        std::fs::create_dir_all(dest)?;
8256
8257        let mut files: Vec<String> = Vec::new();
8258        let mut bytes: u64 = 0;
8259
8260        // Helper: copy src_dir/name → dest/name if the file exists.
8261        let mut try_copy = |name: &str| -> std::io::Result<()> {
8262            let src_path = src_dir.join(name);
8263            if src_path.exists() {
8264                let n = std::fs::copy(&src_path, dest.join(name))?;
8265                bytes += n;
8266                files.push(name.to_string());
8267            }
8268            Ok(())
8269        };
8270
8271        try_copy("snapshot.bin")?;
8272        try_copy("snapshot.bin.bak")?;
8273        try_copy("wal.bin")?;
8274        try_copy("wal.floor")?;
8275        try_copy("wal.genesis")?;
8276        try_copy("roles.json")?;
8277
8278        // Copy WAL archives.
8279        let archives = self.fs.list_archives()?;
8280        for n in &archives {
8281            let name = format!("wal.{n}.archive");
8282            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
8283            bytes += n_bytes;
8284            files.push(name);
8285        }
8286
8287        files.sort();
8288
8289        // Post-copy verification: open dest and run CRC checks.
8290        let snap_in_dest = dest.join("snapshot.bin").exists();
8291        let crc_ok = if snap_in_dest {
8292            crate::verify_snapshot(dest)
8293                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
8294                .unwrap_or(false)
8295        } else {
8296            true // WAL-only store: nothing to CRC-check in snapshot
8297        };
8298        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
8299        let verified = crc_ok && opens_ok;
8300
8301        Ok(BackupReport {
8302            files,
8303            bytes,
8304            verified,
8305        })
8306    }
8307
8308    // ── Export helpers ────────────────────────────────────────────────────────
8309
8310    /// All live nodes, sorted by key (deterministic).
8311    ///
8312    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
8313    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
8314        self.ensure_v8_base_sections_loaded();
8315        let pv = self.props_view();
8316        let mut nodes = Vec::new();
8317        for id in 0..self.ids.len() as u32 {
8318            let Some(key) = self.ids.key_of(id) else {
8319                continue;
8320            };
8321            let Some(&sym) = self.labels.get(id as usize) else {
8322                continue;
8323            };
8324            if sym == u32::MAX {
8325                continue; // tombstoned
8326            }
8327            let Some(label) = self.syms.resolve(sym) else {
8328                continue;
8329            };
8330            let mut props = BTreeMap::new();
8331            for field in pv.field_names() {
8332                if let Some(vr) = pv.get(id, &field) {
8333                    props.insert(field, vr.into_value());
8334                }
8335            }
8336            nodes.push(NodeInfo {
8337                key: key.to_string(),
8338                label: label.to_string(),
8339                props,
8340            });
8341        }
8342        nodes.sort_by(|a, b| a.key.cmp(&b.key));
8343        nodes
8344    }
8345
8346    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
8347    ///
8348    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
8349    /// Manual edges carry `derived: false` and `rule: None`.
8350    /// `weight` is the creating rule's `weight_prop` value read off the edge
8351    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
8352    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
8353    /// store state.
8354    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
8355        self.ensure_v8_base_sections_loaded();
8356
8357        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
8358        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
8359        for (rule_name, triples) in self.engine.provenance() {
8360            for &(etype, src, dst) in triples {
8361                prov.insert((etype, src, dst), rule_name.clone());
8362            }
8363        }
8364
8365        // rule_name → weight_prop, for O(1) lookup per derived edge.
8366        let weight_props: HashMap<&str, Option<&str>> = self
8367            .engine
8368            .rules()
8369            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
8370            .collect();
8371
8372        let tv = self.topo_view();
8373        let ep = self.edge_props_view();
8374        let mut edges = Vec::new();
8375
8376        for id in 0..self.ids.len() as u32 {
8377            let Some(key) = self.ids.key_of(id) else {
8378                continue;
8379            };
8380            let Some(&lsym) = self.labels.get(id as usize) else {
8381                continue;
8382            };
8383            if lsym == u32::MAX {
8384                continue; // tombstoned
8385            }
8386
8387            for etype_sym in tv.etypes() {
8388                // etype from archived CSR (access_unchecked, no eager CRC).
8389                // Skip edges whose etype is not in the interner; this can only
8390                // occur with a corrupt large TOPOLOGY section (bit-flip on an
8391                // etype field in the archived data).  The function returns Vec,
8392                // not Result, so we continue rather than propagate.
8393                let Some(edge_type) = self.syms.resolve(etype_sym) else {
8394                    continue;
8395                };
8396                let edge_type = edge_type.to_string();
8397                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8398                    let Some(dst_key) = self.ids.key_of(nbr) else {
8399                        continue; // skip corrupt entries
8400                    };
8401                    let prov_key = (etype_sym, id, nbr);
8402                    let rule = prov.get(&prov_key).cloned();
8403                    let derived = rule.is_some();
8404                    let weight = rule
8405                        .as_deref()
8406                        .and_then(|rn| weight_props.get(rn).copied().flatten())
8407                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8408                            Some(Value::Float(f)) => Some(f),
8409                            Some(Value::Int(i)) => Some(i as f64),
8410                            _ => None,
8411                        });
8412                    edges.push(ExportEdge {
8413                        edge_type: edge_type.clone(),
8414                        src: key.to_string(),
8415                        dst: dst_key.to_string(),
8416                        derived,
8417                        rule,
8418                        weight,
8419                    });
8420                }
8421            }
8422        }
8423
8424        edges.sort_by(|a, b| {
8425            a.edge_type
8426                .cmp(&b.edge_type)
8427                .then(a.src.cmp(&b.src))
8428                .then(a.dst.cmp(&b.dst))
8429        });
8430        edges
8431    }
8432
8433    /// What each edge type *is*, without building one record per edge.
8434    ///
8435    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
8436    /// question by materialising every edge — three `String`s apiece, a
8437    /// provenance `HashMap` over every derived edge, and a final sort. That is
8438    /// the right shape for an export, and the wrong one for a summary: on a
8439    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
8440    /// produce nine lines. This walks the topology instead, summing neighbour
8441    /// slice lengths and collecting *label symbols* rather than label strings,
8442    /// so the per-edge cost is an integer add and a set insert on a set with
8443    /// as many members as the store has labels.
8444    ///
8445    /// The rule names come off the rule *definitions*, which each declare the
8446    /// `edge_type` they derive, so naming them costs one pass over the rules
8447    /// rather than one provenance lookup per edge. That is also why `rules`
8448    /// is a list: two rules may derive the same type — the association store
8449    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
8450    /// talent→job rule — and naming only one of them would be a half-truth.
8451    /// A type with no rules is one written by hand.
8452    ///
8453    /// `sample` is the first edge of the type in the store's own id order,
8454    /// which is insertion order: deterministic for a given store, and not the
8455    /// same as key order, which cannot be had without resolving a key per
8456    /// edge. Sorted by `edge_type`.
8457    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
8458        self.ensure_v8_base_sections_loaded();
8459
8460        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
8461        for r in self.engine.rules() {
8462            rules_by_type
8463                .entry(r.edge_type.as_str())
8464                .or_default()
8465                .insert(r.name.as_str());
8466        }
8467
8468        let tv = self.topo_view();
8469        let node_count = self.ids.len() as u32;
8470        let mut out = Vec::new();
8471        for etype_sym in tv.etypes() {
8472            // An etype the interner cannot resolve means a corrupt TOPOLOGY
8473            // section; skip it rather than name it, as `all_edges_for_export`
8474            // does for the same reason.
8475            let Some(edge_type) = self.syms.resolve(etype_sym) else {
8476                continue;
8477            };
8478            let mut edges: u64 = 0;
8479            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
8480            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
8481            let mut sample: Option<(u32, u32)> = None;
8482            for id in 0..node_count {
8483                let Some(&lsym) = self.labels.get(id as usize) else {
8484                    continue;
8485                };
8486                if lsym == u32::MAX {
8487                    continue; // tombstoned
8488                }
8489                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
8490                let nbrs = nbrs.as_ref();
8491                if nbrs.is_empty() {
8492                    continue;
8493                }
8494                edges += nbrs.len() as u64;
8495                src_syms.insert(lsym);
8496                for &nbr in nbrs {
8497                    if let Some(&dsym) = self.labels.get(nbr as usize) {
8498                        if dsym != u32::MAX {
8499                            dst_syms.insert(dsym);
8500                        }
8501                    }
8502                }
8503                if sample.is_none() {
8504                    sample = Some((id, nbrs[0]));
8505                }
8506            }
8507            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
8508                syms.iter()
8509                    .filter_map(|&s| self.syms.resolve(s))
8510                    .map(ToString::to_string)
8511                    .collect()
8512            };
8513            out.push(EdgeTypeCensus {
8514                edge_type: edge_type.to_string(),
8515                edges,
8516                src_labels: resolve(&src_syms),
8517                dst_labels: resolve(&dst_syms),
8518                rules: rules_by_type
8519                    .get(edge_type)
8520                    .map(|rs| rs.iter().map(ToString::to_string).collect())
8521                    .unwrap_or_default(),
8522                sample: sample.and_then(|(s, d)| {
8523                    Some((
8524                        self.ids.key_of(s)?.to_string(),
8525                        self.ids.key_of(d)?.to_string(),
8526                    ))
8527                }),
8528            });
8529        }
8530        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
8531        out
8532    }
8533
8534    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
8535    /// on each edge when given.
8536    ///
8537    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
8538    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
8539    /// `None` — callers that want a default weight (e.g. `1.0` for missing
8540    /// props) apply it themselves, matching the convention used internally
8541    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
8542    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
8543    ///
8544    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
8545    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
8546    /// empty vec.
8547    pub fn weighted_edges(
8548        &self,
8549        edge_type: &str,
8550        weight_prop: Option<&str>,
8551    ) -> Vec<(String, String, Option<f64>)> {
8552        let Some(etype_sym) = self.syms.get(edge_type) else {
8553            return Vec::new();
8554        };
8555        let tv = self.topo_view();
8556        let ep = self.edge_props_view();
8557        let mut out = Vec::new();
8558        for id in 0..self.ids.len() as u32 {
8559            let Some(key) = self.ids.key_of(id) else {
8560                continue;
8561            };
8562            let Some(&sym) = self.labels.get(id as usize) else {
8563                continue;
8564            };
8565            if sym == u32::MAX {
8566                continue; // tombstoned
8567            }
8568            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
8569                let Some(dst_key) = self.ids.key_of(nbr) else {
8570                    continue;
8571                };
8572                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
8573                    Some(Value::Float(f)) => Some(f),
8574                    Some(Value::Int(i)) => Some(i as f64),
8575                    _ => None,
8576                });
8577                out.push((key.to_string(), dst_key.to_string(), weight));
8578            }
8579        }
8580        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
8581        out
8582    }
8583
8584    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
8585        self.view()
8586            .nodes_with_label(label)
8587            .into_iter()
8588            .map(|id| NodeRef { db: self, id })
8589            .collect()
8590    }
8591
8592    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
8593        let view = self.view();
8594        view.nodes_with_label(label)
8595            .into_iter()
8596            .filter(|&id| {
8597                eval_filter(filter, &|field| {
8598                    view.prop(id, field).map(|vr| vr.into_value())
8599                })
8600            })
8601            .map(|id| NodeRef { db: self, id })
8602            .collect()
8603    }
8604
8605    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
8606    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
8607    /// with `label = None` will use the native ANN path rather than the O(n)
8608    /// brute-force scan.
8609    pub fn has_vector_rule(&self, field: &str) -> bool {
8610        self.engine.hnsw_has_rule(field)
8611    }
8612
8613    /// How many HNSW graphs this handle has built from scratch since it was
8614    /// opened (one per side of an approximate rule).
8615    ///
8616    /// An open that restored every graph from the snapshot reports `0`.
8617    /// Exposed for tests that assert the open path reuses the persisted index
8618    /// rather than rebuilding it; not part of the stable surface.
8619    #[doc(hidden)]
8620    pub fn hnsw_build_count(&self) -> u64 {
8621        self.engine.hnsw_build_count()
8622    }
8623
8624    /// How many rules this handle still holds a lazily-decoded HNSW graph for.
8625    ///
8626    /// Zero before the first ANN query on a clean open, and again once the
8627    /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
8628    /// Exposed for tests that assert the lazy copies are released; not part of
8629    /// the stable surface.
8630    #[doc(hidden)]
8631    pub fn lazy_hnsw_len(&self) -> usize {
8632        self.engine.lazy_hnsw_len()
8633    }
8634
8635    /// Find nodes whose `field` vector is most similar to `q` (cosine
8636    /// similarity), returning up to `k` results with similarity ≥ `min`,
8637    /// sorted descending.
8638    ///
8639    /// When `label` is `None` the search spans all labels (via
8640    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
8641    /// `Some(lbl)` it restricts to nodes with that label.
8642    ///
8643    /// Uses the HNSW index when one is available (fast path); otherwise falls
8644    /// back to an O(n) brute-force scan.
8645    ///
8646    /// **The index supplies candidates, never scores.** Its own distances are
8647    /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
8648    /// every candidate is re-scored from the `f64` property vectors by
8649    /// [`exact_vector_similarity`] before `min`, the ordering and the reported
8650    /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
8651    /// the re-ordering cannot drop a true top-`k` member; see that constant for
8652    /// the rule. The score a caller receives is therefore the same number the
8653    /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
8654    /// finds an exact duplicate.
8655    pub fn find_similar_vector(
8656        &self,
8657        field: &str,
8658        label: Option<&str>,
8659        q: &[f64],
8660        k: usize,
8661        min: f64,
8662    ) -> Vec<(String, f64)> {
8663        self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
8664            .expect("find_similar_vector_filtered is infallible without where_")
8665    }
8666
8667    /// Like [`find_similar_vector`] but restricts results to nodes visible in
8668    /// `mask`. Hidden nodes never appear in results; the mask is applied
8669    /// **before** k-truncation so a caller still receives up to `k` visible
8670    /// hits.
8671    ///
8672    /// # HNSW path (widening beam)
8673    ///
8674    /// When an HNSW index covers the request, the beam starts at an over-fetch
8675    /// of `k × n / |visible|` (plus the rescore margin) when the mask's
8676    /// selectivity is known from the index length, otherwise at `k` plus that
8677    /// margin. If fewer than `k` visible candidates remain after the mask and
8678    /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
8679    /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
8680    /// or a beam that comes back short of its own width, falls through to the
8681    /// exhaustive masked scan rather than returning a short result.
8682    ///
8683    /// Every surviving candidate is re-scored from the `f64` property vectors,
8684    /// exactly as [`find_similar_vector`] does and for the same reason.
8685    ///
8686    /// # Brute-force path
8687    ///
8688    /// When no HNSW index covers the request, or the beam cannot admit `k`
8689    /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
8690    /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
8691    /// results (or all visible nodes if fewer than `k` exist).
8692    pub fn find_similar_vector_masked(
8693        &self,
8694        field: &str,
8695        label: Option<&str>,
8696        q: &[f64],
8697        k: usize,
8698        min: f64,
8699        mask: &crate::mask::NodeMask,
8700    ) -> Vec<(String, f64)> {
8701        self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
8702            .expect("find_similar_vector_filtered is infallible without where_")
8703    }
8704
8705    /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
8706    ///
8707    /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
8708    /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
8709    /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
8710    /// uses HNSW when an index covers the field.
8711    #[allow(clippy::too_many_arguments)]
8712    pub fn find_similar_vector_filtered(
8713        &self,
8714        field: &str,
8715        label: Option<&str>,
8716        q: &[f64],
8717        k: usize,
8718        min: f64,
8719        mask: Option<&crate::mask::NodeMask>,
8720        where_: Option<&PropPredicate>,
8721        exact: bool,
8722    ) -> Result<Vec<(String, f64)>> {
8723        if let Some(pred) = where_ {
8724            pred.validate_named("where")
8725                .map_err(|detail| GraphError::QueryError { detail })?;
8726        }
8727
8728        // Ensure any HNSW blobs retained from the snapshot are deserialized
8729        // before the first ANN query on a clean-open (no-WAL) path.  The
8730        // section read has to come first: on a clean open nothing else has
8731        // called it, so without it `retained_hnsw_blobs` is empty,
8732        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
8733        // approximate query on the handle runs brute force — correct results,
8734        // silently off the index.  Both calls are idempotent and cheap once hot.
8735        self.ensure_v8_base_sections_loaded();
8736        self.engine.ensure_hnsw_loaded();
8737        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
8738        if norm == 0.0 {
8739            return Ok(vec![]);
8740        }
8741        if let Some(m) = mask {
8742            if k == 0 || m.is_empty() {
8743                return Ok(vec![]);
8744            }
8745        }
8746        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
8747
8748        // `where` implies exact: a predicate must not ride a silent ANN.
8749        let skip_hnsw = exact || where_.is_some();
8750        if !skip_hnsw {
8751            if let Some(mask) = mask {
8752                if let Some(out) =
8753                    self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask)
8754                {
8755                    return Ok(out);
8756                }
8757            } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
8758                return Ok(out);
8759            }
8760        }
8761
8762        let view = match mask {
8763            Some(m) => self.view_masked(m),
8764            None => self.view(),
8765        };
8766        let candidate_ids = Self::vector_candidates(&view, label, where_);
8767        Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
8768    }
8769
8770    /// Unmasked HNSW path. `None` when no populated index covers the request.
8771    fn find_similar_hnsw(
8772        &self,
8773        field: &str,
8774        label: Option<&str>,
8775        q_unit: &[f64],
8776        k: usize,
8777        min: f64,
8778    ) -> Option<Vec<(String, f64)>> {
8779        // Try HNSW fast path.
8780        // `None` label searches across all VectorSimilar rules covering `field`
8781        // (merging their results); `Some(lbl)` restricts to rules whose
8782        // dst_label matches.  Returns `None` when no populated HNSW index
8783        // covers the request — the O(n) brute-force fallback handles that case.
8784        let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
8785        let hits = match label {
8786            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
8787            None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
8788        };
8789        // Candidates only: the index's `f32` similarity is discarded and
8790        // each hit is re-scored against the `f64` vectors.
8791        let view = self.view();
8792        let mut out: Vec<(String, f64)> = hits
8793            .into_iter()
8794            .filter_map(|(id, _)| {
8795                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
8796                if sim < min {
8797                    return None;
8798                }
8799                Some((self.ids.key_of(id)?.to_string(), sim))
8800            })
8801            .collect();
8802        out.sort_by(|a, b| {
8803            b.1.partial_cmp(&a.1)
8804                .unwrap_or(std::cmp::Ordering::Equal)
8805                .then_with(|| a.0.cmp(&b.0))
8806        });
8807        out.truncate(k);
8808        Some(out)
8809    }
8810
8811    /// Masked HNSW widening beam. `None` when no index covers the request or
8812    /// the beam cannot admit `k` visible hits (caller falls through to brute).
8813    fn find_similar_hnsw_masked(
8814        &self,
8815        field: &str,
8816        label: Option<&str>,
8817        q_unit: &[f64],
8818        k: usize,
8819        min: f64,
8820        mask: &crate::mask::NodeMask,
8821    ) -> Option<Vec<(String, f64)>> {
8822        let index_len = match label {
8823            Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
8824            None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
8825        };
8826        let n = index_len?;
8827        // Same ceiling the exact-rule widening loop in `hnsw_candidates`
8828        // consults — including the `with_ef_max` test hook.
8829        let cap = ef_max();
8830        let visible = mask.len();
8831        let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
8832        if visible > 0 && n > 0 {
8833            let over = k
8834                .saturating_mul(n)
8835                .div_ceil(visible)
8836                .saturating_add(VECTOR_RESCORE_MARGIN);
8837            ef = ef.max(over);
8838        }
8839        loop {
8840            let hits = match label {
8841                Some(lbl) => self
8842                    .engine
8843                    .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
8844                None => self
8845                    .engine
8846                    .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
8847            };
8848            let hits = hits?;
8849            let full = hits.len() == ef;
8850            let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
8851            if out.len() >= k {
8852                out.truncate(k);
8853                return Some(out);
8854            }
8855            // Short of its width (frontier exhausted) or at the ceiling:
8856            // a wider beam reaches nothing new, so the scan answers.
8857            if !full || ef >= cap {
8858                return None;
8859            }
8860            ef = ef.saturating_mul(2);
8861        }
8862    }
8863
8864    /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
8865    /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
8866    fn vector_candidates(
8867        view: &GraphView<'_>,
8868        label: Option<&str>,
8869        where_: Option<&PropPredicate>,
8870    ) -> Vec<u32> {
8871        if let (Some(lbl), Some(pred)) = (label, where_) {
8872            let indexed = view
8873                .prop_index
8874                .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
8875            if indexed {
8876                match (&pred.eq, &pred.in_) {
8877                    (Some(eq), None) => {
8878                        if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
8879                            return ids;
8880                        }
8881                    }
8882                    (None, Some(allowed)) => {
8883                        let mut seen = HashSet::new();
8884                        let mut out = Vec::new();
8885                        for v in allowed {
8886                            if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
8887                                for id in ids {
8888                                    if seen.insert(id) {
8889                                        out.push(id);
8890                                    }
8891                                }
8892                            }
8893                        }
8894                        return out;
8895                    }
8896                    _ => {}
8897                }
8898            }
8899        }
8900
8901        let mut ids: Vec<u32> = match label {
8902            Some(lbl) => view
8903                .nodes_with_label(lbl)
8904                .into_iter()
8905                .filter(|&id| view.visible(id))
8906                .collect(),
8907            None => view.nodes_all(),
8908        };
8909        if let Some(pred) = where_ {
8910            ids.retain(|&id| match view.prop(id, &pred.field) {
8911                None => pred.holds(None),
8912                Some(vr) => pred.holds(Some(vr.as_value())),
8913            });
8914        }
8915        ids
8916    }
8917
8918    /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
8919    /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
8920    fn brute_vector_hits(
8921        &self,
8922        view: &GraphView<'_>,
8923        candidate_ids: impl IntoIterator<Item = u32>,
8924        field: &str,
8925        q_unit: &[f64],
8926        k: usize,
8927        min: f64,
8928    ) -> Vec<(String, f64)> {
8929        let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
8930            .into_iter()
8931            .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
8932            .collect();
8933        let packed =
8934            crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
8935        let scores = crate::exact_knn::gemv(&packed, q_unit);
8936        let mut scored: Vec<(String, f64)> = packed
8937            .ids
8938            .iter()
8939            .zip(scores.iter())
8940            .filter_map(|(&id, &sim)| {
8941                if sim < min {
8942                    return None;
8943                }
8944                let key = self.ids.key_of(id)?.to_string();
8945                Some((key, sim))
8946            })
8947            .collect();
8948        scored.sort_by(|a, b| {
8949            b.1.partial_cmp(&a.1)
8950                .unwrap_or(std::cmp::Ordering::Equal)
8951                .then_with(|| a.0.cmp(&b.0))
8952        });
8953        scored.truncate(k);
8954        scored
8955    }
8956
8957    /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
8958    ///
8959    /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
8960    /// same unit and inequality as `find_similar_vector`. Self-matches are
8961    /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
8962    /// embeddings are omitted as both query and candidate. Duplicate keys are
8963    /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
8964    /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
8965    #[allow(clippy::type_complexity)]
8966    pub fn pairwise_similar(
8967        &self,
8968        keys: &[&str],
8969        field: &str,
8970        k: usize,
8971        min: f64,
8972    ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
8973        let mut seen = HashSet::new();
8974        let mut unique_ids = Vec::new();
8975        for key in keys {
8976            let Some(id) = self.ids.get(key) else {
8977                continue;
8978            };
8979            if seen.insert(id) {
8980                unique_ids.push(id);
8981            }
8982        }
8983        let max_n = crate::exact_knn::pairwise_max_n();
8984        if unique_ids.len() > max_n {
8985            return Err(GraphError::QueryError {
8986                detail: format!(
8987                    "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
8988                    unique_ids.len()
8989                ),
8990            });
8991        }
8992        if unique_ids.is_empty() {
8993            return Ok(Vec::new());
8994        }
8995
8996        let view = self.view();
8997        let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
8998        let mut counts: HashMap<usize, usize> = HashMap::new();
8999        for id in unique_ids {
9000            let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
9001                continue;
9002            };
9003            let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
9004            if norm == 0.0 {
9005                continue;
9006            }
9007            *counts.entry(v.len()).or_default() += 1;
9008            rows.push((id, v));
9009        }
9010        if rows.is_empty() {
9011            return Ok(Vec::new());
9012        }
9013        let dim = counts
9014            .into_iter()
9015            .max_by_key(|&(d, c)| (c, d))
9016            .map(|(d, _)| d)
9017            .expect("rows non-empty");
9018        let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
9019        let n = packed.ids.len();
9020        if n == 0 {
9021            return Ok(Vec::new());
9022        }
9023        let src_keys: Vec<String> = packed
9024            .ids
9025            .iter()
9026            .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
9027            .collect();
9028
9029        let mut out = Vec::with_capacity(n);
9030        if n <= crate::exact_knn::pairwise_gram_max() {
9031            let sims = crate::exact_knn::gram(&packed);
9032            for i in 0..n {
9033                out.push(Self::topk_from_row(
9034                    &src_keys,
9035                    i,
9036                    &sims[i * n..(i + 1) * n],
9037                    k,
9038                    min,
9039                ));
9040            }
9041        } else {
9042            for i in 0..n {
9043                let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
9044                let scores = crate::exact_knn::gemv(&packed, row);
9045                out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
9046            }
9047        }
9048        Ok(out)
9049    }
9050
9051    /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
9052    /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
9053    /// still appear as `(src, [])`.
9054    fn topk_from_row(
9055        src_keys: &[String],
9056        i: usize,
9057        scores: &[f64],
9058        k: usize,
9059        min: f64,
9060    ) -> (String, Vec<(String, f64)>) {
9061        let mut neigh: Vec<(String, f64)> = scores
9062            .iter()
9063            .enumerate()
9064            .filter_map(|(j, &sim)| {
9065                if i == j || sim < min {
9066                    return None;
9067                }
9068                Some((src_keys[j].clone(), sim))
9069            })
9070            .collect();
9071        neigh.sort_by(|a, b| {
9072            b.1.partial_cmp(&a.1)
9073                .unwrap_or(std::cmp::Ordering::Equal)
9074                .then_with(|| a.0.cmp(&b.0))
9075        });
9076        neigh.truncate(k);
9077        (src_keys[i].clone(), neigh)
9078    }
9079
9080    /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
9081    /// hits, order by score then key. The index's own `f32` similarity is discarded.
9082    fn score_masked_hnsw_hits(
9083        &self,
9084        hits: &[(u32, f64)],
9085        field: &str,
9086        q_unit: &[f64],
9087        min: f64,
9088        mask: &crate::mask::NodeMask,
9089    ) -> Vec<(String, f64)> {
9090        let view = self.view_masked(mask);
9091        let mut out: Vec<(String, f64)> = hits
9092            .iter()
9093            .copied()
9094            .filter(|&(id, _)| mask.visible.contains(&id))
9095            .filter_map(|(id, _)| {
9096                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
9097                if sim < min {
9098                    return None;
9099                }
9100                Some((self.ids.key_of(id)?.to_string(), sim))
9101            })
9102            .collect();
9103        out.sort_by(|a, b| {
9104            b.1.partial_cmp(&a.1)
9105                .unwrap_or(std::cmp::Ordering::Equal)
9106                .then_with(|| a.0.cmp(&b.0))
9107        });
9108        out
9109    }
9110
9111    /// Read a single property from an edge.
9112    ///
9113    /// Returns `None` when the edge does not exist, the field is absent, or any
9114    /// of the string keys cannot be resolved to interned ids.  Only edge props
9115    /// written by rules (weight fields) are accessible without a `set_edge_prop`
9116    /// binding; topology-only edges (no props set) return `None` for every field.
9117    pub fn get_edge_prop(
9118        &self,
9119        edge_type: &str,
9120        src_key: &str,
9121        dst_key: &str,
9122        field: &str,
9123    ) -> Option<Value> {
9124        let etype = self.syms.get(edge_type)?;
9125        let src = self.ids.get(src_key)?;
9126        let dst = self.ids.get(dst_key)?;
9127        self.edge_props_view().get(etype, src, dst, field)
9128    }
9129
9130    /// Lex → parse → plan → execute `cypher` over a read-only view.
9131    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
9132    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
9133    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
9134        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9135            detail: format!("lex: {e}"),
9136        })?;
9137        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9138            detail: format!("parse: {e}"),
9139        })?;
9140        let t0 = std::time::Instant::now();
9141        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
9142            GraphError::QueryError {
9143                detail: format!("execute: {e}"),
9144            }
9145        });
9146        let elapsed_ms = t0.elapsed().as_millis() as u64;
9147        let threshold = self.slow_query_threshold_ms;
9148        if threshold > 0 && elapsed_ms >= threshold {
9149            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
9150            let entry = SlowQueryEntry {
9151                ms: elapsed_ms,
9152                query: cypher.to_string(),
9153                at_commit: self.commit_seq,
9154            };
9155            if let Ok(mut log) = self.slow_queries.lock() {
9156                if log.entries.len() == SLOW_QUERY_RING_CAP {
9157                    log.entries.pop_front();
9158                }
9159                log.entries.push_back(entry);
9160                log.total += 1;
9161            }
9162        }
9163        result
9164    }
9165
9166    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
9167    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
9168    /// calling [`GraphDb::query`].
9169    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
9170        let map: BTreeMap<String, Value> = params
9171            .iter()
9172            .map(|(k, v)| (k.to_string(), v.clone()))
9173            .collect();
9174        self.query(cypher, &map)
9175    }
9176
9177    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
9178    ///
9179    /// All mutations flow through the same `insert_node` / `set_prop` /
9180    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
9181    /// fires and the WAL captures everything with one fsync per statement.
9182    ///
9183    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
9184    /// and `deleted` matching the write-result contract.
9185    ///
9186    /// **Mutation routing**: mutations are collected into a single
9187    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
9188    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
9189    /// over `self.view()` — the borrow is dropped before the batch is opened.
9190    ///
9191    /// **Limitations (v1)**:
9192    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
9193    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
9194    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
9195    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
9196    /// - Deleting a derived edge → named error "cannot delete derived edge".
9197    pub fn query_write(
9198        &mut self,
9199        cypher: &str,
9200        params: &BTreeMap<String, Value>,
9201    ) -> Result<ResultSet> {
9202        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9203            detail: format!("lex: {e}"),
9204        })?;
9205        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9206            detail: format!("parse: {e}"),
9207        })?;
9208        self.exec_write_stmt(stmt, params)
9209    }
9210
9211    fn exec_write_stmt(
9212        &mut self,
9213        stmt: WriteStatement,
9214        params: &BTreeMap<String, Value>,
9215    ) -> Result<ResultSet> {
9216        match stmt {
9217            WriteStatement::Create(s) => self.exec_create(s, params),
9218            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
9219            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
9220            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
9221            WriteStatement::Merge(s) => self.exec_merge(s, params),
9222        }
9223    }
9224
9225    fn exec_create(
9226        &mut self,
9227        stmt: core_query::cypher::CreateStmt,
9228        params: &BTreeMap<String, Value>,
9229    ) -> Result<ResultSet> {
9230        // Extract the node key from props: require a string-valued `id` field.
9231        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
9232        for node in &stmt.nodes {
9233            let var = node.var.as_deref().unwrap_or("_cn0");
9234            let key = node
9235                .props
9236                .iter()
9237                .find(|(f, _)| f == "id")
9238                .and_then(|(_, v)| {
9239                    if let Value::Str(s) = v {
9240                        Some(s.clone())
9241                    } else {
9242                        None
9243                    }
9244                })
9245                .ok_or_else(|| GraphError::QueryError {
9246                    detail: format!(
9247                        "CREATE node ({}:{}) requires a string 'id' property",
9248                        var, node.label
9249                    ),
9250                })?;
9251            var_to_key.insert(var.to_string(), key);
9252        }
9253
9254        let mut batch = self.batch();
9255        let mut created: usize = 0;
9256        for node in &stmt.nodes {
9257            let var = node.var.as_deref().unwrap_or("_cn0");
9258            let key = &var_to_key[var];
9259            batch.insert_node(&node.label, key, node.props.clone());
9260            created += 1;
9261        }
9262        for edge in &stmt.edges {
9263            let src_key = var_to_key
9264                .get(&edge.src_var)
9265                .ok_or_else(|| GraphError::QueryError {
9266                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
9267                })?;
9268            let dst_key = var_to_key
9269                .get(&edge.dst_var)
9270                .ok_or_else(|| GraphError::QueryError {
9271                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
9272                })?;
9273            batch.insert_edge(&edge.etype, src_key, dst_key);
9274        }
9275        batch.commit()?;
9276
9277        // Optional RETURN clause: project created bindings as a read result.
9278        if let Some(returns) = stmt.returns {
9279            // Each created node is looked up by its key via a separate MATCH pattern.
9280            // Multiple single-node patterns cross-join to produce 1 output row with
9281            // all variables bound (each pattern returns exactly 1 row).
9282            let patterns: Vec<Pattern> = stmt
9283                .nodes
9284                .iter()
9285                .map(|node| {
9286                    let var = node.var.as_deref().unwrap_or("_cn0");
9287                    let key = var_to_key[var].clone();
9288                    Pattern {
9289                        start: NodePat {
9290                            var: Some(var.to_string()),
9291                            label: Some(node.label.clone()),
9292                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
9293                        },
9294                        chain: vec![],
9295                        shortest: false,
9296                    }
9297                })
9298                .collect();
9299            let q = Query {
9300                matches: patterns,
9301                optional_clauses: vec![],
9302                where_expr: None,
9303                unwinds: vec![],
9304                post_unwind_where: None,
9305                stages: vec![],
9306                returns,
9307                distinct: false,
9308                order_by: vec![],
9309                skip: None,
9310                limit: None,
9311            };
9312            let ops = plan(&q).map_err(|e| GraphError::QueryError {
9313                detail: format!("plan: {e}"),
9314            })?;
9315            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
9316                GraphError::QueryError {
9317                    detail: format!("execute: {e}"),
9318                }
9319            });
9320        }
9321
9322        let mut rs = write_result_set();
9323        rs.push_row(vec![
9324            Some(Value::Int(created as i64)),
9325            Some(Value::Int(0)),
9326            Some(Value::Int(0)),
9327        ]);
9328        Ok(rs)
9329    }
9330
9331    fn exec_match_set(
9332        &mut self,
9333        stmt: core_query::cypher::MatchSetStmt,
9334        params: &BTreeMap<String, Value>,
9335    ) -> Result<ResultSet> {
9336        let project_returns = stmt.returns.clone();
9337        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
9338        // so the post-write projection can look them up by key.
9339        let mut set_vars: Vec<String> = Vec::new();
9340        for s in &stmt.sets {
9341            if !set_vars.contains(&s.var) {
9342                set_vars.push(s.var.clone());
9343            }
9344        }
9345        let rel_vars = pattern_rel_vars(&stmt.matches);
9346        let mut lookup_vars = set_vars.clone();
9347        for v in pattern_node_vars(&stmt.matches) {
9348            add_var(&mut lookup_vars, &v);
9349        }
9350        if let Some(ref returns) = project_returns {
9351            for v in ret_node_vars(returns) {
9352                if !rel_vars.iter().any(|r| r == &v) {
9353                    add_var(&mut lookup_vars, &v);
9354                }
9355            }
9356        }
9357
9358        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
9359        // SET values are projected as ScalarExpr items so that arithmetic expressions
9360        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
9361        let mut set_returns: Vec<RetItem> = lookup_vars
9362            .iter()
9363            .map(|v| RetItem {
9364                value: RetVal::Var(v.clone()),
9365                alias: None,
9366            })
9367            .collect();
9368        // One computed column per SET clause; alias is `__sv_<i>`.
9369        let set_val_cols: Vec<String> = stmt
9370            .sets
9371            .iter()
9372            .enumerate()
9373            .map(|(i, _)| format!("__sv_{i}"))
9374            .collect();
9375        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9376            set_returns.push(RetItem {
9377                value: RetVal::ScalarExpr(sc.value.clone()),
9378                alias: Some(col.clone()),
9379            });
9380        }
9381        // Capture relationship types while r is bound; SET does not change them.
9382        for r in &rel_vars {
9383            set_returns.push(RetItem {
9384                value: RetVal::FuncCall {
9385                    name: "type".into(),
9386                    args: vec![Operand::Var(r.clone())],
9387                },
9388                alias: Some(rel_type_alias(r)),
9389            });
9390        }
9391
9392        let read_q = Query {
9393            matches: stmt.matches.clone(),
9394            optional_clauses: vec![],
9395            where_expr: stmt.where_expr.clone(),
9396            unwinds: vec![],
9397            post_unwind_where: None,
9398            stages: vec![],
9399            returns: set_returns,
9400            distinct: false,
9401            order_by: vec![],
9402            skip: None,
9403            limit: None,
9404        };
9405        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9406            detail: format!("plan: {e}"),
9407        })?;
9408        // MATCH phase is read-only; borrow ends before batch opens.
9409        //
9410        // When a role-scoped write is in flight, run the MATCH read through
9411        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
9412        // zero-rows (no SetProp ops generated, no existence-oracle 403).
9413        // Full-authority writes (pending_write_authz=None) keep view().
9414        let match_rs = {
9415            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9416            if let Some(ref mask) = mask_opt {
9417                execute(&self.view_masked(mask), &ops, &Params(params))
9418            } else {
9419                execute(&self.view(), &ops, &Params(params))
9420            }
9421        }
9422        .map_err(|e| GraphError::QueryError {
9423            detail: format!("execute: {e}"),
9424        })?;
9425
9426        // Collect (key, field, value) for each matched row × each SET clause.
9427        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
9428        for row_i in 0..match_rs.len() {
9429            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
9430                let key = match match_rs.get(row_i, &sc.var) {
9431                    Some(Value::Str(k)) => k.clone(),
9432                    _ => {
9433                        return Err(GraphError::QueryError {
9434                            detail: format!(
9435                                "SET variable '{}' did not resolve to a node key",
9436                                sc.var
9437                            ),
9438                        })
9439                    }
9440                };
9441                // The SET value was already evaluated by the executor.
9442                let value = match match_rs.get(row_i, col) {
9443                    Some(v) => v.clone(),
9444                    None => {
9445                        return Err(GraphError::QueryError {
9446                            detail: format!(
9447                                "SET value for {}.{} evaluated to null",
9448                                sc.var, sc.field
9449                            ),
9450                        })
9451                    }
9452                };
9453                set_ops.push((key, sc.field.clone(), value));
9454            }
9455        }
9456
9457        // Apply as one atomic batch.
9458        let props_set = set_ops.len();
9459        let mut batch = self.batch();
9460        for (key, field, value) in set_ops {
9461            batch.set_prop(&key, &field, value);
9462        }
9463        batch.commit()?;
9464
9465        if let Some(returns) = project_returns {
9466            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
9467        }
9468
9469        let mut rs = write_result_set();
9470        rs.push_row(vec![
9471            Some(Value::Int(0)),
9472            Some(Value::Int(props_set as i64)),
9473            Some(Value::Int(0)),
9474        ]);
9475        Ok(rs)
9476    }
9477
9478    fn exec_match_delete(
9479        &mut self,
9480        stmt: core_query::cypher::MatchDeleteStmt,
9481        params: &BTreeMap<String, Value>,
9482    ) -> Result<ResultSet> {
9483        // Collect unique node vars needed to identify edge endpoints.
9484        let mut node_vars: Vec<String> = Vec::new();
9485        for ed in &stmt.deletes {
9486            if !node_vars.contains(&ed.src_var) {
9487                node_vars.push(ed.src_var.clone());
9488            }
9489            if !node_vars.contains(&ed.dst_var) {
9490                node_vars.push(ed.dst_var.clone());
9491            }
9492        }
9493
9494        // Synthesize read query.
9495        let returns: Vec<RetItem> = node_vars
9496            .iter()
9497            .map(|v| RetItem {
9498                value: RetVal::Var(v.clone()),
9499                alias: None,
9500            })
9501            .collect();
9502        let read_q = Query {
9503            matches: stmt.matches,
9504            optional_clauses: vec![],
9505            where_expr: stmt.where_expr,
9506            unwinds: vec![],
9507            post_unwind_where: None,
9508            stages: vec![],
9509            returns,
9510            distinct: false,
9511            order_by: vec![],
9512            skip: None,
9513            limit: None,
9514        };
9515        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9516            detail: format!("plan: {e}"),
9517        })?;
9518        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9519        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9520        let match_rs = {
9521            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9522            if let Some(ref mask) = mask_opt {
9523                execute(&self.view_masked(mask), &ops, &Params(params))
9524            } else {
9525                execute(&self.view(), &ops, &Params(params))
9526            }
9527        }
9528        .map_err(|e| GraphError::QueryError {
9529            detail: format!("execute: {e}"),
9530        })?;
9531
9532        // Collect (etype, src_key, dst_key) for each row × each delete target.
9533        let mut del_ops: Vec<(String, String, String)> = Vec::new();
9534        for row_i in 0..match_rs.len() {
9535            for ed in &stmt.deletes {
9536                let src_key = match match_rs.get(row_i, &ed.src_var) {
9537                    Some(Value::Str(k)) => k.clone(),
9538                    _ => {
9539                        return Err(GraphError::QueryError {
9540                            detail: format!(
9541                                "DELETE src variable '{}' did not resolve to a node key",
9542                                ed.src_var
9543                            ),
9544                        })
9545                    }
9546                };
9547                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
9548                    Some(Value::Str(k)) => k.clone(),
9549                    _ => {
9550                        return Err(GraphError::QueryError {
9551                            detail: format!(
9552                                "DELETE dst variable '{}' did not resolve to a node key",
9553                                ed.dst_var
9554                            ),
9555                        })
9556                    }
9557                };
9558                del_ops.push((ed.etype.clone(), src_key, dst_key));
9559            }
9560        }
9561
9562        // Apply as one atomic batch.
9563        let deleted = del_ops.len();
9564        let mut batch = self.batch();
9565        for (etype, src_key, dst_key) in del_ops {
9566            batch.delete_edge(&etype, &src_key, &dst_key);
9567        }
9568        batch.commit().map_err(|e| match e {
9569            GraphError::RuleOwned { .. } => GraphError::QueryError {
9570                detail: "cannot delete derived edge; retract via the rule or change the property"
9571                    .to_string(),
9572            },
9573            other => other,
9574        })?;
9575
9576        let mut rs = write_result_set();
9577        rs.push_row(vec![
9578            Some(Value::Int(0)),
9579            Some(Value::Int(0)),
9580            Some(Value::Int(deleted as i64)),
9581        ]);
9582        Ok(rs)
9583    }
9584
9585    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
9586    ///
9587    /// Collects the matching node keys via an ephemeral read query, then calls
9588    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
9589    /// the executor first checks that the node has no incident edges; if any
9590    /// remain it returns a named error matching openCypher semantics.
9591    fn exec_match_delete_node(
9592        &mut self,
9593        stmt: MatchDeleteNodeStmt,
9594        params: &BTreeMap<String, Value>,
9595    ) -> Result<ResultSet> {
9596        // Build a read query returning only the node keys we need.
9597        let returns: Vec<RetItem> = stmt
9598            .node_vars
9599            .iter()
9600            .map(|v| RetItem {
9601                value: RetVal::Var(v.clone()),
9602                alias: None,
9603            })
9604            .collect();
9605        let read_q = Query {
9606            matches: stmt.matches,
9607            optional_clauses: vec![],
9608            where_expr: stmt.where_expr,
9609            unwinds: vec![],
9610            post_unwind_where: None,
9611            stages: vec![],
9612            returns,
9613            distinct: false,
9614            order_by: vec![],
9615            skip: None,
9616            limit: None,
9617        };
9618        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
9619            detail: format!("plan: {e}"),
9620        })?;
9621        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
9622        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
9623        let match_rs = {
9624            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9625            if let Some(ref mask) = mask_opt {
9626                execute(&self.view_masked(mask), &ops, &Params(params))
9627            } else {
9628                execute(&self.view(), &ops, &Params(params))
9629            }
9630        }
9631        .map_err(|e| GraphError::QueryError {
9632            detail: format!("execute: {e}"),
9633        })?;
9634
9635        // Collect unique node keys to delete (deduplicate across rows × vars).
9636        let mut keys: Vec<String> = Vec::new();
9637        for row_i in 0..match_rs.len() {
9638            for var in &stmt.node_vars {
9639                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
9640                    if !keys.contains(k) {
9641                        keys.push(k.clone());
9642                    }
9643                }
9644            }
9645        }
9646
9647        if !stmt.detach {
9648            // openCypher bare DELETE: error if any matched node has incident edges.
9649            for key in &keys {
9650                if let Some(id) = self.ids.get(key) {
9651                    let tv = self.topo_view();
9652                    let has_edges = tv.etypes().any(|et| {
9653                        !tv.neighbors(et, Direction::Out, id).is_empty()
9654                            || !tv.neighbors(et, Direction::In, id).is_empty()
9655                    });
9656                    if has_edges {
9657                        return Err(GraphError::QueryError {
9658                            detail: format!(
9659                                "Cannot delete node `{key}` because it still has incident edges. \
9660                                 Use DETACH DELETE to remove the node and all its edges."
9661                            ),
9662                        });
9663                    }
9664                }
9665            }
9666        }
9667
9668        let mut nodes_deleted = 0i64;
9669        let mut edges_deleted = 0i64;
9670        for key in keys {
9671            match self.delete_node(&key) {
9672                Ok(report) => {
9673                    nodes_deleted += 1;
9674                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
9675                }
9676                Err(GraphError::KeyNotFound { .. }) => {
9677                    // Node may have been deleted by an earlier iteration (e.g., via
9678                    // multiple MATCH rows for the same node).  Safe to skip.
9679                }
9680                Err(e) => return Err(e),
9681            }
9682        }
9683
9684        let mut rs = write_result_set();
9685        rs.push_row(vec![
9686            Some(Value::Int(0)),
9687            Some(Value::Int(0)),
9688            Some(Value::Int(nodes_deleted + edges_deleted)),
9689        ]);
9690        Ok(rs)
9691    }
9692
9693    /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
9694    /// the pattern named one, or the executing role's sole namespace when it
9695    /// did not. A role bound to two or more namespaces cannot choose, and is
9696    /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
9697    /// refuses a named `ns` the role cannot write.
9698    fn merge_create_props(
9699        &self,
9700        key_field: &str,
9701        key_value: &Value,
9702        named_ns: Option<&Value>,
9703    ) -> Result<Vec<(String, Value)>> {
9704        let mut props = vec![(key_field.to_string(), key_value.clone())];
9705        if let Some(ns) = named_ns {
9706            props.push((NS_PROP.to_string(), ns.clone()));
9707            return Ok(props);
9708        }
9709        if let Some(ns) = self.merge_create_stamp_ns()? {
9710            props.push((NS_PROP.to_string(), Value::Str(ns)));
9711        }
9712        Ok(props)
9713    }
9714
9715    /// The namespace a role-scoped MERGE create stamps when the pattern does
9716    /// not name `ns`. `None` = unscoped / full authority, so the node lands in
9717    /// `default`.
9718    fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
9719        let Some(authz) = self.pending_write_authz.as_ref() else {
9720            return Ok(None);
9721        };
9722        let Some(def) = self.role_def_for(&authz.role) else {
9723            return Ok(None);
9724        };
9725        match def.namespaces.as_deref() {
9726            Some([only]) => Ok(Some(only.clone())),
9727            Some(_) => Err(GraphError::RoleWriteDenied {
9728                reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
9729            }),
9730            None => Ok(None),
9731        }
9732    }
9733
9734    fn exec_merge(
9735        &mut self,
9736        stmt: core_query::cypher::MergeStmt,
9737        params: &BTreeMap<String, Value>,
9738    ) -> Result<ResultSet> {
9739        // MERGE: check if a node with the given key already exists.
9740        let key = match &stmt.key_value {
9741            Value::Str(s) => s.clone(),
9742            _ => {
9743                return Err(GraphError::QueryError {
9744                    detail: format!(
9745                        "MERGE key value must be a string (got {:?})",
9746                        stmt.key_value
9747                    ),
9748                })
9749            }
9750        };
9751
9752        if let Some(var) = stmt.var.as_deref() {
9753            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
9754                if sc.var != var {
9755                    return Err(GraphError::QueryError {
9756                        detail: format!(
9757                            "SET variable '{}' does not match MERGE variable '{var}'",
9758                            sc.var
9759                        ),
9760                    });
9761                }
9762            }
9763        }
9764
9765        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
9766        //
9767        // MERGE scope precondition: check create OR update scope for the
9768        // declared label BEFORE calling `has_node` (timing-oracle closure,
9769        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
9770        // unscoped roles — the scope denial fires without touching the key store).
9771        //
9772        // Clone to avoid holding a borrow on `self.pending_write_authz` while
9773        // also calling `self.ids.get(key)`.
9774        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
9775            let has_create = authz.scope.create_labels.contains(&stmt.label);
9776            let has_update = authz.scope.update_labels.contains(&stmt.label);
9777            if !has_create && !has_update {
9778                // Scope-before-lookup: 403 without has_node call (timing oracle
9779                // closure — see test_merge_unscoped_no_key_lookup).
9780                return Err(GraphError::RoleWriteDenied {
9781                    reason: format!(
9782                        "role-bound token: label '{}' not in write scope (create_labels)",
9783                        stmt.label
9784                    ),
9785                });
9786            }
9787            // Key lookup under mask.
9788            match self.ids.get(key.as_str()) {
9789                Some(id) if authz.mask.contains_id(id) => {
9790                    // Visible: must have update scope to proceed to match arm.
9791                    if !has_update {
9792                        return Err(GraphError::RoleWriteDenied {
9793                            reason: format!(
9794                                "role-bound token: label '{}' not in write scope (update_labels)",
9795                                stmt.label
9796                            ),
9797                        });
9798                    }
9799                    true // existed = true → match arm
9800                }
9801                Some(_) => {
9802                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
9803                    return Err(GraphError::RoleWriteDenied {
9804                        reason: "role-bound token: target node not visible".into(),
9805                    });
9806                }
9807                None => {
9808                    // Absent: must have create scope to proceed to the create arm.
9809                    //
9810                    // Update-only roles (create_labels empty, update_labels set):
9811                    // return the SAME "not visible" error as the hidden-key branch
9812                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
9813                    // "confirm existence of hidden nodes: No").
9814                    //
9815                    // Create-scoped roles (has_create=true): absent → create arm
9816                    // as before.  The accepted structural key-existence disclosure
9817                    // (§THREAT-MODEL) applies only when the role holds create scope.
9818                    if !has_create {
9819                        return Err(GraphError::RoleWriteDenied {
9820                            reason: "role-bound token: target node not visible".into(),
9821                        });
9822                    }
9823                    false // existed = false → create arm
9824                }
9825            }
9826        } else {
9827            // Full authority: use the existing non-masked has_node check.
9828            self.has_node(&key)
9829        };
9830
9831        let existed = merge_existed;
9832        let create_props = if existed {
9833            None
9834        } else {
9835            Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
9836        };
9837        let mut created = 0i64;
9838        if create_props.is_some() || !stmt.on_match.is_empty() {
9839            let mut batch = self.batch();
9840            if let Some(props) = create_props {
9841                batch.insert_node(&stmt.label, &key, props);
9842                for sc in &stmt.on_create {
9843                    let value = resolve_merge_set_value(&sc.value, params)?;
9844                    batch.set_prop(&key, &sc.field, value);
9845                }
9846                created = 1;
9847            } else {
9848                for sc in &stmt.on_match {
9849                    let value = resolve_merge_set_value(&sc.value, params)?;
9850                    batch.set_prop(&key, &sc.field, value);
9851                }
9852            }
9853            batch.commit()?;
9854        }
9855
9856        // Refresh the role mask so the just-created node is visible to this
9857        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
9858        // (apply_schema subset rule), so the new node's label is already in the
9859        // role's read scope — this never widens beyond the role's declared labels.
9860        if !existed {
9861            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
9862                let new_mask = self.mask_for_role(&role)?;
9863                if let Some(a) = self.pending_write_authz.as_mut() {
9864                    a.mask = new_mask;
9865                }
9866            }
9867        }
9868
9869        // Optional RETURN clause: project the node (created or matched) as a read result.
9870        if let Some(returns) = stmt.returns {
9871            let var = stmt.var.as_deref().unwrap_or("_mn0");
9872            let q = Query {
9873                matches: vec![Pattern {
9874                    start: NodePat {
9875                        var: Some(var.to_string()),
9876                        label: Some(stmt.label.clone()),
9877                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
9878                    },
9879                    chain: vec![],
9880                    shortest: false,
9881                }],
9882                optional_clauses: vec![],
9883                where_expr: None,
9884                unwinds: vec![],
9885                post_unwind_where: None,
9886                stages: vec![],
9887                returns,
9888                distinct: false,
9889                order_by: vec![],
9890                skip: None,
9891                limit: None,
9892            };
9893            let ops = plan(&q).map_err(|e| GraphError::QueryError {
9894                detail: format!("plan: {e}"),
9895            })?;
9896            // Use view_masked when a role-scoped write is in flight so the
9897            // post-merge projection is consistent with the masked read phase.
9898            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
9899            return (if let Some(ref mask) = mask_opt {
9900                execute(&self.view_masked(mask), &ops, &Params(params))
9901            } else {
9902                execute(&self.view(), &ops, &Params(params))
9903            })
9904            .map_err(|e| GraphError::QueryError {
9905                detail: format!("execute: {e}"),
9906            });
9907        }
9908
9909        let mut rs = write_result_set();
9910        rs.push_row(vec![
9911            Some(Value::Int(created)),
9912            Some(Value::Int(0)),
9913            Some(Value::Int(0)),
9914        ]);
9915        Ok(rs)
9916    }
9917
9918    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
9919    /// annotated with rule name, edge type, direction, and weight.
9920    /// Results are sorted by (rule, edge_type).
9921    /// Returns `Err(KeyNotFound)` if either key is unknown.
9922    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
9923        self.ensure_v8_base_sections_loaded();
9924        let id_a = self
9925            .ids
9926            .get(key_a)
9927            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
9928        let id_b = self
9929            .ids
9930            .get(key_b)
9931            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
9932
9933        let mut results = Vec::new();
9934
9935        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
9936        // rather than O(total provenance).
9937        let scan = if self.engine.provenance_touching_len(id_a)
9938            <= self.engine.provenance_touching_len(id_b)
9939        {
9940            id_a
9941        } else {
9942            id_b
9943        };
9944        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
9945            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
9946                continue;
9947            }
9948            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
9949                continue;
9950            };
9951            let edge_type = match self.syms.resolve(etype) {
9952                Some(s) => s.to_string(),
9953                None => continue,
9954            };
9955            // Provenance (src, dst) ids come from the archived PROVENANCE section
9956            // (large, no eager CRC).  A corrupt section can produce ids that are
9957            // out of range; return Corrupt rather than panic.
9958            let src_key = self
9959                .ids
9960                .key_of(src)
9961                .ok_or_else(|| GraphError::Corrupt {
9962                    detail: format!("v8: provenance src id {src} not in id table"),
9963                })?
9964                .to_string();
9965            let dst_key = self
9966                .ids
9967                .key_of(dst)
9968                .ok_or_else(|| GraphError::Corrupt {
9969                    detail: format!("v8: provenance dst id {dst} not in id table"),
9970                })?
9971                .to_string();
9972            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
9973                self.edge_props_view()
9974                    .get(etype, src, dst, prop)
9975                    .and_then(|v| {
9976                        if let Value::Float(f) = v {
9977                            Some(f)
9978                        } else {
9979                            None
9980                        }
9981                    })
9982            });
9983            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
9984            // still have a score: recompute it from the predicate so explain
9985            // never reports "no score" for an edge the engine scored.  Via-hop
9986            // rules score over their via set, not over (src, dst), so leave
9987            // those None rather than report a number the rule did not produce.
9988            let weight = stored.or_else(|| {
9989                if rule_def.via_edge.is_some() {
9990                    return None;
9991                }
9992                let props_view = build_props_view(&self.props, &self.base);
9993                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
9994                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
9995                let src_view = NodeView {
9996                    key: &src_key,
9997                    props: &src_get,
9998                };
9999                let dst_view = NodeView {
10000                    key: &dst_key,
10001                    props: &dst_get,
10002                };
10003                evaluate(&rule_def.predicate, &src_view, &dst_view)
10004            });
10005            results.push(Explanation {
10006                rule: rule_name.to_string(),
10007                edge_type,
10008                src_key,
10009                dst_key,
10010                weight,
10011                predicate: PredicateSummary {
10012                    approximate: rule_def.approximate,
10013                    ..PredicateSummary::from(&rule_def.predicate)
10014                },
10015                via_edge: rule_def.via_edge.clone(),
10016            });
10017        }
10018
10019        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
10020        Ok(results)
10021    }
10022
10023    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
10024        let id = self
10025            .ids
10026            .get(key)
10027            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10028        let Some(sym) = self.syms.get(edge_type) else {
10029            return Ok(Vec::new());
10030        };
10031        self.topo_view()
10032            .neighbors(sym, dir, id)
10033            .iter()
10034            .map(|&n| {
10035                self.ids
10036                    .key_of(n)
10037                    .map(|k| k.to_string())
10038                    .ok_or_else(|| GraphError::Corrupt {
10039                        detail: format!("topology id {n} has no key"),
10040                    })
10041            })
10042            .collect::<Result<Vec<_>>>()
10043    }
10044
10045    /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
10046    /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
10047    pub fn degree(
10048        &self,
10049        key: &str,
10050        edge_type: Option<&str>,
10051        direction: crate::algo::AlgoDir,
10052    ) -> Result<u64> {
10053        let id = self
10054            .ids
10055            .get(key)
10056            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10057        let topo = self.topo_view();
10058        Ok(Self::unique_directed_degree(
10059            &topo, &self.syms, id, edge_type, direction,
10060        ))
10061    }
10062
10063    /// Unique directed degree for a subset or a label scan.
10064    ///
10065    /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
10066    /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
10067    /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
10068    #[allow(clippy::too_many_arguments)]
10069    pub fn degrees(
10070        &self,
10071        keys: Option<&[String]>,
10072        label: Option<&str>,
10073        where_: Option<&PropPredicate>,
10074        edge_type: Option<&str>,
10075        direction: crate::algo::AlgoDir,
10076        limit: Option<usize>,
10077    ) -> Result<Vec<(String, u64)>> {
10078        if let Some(pred) = where_ {
10079            pred.validate_named("where")
10080                .map_err(|detail| GraphError::QueryError { detail })?;
10081        }
10082        if matches!(keys, Some(ks) if ks.is_empty()) {
10083            return Ok(Vec::new());
10084        }
10085        let view = self.view();
10086        let ids: Vec<u32> = match keys {
10087            Some(ks) => {
10088                let mut seen = HashSet::new();
10089                let mut out = Vec::new();
10090                for k in ks {
10091                    let Some(id) = view.ids.get(k) else {
10092                        continue;
10093                    };
10094                    if !seen.insert(id) {
10095                        continue;
10096                    }
10097                    if let Some(pred) = where_ {
10098                        let holds = match view.prop(id, &pred.field) {
10099                            None => pred.holds(None),
10100                            Some(vr) => pred.holds(Some(vr.as_value())),
10101                        };
10102                        if !holds {
10103                            continue;
10104                        }
10105                    }
10106                    out.push(id);
10107                }
10108                out
10109            }
10110            None => Self::vector_candidates(&view, label, where_),
10111        };
10112        let mut out: Vec<(String, u64)> = ids
10113            .into_iter()
10114            .filter_map(|id| {
10115                let key = self.ids.key_of(id)?.to_string();
10116                let deg =
10117                    Self::unique_directed_degree(&view.topo, view.syms, id, edge_type, direction);
10118                Some((key, deg))
10119            })
10120            .collect();
10121        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
10122        if let Some(lim) = limit {
10123            out.truncate(lim);
10124        }
10125        Ok(out)
10126    }
10127
10128    /// Unique neighbour count for `id` across `edge_type` (or all types) and
10129    /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
10130    fn unique_directed_degree(
10131        topo: &TopologyView<'_>,
10132        syms: &Interner,
10133        id: u32,
10134        edge_type: Option<&str>,
10135        direction: crate::algo::AlgoDir,
10136    ) -> u64 {
10137        let dirs: &[Direction] = match direction {
10138            crate::algo::AlgoDir::Out => &[Direction::Out],
10139            crate::algo::AlgoDir::In => &[Direction::In],
10140            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
10141        };
10142        match edge_type {
10143            Some(name) => {
10144                let Some(et) = syms.get(name) else {
10145                    return 0;
10146                };
10147                dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
10148            }
10149            None => topo
10150                .etypes()
10151                .map(|et| {
10152                    dirs.iter()
10153                        .map(|&d| topo.degree(et, d, id) as u64)
10154                        .sum::<u64>()
10155                })
10156                .sum(),
10157        }
10158    }
10159
10160    /// Return the last-change commit sequence for `key`, or `None` if the node
10161    /// does not exist or has never been mutated since the last V5-V7 snapshot
10162    /// (horizon-bounded for legacy stores).
10163    ///
10164    /// The returned sequence is a monotonically increasing counter that starts
10165    /// at 1 for the first commit after `open` and increments with every
10166    /// successful write.  WAL replay at open also assigns sequences (1..N for N
10167    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
10168    ///
10169    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
10170    /// in the snapshot but not touched by any WAL frame will return `None`
10171    /// (horizon-bounded: CAS against such nodes is only safe after the first
10172    /// V8 snapshot or after the node is next mutated).
10173    pub fn last_changed(&self, key: &str) -> Option<u64> {
10174        let id = self.ids.get(key)?;
10175        self.last_change.get(&id).copied()
10176    }
10177
10178    /// The current commit sequence (number of successful commits since open,
10179    /// including WAL replay frames).  Useful for recording a baseline before
10180    /// a read-modify-write cycle.
10181    pub fn commit_seq(&self) -> u64 {
10182        self.commit_seq
10183    }
10184
10185    /// Check that all `preconds` are satisfied against the current db state.
10186    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
10187    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
10188        for precond in preconds {
10189            match precond {
10190                Precondition::NodeUnchangedSince { key, expected } => {
10191                    // Missing entry means the node predates the WAL window or
10192                    // does not exist; treat as 0 (before any commit).
10193                    let actual = self.last_changed(key).unwrap_or_default();
10194                    if actual != *expected {
10195                        return Err(GraphError::CasConflict {
10196                            key: key.clone(),
10197                            expected: *expected,
10198                            actual,
10199                        });
10200                    }
10201                }
10202                Precondition::NodeAbsent { key } => {
10203                    // Node must not exist (not live).
10204                    if self.ids.get(key).is_some() {
10205                        let actual = self.last_changed(key).unwrap_or(0);
10206                        return Err(GraphError::CasConflict {
10207                            key: key.clone(),
10208                            expected: u64::MAX,
10209                            actual,
10210                        });
10211                    }
10212                }
10213            }
10214        }
10215        Ok(())
10216    }
10217
10218    /// Apply a batch of mutations with compare-and-set preconditions.
10219    ///
10220    /// All preconditions are checked atomically before any operation is applied.
10221    /// If any precondition fails, the entire batch is rejected with
10222    /// [`GraphError::CasConflict`] and no WAL frame is written.
10223    ///
10224    /// # Returns
10225    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
10226    ///
10227    /// # Errors
10228    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
10229    /// - Any error that [`write_batch`] would return for the ops themselves.
10230    pub fn write_batch_cas(
10231        &mut self,
10232        preconds: Vec<Precondition>,
10233        ops: Vec<BatchOp>,
10234    ) -> Result<(usize, usize)> {
10235        self.check_preconditions(&preconds)?;
10236        self.commit_logged_batch(ops, None, None)
10237    }
10238
10239    /// Update the per-node last-change map for a WAL record at commit `seq`.
10240    ///
10241    /// Called after a successful apply to record which nodes were touched.
10242    /// For replay, called with the WAL-frame's replayed seq.
10243    ///
10244    /// Touch definition (see [`Precondition`] doc):
10245    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
10246    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
10247    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
10248    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
10249    /// - Batch → recurse into inner records.
10250    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
10251        match rec {
10252            WalRecord::InsertNode { key, .. }
10253            | WalRecord::SetProp { key, .. }
10254            | WalRecord::RemoveProp { key, .. } => {
10255                if let Some(id) = self.ids.get(key) {
10256                    self.last_change.insert(id, seq);
10257                }
10258            }
10259            WalRecord::InsertNodeId { key, .. } => {
10260                if let Some(id) = self.ids.get(key) {
10261                    self.last_change.insert(id, seq);
10262                }
10263            }
10264            WalRecord::SetPropId { id, .. } => {
10265                self.last_change.insert(*id, seq);
10266            }
10267            WalRecord::InsertEdge {
10268                src_key, dst_key, ..
10269            }
10270            | WalRecord::DeleteEdge {
10271                src_key, dst_key, ..
10272            } => {
10273                if let Some(src_id) = self.ids.get(src_key) {
10274                    self.last_change.insert(src_id, seq);
10275                }
10276                if let Some(dst_id) = self.ids.get(dst_key) {
10277                    self.last_change.insert(dst_id, seq);
10278                }
10279            }
10280            WalRecord::InsertEdgeId { src, dst, .. } => {
10281                self.last_change.insert(*src, seq);
10282                self.last_change.insert(*dst, seq);
10283            }
10284            // DeleteNode: node is tombstoned; last_changed(key) returns None for
10285            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
10286            // History markers: state no-ops; the underlying mutation already
10287            // touched the relevant nodes' last_change entries.
10288            WalRecord::DeleteNode { .. }
10289            | WalRecord::DerivedEdgeAdded { .. }
10290            | WalRecord::DerivedEdgeRetracted { .. }
10291            | WalRecord::Intern { .. }
10292            | WalRecord::CreateRule { .. }
10293            | WalRecord::DeleteRule { .. }
10294            | WalRecord::RebuildRule { .. }
10295            | WalRecord::CreateView { .. }
10296            | WalRecord::DeleteView { .. }
10297            | WalRecord::EnableFulltext { .. }
10298            | WalRecord::DisableFulltext { .. }
10299            | WalRecord::EnableIndex { .. }
10300            | WalRecord::DisableIndex { .. } => {}
10301            // RenameNode: node id is stable; update last_change via the new key.
10302            // Called after apply(), so ids already reflects new_key.
10303            WalRecord::RenameNode { new_key, .. } => {
10304                if let Some(id) = self.ids.get(new_key) {
10305                    self.last_change.insert(id, seq);
10306                }
10307            }
10308            WalRecord::Batch(inner) => {
10309                for inner_rec in inner {
10310                    self.update_last_change_from_rec(inner_rec, seq);
10311                }
10312            }
10313        }
10314    }
10315
10316    pub fn node_count(&self) -> usize {
10317        self.ids.len()
10318    }
10319
10320    /// Configure archive retention: keep the `N` newest WAL archives at each
10321    /// [`snapshot_with`] call when `archive_wal: true`.
10322    ///
10323    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
10324    /// `Some(0)` or `None` → unlimited (no pruning).
10325    ///
10326    /// Pruning only ever happens inside [`snapshot_with`]; this method only
10327    /// stores the policy.  Archives below the retention limit are deleted
10328    /// oldest-first.  The horizon floor is updated so that
10329    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
10330    /// in pruned archives rather than silently returning wrong data.
10331    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
10332        self.wal_archive_retention = keep;
10333    }
10334
10335    /// Delete any WAL archives that are fully below the current horizon floor.
10336    ///
10337    /// Orphaned archives arise when the floor is written first during retention
10338    /// pruning and then a crash interrupts the archive-delete sequence.  The
10339    /// opening cleanup ensures no subsequent read path sees stale data.
10340    ///
10341    /// Under the monotonic naming scheme, the archive name N equals the
10342    /// cumulative end-frame index of the archive in global commit space (i.e.
10343    /// the archive covers global frames `[prev_n, N)`).  An archive is
10344    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
10345    /// below the floor and have already been counted in it.
10346    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
10347        if self.wal_horizon_floor == 0 {
10348            // Floor at 0 means no pruning has ever occurred; nothing to clean.
10349            return Ok(());
10350        }
10351        let archive_ns = self.fs.list_archives()?;
10352        for n in archive_ns {
10353            if n <= self.wal_horizon_floor {
10354                // Archive N ends at global frame N; all its frames are below
10355                // the floor (floor already accounts for them) → orphaned.
10356                self.fs.delete_archive(n).map_err(GraphError::Io)?;
10357            } else {
10358                // Archives are sorted ascending; first one above floor stops scan.
10359                break;
10360            }
10361        }
10362        Ok(())
10363    }
10364
10365    /// Collect all WAL frames from surviving archives (oldest-first) then the
10366    /// live WAL into one flat list, and return the total along with the number
10367    /// of archive frames at the front of the list.
10368    ///
10369    /// Commit indices into the returned list are LOCAL (0 = first frame of
10370    /// oldest surviving archive).  To obtain the GLOBAL index add
10371    /// `self.wal_horizon_floor`.
10372    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
10373        let archive_ns = self.fs.list_archives()?;
10374        let mut all: Vec<WalRecord> = Vec::new();
10375        for n in archive_ns {
10376            let bytes = self.fs.read_archive(n)?;
10377            let (frames, _) = decode_all(&bytes);
10378            all.extend(frames);
10379        }
10380        let archive_count = all.len() as u64;
10381        let live_bytes = self.fs.read(FileId::Wal)?;
10382        let (live_frames, _) = decode_all(&live_bytes);
10383        all.extend(live_frames);
10384        Ok((all, archive_count))
10385    }
10386
10387    /// Return the total number of committed WAL frames visible in the current
10388    /// horizon window, including frames in surviving WAL archives.
10389    ///
10390    /// This is the exclusive upper bound for valid `at_commit` indices in
10391    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
10392    ///
10393    /// Returns the horizon floor when all surviving history is empty.
10394    pub fn wal_total_commits(&self) -> Result<u64> {
10395        let (frames, _) = self.all_frames()?;
10396        Ok(self.wal_horizon_floor + frames.len() as u64)
10397    }
10398
10399    /// The global frame index of the first commit reachable through surviving
10400    /// archives (0 when no archives have been pruned).
10401    pub fn wal_horizon_floor(&self) -> u64 {
10402        self.wal_horizon_floor
10403    }
10404
10405    /// Return the per-node change history for `key` by scanning the on-disk WAL.
10406    ///
10407    /// ## Horizon
10408    ///
10409    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
10410    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
10411    /// zero-cost contract; a durable history log is out of scope.
10412    ///
10413    /// ## Derived edges
10414    ///
10415    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
10416    /// history. Only edges written directly by the application are recorded.
10417    ///
10418    /// ## Deleted nodes
10419    ///
10420    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
10421    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
10422    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
10423    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
10424    ///
10425    /// ## Dense-id edge entries and tombstoned partners
10426    ///
10427    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
10428    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
10429    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
10430    /// Build commit-bounded alias intervals for `queried_key`.
10431    ///
10432    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
10433    /// A record written under `key` at commit `c` matches the queried identity iff
10434    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
10435    ///
10436    /// Each alias entry carries both a lower and an upper bound so that key-reuse
10437    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
10438    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
10439    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
10440    /// only identity-2's events (commits 7–9 under "a") are in scope.
10441    ///
10442    /// Only **forward aliasing**: querying the *new* key surfaces events written
10443    /// under the *old* key.  The reverse direction is not supported.
10444    fn build_key_alias_intervals(
10445        &self,
10446        frames: &[core_storage::wal::WalRecord],
10447        queried_key: &str,
10448    ) -> Vec<(String, u64, Option<u64>)> {
10449        use core_storage::wal::WalRecord;
10450
10451        // Pre-pass: build reverse_rename and key_starts maps.
10452        let mut reverse_rename: HashMap<String, (String, u64)> = HashMap::new();
10453        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
10454
10455        for (local_i, frame) in frames.iter().enumerate() {
10456            let commit = self.wal_horizon_floor + local_i as u64;
10457            let records: &[WalRecord] = match frame {
10458                WalRecord::Batch(inner) => inner.as_slice(),
10459                single => std::slice::from_ref(single),
10460            };
10461            for rec in records {
10462                match rec {
10463                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
10464                        key_starts.entry(key.clone()).or_default().push(commit);
10465                    }
10466                    WalRecord::RenameNode { old_key, new_key } => {
10467                        // new_key came into existence at this commit.
10468                        key_starts.entry(new_key.clone()).or_default().push(commit);
10469                        // Record the reverse rename: new_key was introduced by renaming old_key.
10470                        reverse_rename.insert(new_key.clone(), (old_key.clone(), commit));
10471                    }
10472                    _ => {}
10473                }
10474            }
10475        }
10476
10477        // Build alias intervals by following the reverse rename chain.
10478        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
10479        let mut current_key = queried_key.to_string();
10480        let mut current_valid_until: Option<u64> = None;
10481
10482        loop {
10483            // valid_from: the most recent commit where current_key was assigned to this
10484            // identity.  For aliases (valid_until = Some(vu)), find the last start event
10485            // for the key strictly before vu — this is where the alias's occupancy by
10486            // this identity began, correctly excluding prior identities that reused the key.
10487            let valid_from = if let Some(vu) = current_valid_until {
10488                key_starts
10489                    .get(&current_key)
10490                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
10491                    .unwrap_or(self.wal_horizon_floor)
10492            } else {
10493                // Queried key — no upper bound; may have been introduced at any commit.
10494                self.wal_horizon_floor
10495            };
10496
10497            result.push((current_key.clone(), valid_from, current_valid_until));
10498
10499            match reverse_rename.get(&current_key) {
10500                Some((old_key, rename_commit)) => {
10501                    current_valid_until = Some(*rename_commit);
10502                    current_key = old_key.clone();
10503                }
10504                None => break,
10505            }
10506        }
10507
10508        result
10509    }
10510
10511    /// Returns true if `record_key` matches any alias interval that covers `commit`.
10512    fn aliases_match(
10513        intervals: &[(String, u64, Option<u64>)],
10514        record_key: &str,
10515        commit: u64,
10516    ) -> bool {
10517        intervals
10518            .iter()
10519            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
10520    }
10521
10522    /// Return the change history of node `key` by scanning the on-disk WAL.
10523    ///
10524    /// ## Horizon
10525    ///
10526    /// History reaches back only as far as the retained WAL. The returned
10527    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
10528    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
10529    /// oldest commit still reachable). When `horizon > 0`, older events were
10530    /// pruned and are not in `items`.
10531    pub fn node_history(
10532        &self,
10533        key: &str,
10534    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
10535        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
10536        use core_storage::wal::WalRecord;
10537
10538        let (frames, _) = self.all_frames()?;
10539        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10540
10541        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
10542        let alias_intervals = self.build_key_alias_intervals(&frames, key);
10543
10544        let mut out: Vec<HistoryEntry> = Vec::new();
10545
10546        for (local_i, frame) in frames.iter().enumerate() {
10547            let commit = self.wal_horizon_floor + local_i as u64;
10548            // Collect the inner records to process — Batch is one commit, single records are one commit.
10549            let records: &[WalRecord] = match frame {
10550                WalRecord::Batch(inner) => inner.as_slice(),
10551                single => std::slice::from_ref(single),
10552            };
10553
10554            for rec in records {
10555                let change = match rec {
10556                    WalRecord::InsertNode { label, key: k, .. }
10557                        if Self::aliases_match(&alias_intervals, k, commit) =>
10558                    {
10559                        Some(HistoryChange::NodeInserted {
10560                            label: label.clone(),
10561                        })
10562                    }
10563                    WalRecord::InsertNodeId { label, key: k, .. }
10564                        if Self::aliases_match(&alias_intervals, k, commit) =>
10565                    {
10566                        let label_str = match self.syms.resolve(*label) {
10567                            Some(s) => s.to_string(),
10568                            None => continue,
10569                        };
10570                        Some(HistoryChange::NodeInserted { label: label_str })
10571                    }
10572                    WalRecord::SetProp {
10573                        key: k,
10574                        field,
10575                        value,
10576                    } if Self::aliases_match(&alias_intervals, k, commit) => {
10577                        Some(HistoryChange::PropSet {
10578                            field: field.clone(),
10579                            value: value.clone(),
10580                        })
10581                    }
10582                    WalRecord::SetPropId { id, field, value } => {
10583                        // Use key_of_historical (not key_of) so a node's prop_set
10584                        // events remain visible after the node is later deleted:
10585                        // key_of returns None for a tombstoned id, which would
10586                        // silently drop every PropSet between insert and delete.
10587                        // Mirrors the InsertEdgeId arm below and edge_history's
10588                        // own id-keyed arms.
10589                        match self.ids.key_of_historical(*id) {
10590                            // key_of_historical returns the last-known (possibly
10591                            // post-rename, possibly post-delete) key; compare to queried key.
10592                            Some(resolved) if resolved == key => {
10593                                let field_str = match self.syms.resolve(*field) {
10594                                    Some(s) => s.to_string(),
10595                                    None => continue,
10596                                };
10597                                Some(HistoryChange::PropSet {
10598                                    field: field_str,
10599                                    value: value.clone(),
10600                                })
10601                            }
10602                            _ => None,
10603                        }
10604                    }
10605                    WalRecord::RemoveProp { key: k, field }
10606                        if Self::aliases_match(&alias_intervals, k, commit) =>
10607                    {
10608                        Some(HistoryChange::PropRemoved {
10609                            field: field.clone(),
10610                        })
10611                    }
10612                    WalRecord::InsertEdge {
10613                        edge_type,
10614                        src_key,
10615                        dst_key,
10616                    } => {
10617                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10618                            Some(HistoryChange::EdgeAdded {
10619                                edge_type: edge_type.clone(),
10620                                other: dst_key.clone(),
10621                                outgoing: true,
10622                            })
10623                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10624                            Some(HistoryChange::EdgeAdded {
10625                                edge_type: edge_type.clone(),
10626                                other: src_key.clone(),
10627                                outgoing: false,
10628                            })
10629                        } else {
10630                            None
10631                        }
10632                    }
10633                    WalRecord::InsertEdgeId { etype, src, dst } => {
10634                        let etype_str = match self.syms.resolve(*etype) {
10635                            Some(s) => s.to_string(),
10636                            None => continue,
10637                        };
10638                        // key_of_historical (not key_of): an edge added before
10639                        // either endpoint was later deleted must still resolve —
10640                        // see the SetPropId arm above and edge_history's
10641                        // InsertEdgeId arm, which use the same lookup for the
10642                        // same reason.
10643                        let src_key = self.ids.key_of_historical(*src);
10644                        let dst_key = self.ids.key_of_historical(*dst);
10645                        if src_key == Some(key) {
10646                            let other = match dst_key {
10647                                Some(s) => s.to_string(),
10648                                None => continue,
10649                            };
10650                            Some(HistoryChange::EdgeAdded {
10651                                edge_type: etype_str,
10652                                other,
10653                                outgoing: true,
10654                            })
10655                        } else if dst_key == Some(key) {
10656                            let other = match src_key {
10657                                Some(s) => s.to_string(),
10658                                None => continue,
10659                            };
10660                            Some(HistoryChange::EdgeAdded {
10661                                edge_type: etype_str,
10662                                other,
10663                                outgoing: false,
10664                            })
10665                        } else {
10666                            None
10667                        }
10668                    }
10669                    WalRecord::DeleteEdge {
10670                        edge_type,
10671                        src_key,
10672                        dst_key,
10673                    } => {
10674                        if Self::aliases_match(&alias_intervals, src_key, commit) {
10675                            Some(HistoryChange::EdgeRemoved {
10676                                edge_type: edge_type.clone(),
10677                                other: dst_key.clone(),
10678                                outgoing: true,
10679                            })
10680                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
10681                            Some(HistoryChange::EdgeRemoved {
10682                                edge_type: edge_type.clone(),
10683                                other: src_key.clone(),
10684                                outgoing: false,
10685                            })
10686                        } else {
10687                            None
10688                        }
10689                    }
10690                    WalRecord::DeleteNode { key: k }
10691                        if Self::aliases_match(&alias_intervals, k, commit) =>
10692                    {
10693                        Some(HistoryChange::NodeDeleted)
10694                    }
10695                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
10696                    _ => None,
10697                };
10698
10699                if let Some(change) = change {
10700                    out.push(HistoryEntry { commit, change });
10701                }
10702            }
10703        }
10704
10705        Ok(HistoryResult {
10706            items: out,
10707            total_commits,
10708            horizon: self.wal_horizon_floor,
10709        })
10710    }
10711
10712    /// Return the per-edge change history between nodes `a` and `b` by scanning
10713    /// the on-disk WAL.
10714    ///
10715    /// ## Horizon
10716    ///
10717    /// History reaches back only to the last WAL-truncating snapshot, exactly
10718    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
10719    /// `total_commits` (= number of WAL frames), which is the exclusive upper
10720    /// bound for valid commit indices.
10721    ///
10722    /// ## Derived edges
10723    ///
10724    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10725    /// WAL markers written by `log_then_apply_with` after each rule-firing
10726    /// mutation. The `rule` field of those events carries the rule name.
10727    ///
10728    /// ## DeleteNode
10729    ///
10730    /// When a node is deleted, its manual incident edges are swept inline without
10731    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
10732    /// events for either endpoint and synthesises `Retracted(rule:None)` events
10733    /// for each manual edge that was active at that point. Derived edges active at
10734    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
10735    /// the engine appends immediately after the `DeleteNode` record; those events
10736    /// carry correct rule attribution and are emitted by the marker arm, not the
10737    /// synthetic sweep.
10738    ///
10739    /// ## Masks
10740    ///
10741    /// Like `node_history`, this method has no mask parameter and returns WAL
10742    /// history regardless of any role mask. For masked history semantics, apply
10743    /// the mask at the caller level.
10744    pub fn edge_history(
10745        &self,
10746        a: &str,
10747        b: &str,
10748    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
10749        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
10750        use core_storage::wal::WalRecord;
10751
10752        let (frames, _) = self.all_frames()?;
10753        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10754
10755        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10756        // Intervals are commit-bounded so recycled keys don't contaminate histories.
10757        let alias_a = self.build_key_alias_intervals(&frames, a);
10758        let alias_b = self.build_key_alias_intervals(&frames, b);
10759
10760        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
10761        // The is_derived flag is used by the DeleteNode sweep: manual edges are
10762        // swept with a synthetic Retracted(rule:None); derived edges are skipped
10763        // because the engine writes a DerivedEdgeRetracted marker immediately after
10764        // the DeleteNode record, which carries the correct rule attribution.
10765        let mut active: Vec<(String, String, String, bool)> = Vec::new();
10766        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
10767
10768        for (local_i, frame) in frames.iter().enumerate() {
10769            let commit = self.wal_horizon_floor + local_i as u64;
10770            let records: &[WalRecord] = match frame {
10771                WalRecord::Batch(inner) => inner.as_slice(),
10772                single => std::slice::from_ref(single),
10773            };
10774
10775            for rec in records {
10776                match rec {
10777                    WalRecord::InsertEdge {
10778                        edge_type,
10779                        src_key,
10780                        dst_key,
10781                    } => {
10782                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10783                            && Self::aliases_match(&alias_b, dst_key, commit);
10784                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10785                            && Self::aliases_match(&alias_a, dst_key, commit);
10786                        if is_ab || is_ba {
10787                            active.push((
10788                                edge_type.clone(),
10789                                src_key.clone(),
10790                                dst_key.clone(),
10791                                false,
10792                            ));
10793                            out.push(EdgeHistoryEvent {
10794                                edge_type: edge_type.clone(),
10795                                commit,
10796                                event: EdgeEvent::Added,
10797                                rule: None,
10798                            });
10799                        }
10800                    }
10801                    WalRecord::InsertEdgeId { etype, src, dst } => {
10802                        let etype_str = match self.syms.resolve(*etype) {
10803                            Some(s) => s.to_string(),
10804                            None => continue,
10805                        };
10806                        // Use key_of_historical so tombstoned nodes (deleted
10807                        // later in the WAL) still resolve during the scan.
10808                        let src_key = self.ids.key_of_historical(*src);
10809                        let dst_key = self.ids.key_of_historical(*dst);
10810                        let is_ab = src_key == Some(a) && dst_key == Some(b);
10811                        let is_ba = src_key == Some(b) && dst_key == Some(a);
10812                        if is_ab || is_ba {
10813                            let src_str = src_key.unwrap().to_string();
10814                            let dst_str = dst_key.unwrap().to_string();
10815                            active.push((etype_str.clone(), src_str, dst_str, false));
10816                            out.push(EdgeHistoryEvent {
10817                                edge_type: etype_str,
10818                                commit,
10819                                event: EdgeEvent::Added,
10820                                rule: None,
10821                            });
10822                        }
10823                    }
10824                    WalRecord::DeleteEdge {
10825                        edge_type,
10826                        src_key,
10827                        dst_key,
10828                    } => {
10829                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10830                            && Self::aliases_match(&alias_b, dst_key, commit);
10831                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10832                            && Self::aliases_match(&alias_a, dst_key, commit);
10833                        if is_ab || is_ba {
10834                            // Remove the first matching active entry (flag ignored).
10835                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
10836                                et == edge_type && s == src_key && d == dst_key
10837                            }) {
10838                                active.remove(pos);
10839                            }
10840                            out.push(EdgeHistoryEvent {
10841                                edge_type: edge_type.clone(),
10842                                commit,
10843                                event: EdgeEvent::Retracted,
10844                                rule: None,
10845                            });
10846                        }
10847                    }
10848                    WalRecord::DeleteNode { key: k }
10849                        if Self::aliases_match(&alias_a, k, commit)
10850                            || Self::aliases_match(&alias_b, k, commit) =>
10851                    {
10852                        // Sweep: implicitly retract only MANUAL active edges.
10853                        // Derived active edges are skipped here because the rule
10854                        // engine appends a DerivedEdgeRetracted marker immediately
10855                        // after this DeleteNode record; that marker produces the
10856                        // single correctly-attributed Retracted event.  Derived
10857                        // entries are dropped from `active` (the marker arm's
10858                        // idempotent retain finds nothing to remove).
10859                        for (et, _, _, is_derived) in active.drain(..) {
10860                            if !is_derived {
10861                                out.push(EdgeHistoryEvent {
10862                                    edge_type: et,
10863                                    commit,
10864                                    event: EdgeEvent::Retracted,
10865                                    rule: None,
10866                                });
10867                            }
10868                            // Derived: drop silently; marker carries the Retracted event.
10869                        }
10870                    }
10871                    WalRecord::DerivedEdgeAdded {
10872                        rule,
10873                        edge_type: et,
10874                        src_key,
10875                        dst_key,
10876                    } => {
10877                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10878                            && Self::aliases_match(&alias_b, dst_key, commit);
10879                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10880                            && Self::aliases_match(&alias_a, dst_key, commit);
10881                        if is_ab || is_ba {
10882                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
10883                            out.push(EdgeHistoryEvent {
10884                                edge_type: et.clone(),
10885                                commit,
10886                                event: EdgeEvent::Added,
10887                                rule: Some(rule.clone()),
10888                            });
10889                        }
10890                    }
10891                    WalRecord::DerivedEdgeRetracted {
10892                        rule,
10893                        edge_type: et,
10894                        src_key,
10895                        dst_key,
10896                    } => {
10897                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10898                            && Self::aliases_match(&alias_b, dst_key, commit);
10899                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10900                            && Self::aliases_match(&alias_a, dst_key, commit);
10901                        if is_ab || is_ba {
10902                            // Push unconditionally: a derived edge whose Added marker
10903                            // predates the history horizon has no `active` entry, but
10904                            // the retraction is still a real in-window event.
10905                            // Remove from active idempotently if present.
10906                            active.retain(|(aet, s, d, _)| {
10907                                !(aet == et && s == src_key && d == dst_key)
10908                            });
10909                            out.push(EdgeHistoryEvent {
10910                                edge_type: et.clone(),
10911                                commit,
10912                                event: EdgeEvent::Retracted,
10913                                rule: Some(rule.clone()),
10914                            });
10915                        }
10916                    }
10917                    // All other records (InsertNode, SetProp, CreateRule, etc.)
10918                    // do not affect edges between a and b.
10919                    _ => {}
10920                }
10921            }
10922        }
10923
10924        Ok(HistoryResult {
10925            items: out,
10926            total_commits,
10927            horizon: self.wal_horizon_floor,
10928        })
10929    }
10930
10931    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
10932    /// (in either direction) at the WAL commit `at_commit`.
10933    ///
10934    /// ## Horizon
10935    ///
10936    /// Valid commit indices are `0..total_commits` where `total_commits` is the
10937    /// number of WAL frames. An `at_commit >= total_commits` is outside the
10938    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
10939    ///
10940    /// ## Derived edges
10941    ///
10942    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
10943    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
10944    /// and therefore includes derived edges in its point-in-time evaluation,
10945    /// matching `edge_history`'s fidelity.
10946    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
10947        use core_storage::wal::WalRecord;
10948
10949        let (frames, _) = self.all_frames()?;
10950        let total_commits = self.wal_horizon_floor + frames.len() as u64;
10951
10952        // Horizon floor: commits in pruned archives are unreachable.
10953        if at_commit < self.wal_horizon_floor {
10954            return Err(GraphError::CommitOutOfRange {
10955                commit: at_commit,
10956                total: total_commits,
10957                floor: self.wal_horizon_floor,
10958            });
10959        }
10960        if at_commit >= total_commits {
10961            return Err(GraphError::CommitOutOfRange {
10962                commit: at_commit,
10963                total: total_commits,
10964                floor: self.wal_horizon_floor,
10965            });
10966        }
10967
10968        // Resolve all historical names for a and b (handles RenameNode in the WAL).
10969        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
10970        let alias_a = self.build_key_alias_intervals(&frames, a);
10971        let alias_b = self.build_key_alias_intervals(&frames, b);
10972
10973        // Local index into surviving frames (0 = first frame of oldest archive).
10974        let local_commit = at_commit - self.wal_horizon_floor;
10975
10976        // Replay local frames 0..=local_commit, tracking active edges.
10977        let mut active: BTreeSet<(String, String, String)> = BTreeSet::new();
10978
10979        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
10980            let commit = self.wal_horizon_floor + local_i as u64;
10981            let records: &[WalRecord] = match frame {
10982                WalRecord::Batch(inner) => inner.as_slice(),
10983                single => std::slice::from_ref(single),
10984            };
10985
10986            for rec in records {
10987                match rec {
10988                    WalRecord::InsertEdge {
10989                        edge_type: et,
10990                        src_key,
10991                        dst_key,
10992                    } => {
10993                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
10994                            && Self::aliases_match(&alias_b, dst_key, commit);
10995                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
10996                            && Self::aliases_match(&alias_a, dst_key, commit);
10997                        if is_ab || is_ba {
10998                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
10999                        }
11000                    }
11001                    WalRecord::InsertEdgeId { etype, src, dst } => {
11002                        let etype_str = match self.syms.resolve(*etype) {
11003                            Some(s) => s.to_string(),
11004                            None => continue,
11005                        };
11006                        // Use key_of_historical so tombstoned nodes resolve.
11007                        let src_key = self.ids.key_of_historical(*src);
11008                        let dst_key = self.ids.key_of_historical(*dst);
11009                        let is_ab = src_key == Some(a) && dst_key == Some(b);
11010                        let is_ba = src_key == Some(b) && dst_key == Some(a);
11011                        if is_ab || is_ba {
11012                            active.insert((
11013                                etype_str,
11014                                src_key.unwrap().to_string(),
11015                                dst_key.unwrap().to_string(),
11016                            ));
11017                        }
11018                    }
11019                    WalRecord::DeleteEdge {
11020                        edge_type: et,
11021                        src_key,
11022                        dst_key,
11023                    } => {
11024                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
11025                            && Self::aliases_match(&alias_b, dst_key, commit);
11026                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
11027                            && Self::aliases_match(&alias_a, dst_key, commit);
11028                        if is_ab || is_ba {
11029                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
11030                        }
11031                    }
11032                    WalRecord::DeleteNode { key: k }
11033                        if Self::aliases_match(&alias_a, k, commit)
11034                            || Self::aliases_match(&alias_b, k, commit) =>
11035                    {
11036                        // All edges touching the deleted node are gone.
11037                        active.retain(|(_, s, d)| s != k && d != k);
11038                    }
11039                    WalRecord::DerivedEdgeAdded {
11040                        edge_type: et,
11041                        src_key,
11042                        dst_key,
11043                        ..
11044                    } => {
11045                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
11046                            && Self::aliases_match(&alias_b, dst_key, commit);
11047                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
11048                            && Self::aliases_match(&alias_a, dst_key, commit);
11049                        if is_ab || is_ba {
11050                            active.insert((et.clone(), src_key.clone(), dst_key.clone()));
11051                        }
11052                    }
11053                    WalRecord::DerivedEdgeRetracted {
11054                        edge_type: et,
11055                        src_key,
11056                        dst_key,
11057                        ..
11058                    } => {
11059                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
11060                            && Self::aliases_match(&alias_b, dst_key, commit);
11061                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
11062                            && Self::aliases_match(&alias_a, dst_key, commit);
11063                        if is_ab || is_ba {
11064                            active.remove(&(et.clone(), src_key.clone(), dst_key.clone()));
11065                        }
11066                    }
11067                    _ => {}
11068                }
11069            }
11070        }
11071
11072        Ok(active.iter().any(|(et, _, _)| et == edge_type))
11073    }
11074
11075    /// Every edge incident to `key` — either endpoint — that existed at WAL
11076    /// commit `commit`, from ONE scan of the WAL.
11077    ///
11078    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
11079    /// "what did K's relationships look like at commit C" with one call instead
11080    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
11081    /// The two agree edge for edge.
11082    ///
11083    /// Results are sorted by `(edge_type, src_key, dst_key)`.
11084    ///
11085    /// ## Horizon
11086    ///
11087    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
11088    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
11089    /// `was_linked`. An unknown key is not an error — it simply had no edges.
11090    ///
11091    /// ## Derived edges
11092    ///
11093    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
11094    /// attribution, so a rule-owned edge comes back with `derived: true` and
11095    /// `rule: Some(name)`.
11096    ///
11097    /// ## Renames
11098    ///
11099    /// `key` is matched through the same commit-bounded alias intervals
11100    /// `edge_history` uses, so querying a node's *current* key surfaces edges
11101    /// written under an earlier name. Endpoint keys in the result are reported
11102    /// under the name the node carries today, so they can be fed straight back
11103    /// into `node_info`, `explain` or another `edges_at`.
11104    ///
11105    /// ## Masks
11106    ///
11107    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
11108    /// any role mask. Apply masking at the caller level.
11109    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
11110        use core_storage::wal::WalRecord;
11111
11112        let (frames, _) = self.all_frames()?;
11113        let total_commits = self.wal_horizon_floor + frames.len() as u64;
11114
11115        // Horizon floor: commits in pruned archives are unreachable.
11116        if commit < self.wal_horizon_floor || commit >= total_commits {
11117            return Err(GraphError::CommitOutOfRange {
11118                commit,
11119                total: total_commits,
11120                floor: self.wal_horizon_floor,
11121            });
11122        }
11123
11124        // Commit-bounded historical names of `key` (handles RenameNode).
11125        let alias = self.build_key_alias_intervals(&frames, key);
11126
11127        // Forward rename chain, for reporting endpoints under their current
11128        // names: old key → [(commit, new key)] in ascending commit order.
11129        // Built over the whole WAL, not just the prefix up to `commit`, because
11130        // a rename after `commit` still changes what the node is called today.
11131        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
11132        for (local_i, frame) in frames.iter().enumerate() {
11133            let c = self.wal_horizon_floor + local_i as u64;
11134            let records: &[WalRecord] = match frame {
11135                WalRecord::Batch(inner) => inner.as_slice(),
11136                single => std::slice::from_ref(single),
11137            };
11138            for rec in records {
11139                if let WalRecord::RenameNode { old_key, new_key } = rec {
11140                    renames
11141                        .entry(old_key.clone())
11142                        .or_default()
11143                        .push((c, new_key.clone()));
11144                }
11145            }
11146        }
11147
11148        // The name a node written as `k` at commit `from` carries today.
11149        // Follows the first rename at or after `from`, then keeps going. The
11150        // iteration cap bounds a rename cycle inside a single batch.
11151        let canon = |k: &str, from: u64| -> String {
11152            if renames.is_empty() {
11153                return k.to_string();
11154            }
11155            let mut cur = k.to_string();
11156            let mut at = from;
11157            for _ in 0..64 {
11158                match renames
11159                    .get(&cur)
11160                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
11161                {
11162                    Some((c, new)) => {
11163                        at = *c;
11164                        cur = new.clone();
11165                    }
11166                    None => break,
11167                }
11168            }
11169            cur
11170        };
11171
11172        let local_commit = commit - self.wal_horizon_floor;
11173        // (edge_type, src_key, dst_key) → (derived, rule)
11174        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
11175            BTreeMap::new();
11176
11177        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
11178            let c = self.wal_horizon_floor + local_i as u64;
11179            let records: &[WalRecord] = match frame {
11180                WalRecord::Batch(inner) => inner.as_slice(),
11181                single => std::slice::from_ref(single),
11182            };
11183
11184            for rec in records {
11185                match rec {
11186                    WalRecord::InsertEdge {
11187                        edge_type,
11188                        src_key,
11189                        dst_key,
11190                    } => {
11191                        if Self::aliases_match(&alias, src_key, c)
11192                            || Self::aliases_match(&alias, dst_key, c)
11193                        {
11194                            active.insert(
11195                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11196                                (false, None),
11197                            );
11198                        }
11199                    }
11200                    WalRecord::InsertEdgeId { etype, src, dst } => {
11201                        let Some(etype_str) = self.syms.resolve(*etype) else {
11202                            continue;
11203                        };
11204                        // `key_of_historical` resolves tombstoned ids too, and
11205                        // already returns the node's current key — no rename
11206                        // canonicalisation needed on this arm.
11207                        let (Some(src_key), Some(dst_key)) = (
11208                            self.ids.key_of_historical(*src),
11209                            self.ids.key_of_historical(*dst),
11210                        ) else {
11211                            continue;
11212                        };
11213                        if src_key == key || dst_key == key {
11214                            active.insert(
11215                                (
11216                                    etype_str.to_string(),
11217                                    src_key.to_string(),
11218                                    dst_key.to_string(),
11219                                ),
11220                                (false, None),
11221                            );
11222                        }
11223                    }
11224                    WalRecord::DeleteEdge {
11225                        edge_type,
11226                        src_key,
11227                        dst_key,
11228                    } => {
11229                        if Self::aliases_match(&alias, src_key, c)
11230                            || Self::aliases_match(&alias, dst_key, c)
11231                        {
11232                            active.remove(&(
11233                                edge_type.clone(),
11234                                canon(src_key, c),
11235                                canon(dst_key, c),
11236                            ));
11237                        }
11238                    }
11239                    WalRecord::DeleteNode { key: k } => {
11240                        if active.is_empty() {
11241                            continue;
11242                        }
11243                        if Self::aliases_match(&alias, k, c) {
11244                            // Our node is gone; every incident edge goes with it.
11245                            active.clear();
11246                        } else {
11247                            // A partner is gone; its edges to us go with it.
11248                            let ck = canon(k, c);
11249                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
11250                        }
11251                    }
11252                    WalRecord::DerivedEdgeAdded {
11253                        rule,
11254                        edge_type,
11255                        src_key,
11256                        dst_key,
11257                    } => {
11258                        if Self::aliases_match(&alias, src_key, c)
11259                            || Self::aliases_match(&alias, dst_key, c)
11260                        {
11261                            active.insert(
11262                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
11263                                (true, Some(rule.clone())),
11264                            );
11265                        }
11266                    }
11267                    WalRecord::DerivedEdgeRetracted {
11268                        edge_type,
11269                        src_key,
11270                        dst_key,
11271                        ..
11272                    } => {
11273                        if Self::aliases_match(&alias, src_key, c)
11274                            || Self::aliases_match(&alias, dst_key, c)
11275                        {
11276                            active.remove(&(
11277                                edge_type.clone(),
11278                                canon(src_key, c),
11279                                canon(dst_key, c),
11280                            ));
11281                        }
11282                    }
11283                    // InsertNode, SetProp, CreateRule, … do not move edges.
11284                    _ => {}
11285                }
11286            }
11287        }
11288
11289        // BTreeMap iteration is already (edge_type, src, dst) order.
11290        Ok(active
11291            .into_iter()
11292            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
11293                edge_type,
11294                src_key,
11295                dst_key,
11296                derived,
11297                rule,
11298            })
11299            .collect())
11300    }
11301
11302    /// The derived edges that would be retracted and derived if `key.field`
11303    /// were set to `value` — computed WITHOUT writing anything.
11304    ///
11305    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
11306    /// provenance, its candidate indexes, the topology and the property columns
11307    /// are all cloned first, the change is applied to the clone, and the real
11308    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
11309    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
11310    /// it emits are the answer, so rule semantics — predicates, top-k,
11311    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
11312    ///
11313    /// Works on a read-only handle.
11314    ///
11315    /// **While a rule's vector index is still building** (`RuleStats::building`)
11316    /// the clone carries no pending-build state, so this reports the edges that
11317    /// rule would derive — which the live store will not derive until its
11318    /// backfill runs. Right about the end state, early about the timing.
11319    ///
11320    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
11321    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
11322    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
11323    /// (the node already holds `value`, or no rule watches `field`) returns
11324    /// empty lists.
11325    ///
11326    /// ## Cost
11327    ///
11328    /// One clone of the property columns, the topology overlay, the symbol
11329    /// interner, the edge properties and the provenance map, plus one candidate
11330    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
11331    /// directory, but it is not free — this is an interactive "what if", not a
11332    /// hot path.
11333    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
11334        // The engine's provenance, HNSW and IVF state live in the mmap'd base
11335        // until something asks for them. On a store opened cold from a snapshot
11336        // this is the first ask, and without it the clone below starts from an
11337        // empty provenance map: nothing to retract, so `lost` comes back empty.
11338        self.ensure_v8_base_sections_loaded();
11339
11340        let empty = WhatIf {
11341            lost: Vec::new(),
11342            gained: Vec::new(),
11343        };
11344
11345        if let Some(view_name) = self.view_store.view_for_prop(field) {
11346            return Err(GraphError::ViewPropReadOnly {
11347                view_name: view_name.to_string(),
11348            });
11349        }
11350        MutPreview::new(self).check_live_key(key)?;
11351        let id = self
11352            .ids
11353            .get(key)
11354            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
11355
11356        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
11357        if rules.is_empty() {
11358            return Ok(empty);
11359        }
11360
11361        // No rule watches this field → no derivation can change.
11362        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
11363            return Ok(empty);
11364        }
11365
11366        let old_value = build_props_view(&self.props, &self.base)
11367            .get(id, field)
11368            .map(|vr| vr.into_value());
11369        if old_value.as_ref() == Some(&value) {
11370            return Ok(empty);
11371        }
11372
11373        // --- Clone every piece of state the re-derivation writes to. ---
11374        let mut props = self.props.clone();
11375        let mut topo = self.topo.clone();
11376        let mut syms = self.syms.clone();
11377        let mut edge_props = self.edge_props.clone();
11378
11379        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
11380        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
11381        for r in &rules {
11382            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
11383            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
11384        }
11385        // `provenance()` decodes retained snapshot bytes on first use; the
11386        // engine clone needs the real map, not an empty one.
11387        let provenance = self.engine.provenance().clone();
11388        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
11389
11390        // Build the candidate indexes from the state BEFORE the change, exactly
11391        // as apply() sees them: `on_node_changed` withdraws the node under its
11392        // old value and refiles it under the new one, so the index must not
11393        // already reflect the change.
11394        engine.reindex_all_load_state(
11395            &self.ids,
11396            &syms,
11397            &self.labels,
11398            build_props_view(&self.props, &self.base),
11399            self.engine.export_ivf_state(),
11400            self.engine.export_hnsw_state_passthrough(),
11401        );
11402        engine.set_emit_deltas(true);
11403
11404        // --- Apply the hypothetical change and re-derive. ---
11405        props.set(id, field, value);
11406        {
11407            let mut gm = make_graph_mut(
11408                &self.ids,
11409                &mut syms,
11410                &self.labels,
11411                build_props_view(&props, &self.base),
11412                &mut topo,
11413                &self.base,
11414                &mut edge_props,
11415            );
11416            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
11417        }
11418
11419        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
11420        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
11421        for d in engine.drain_deltas() {
11422            let edge = EdgeAt {
11423                edge_type: d.edge_type,
11424                src_key: d.src_key,
11425                dst_key: d.dst_key,
11426                derived: true,
11427                rule: Some(d.rule),
11428            };
11429            if d.fired {
11430                gained.insert(edge);
11431            } else {
11432                lost.insert(edge);
11433            }
11434        }
11435        // An edge retracted and re-derived within the same re-derivation (top-k
11436        // churn) is not a change the caller would see.
11437        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
11438        for e in churn {
11439            lost.remove(&e);
11440            gained.remove(&e);
11441        }
11442
11443        Ok(WhatIf {
11444            lost: lost.into_iter().collect(),
11445            gained: gained.into_iter().collect(),
11446        })
11447    }
11448
11449    pub fn edge_count(&self) -> u64 {
11450        self.topo_view().edge_count()
11451    }
11452
11453    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
11454    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
11455    pub fn stats(&self) -> Stats {
11456        self.ensure_v8_base_sections_loaded();
11457        let building = self.engine.builds_in_progress();
11458        let rules: Vec<RuleStats> = self
11459            .engine
11460            .rules()
11461            .map(|r| RuleStats {
11462                name: r.name.clone(),
11463                edges: self
11464                    .engine
11465                    .provenance()
11466                    .get(&r.name)
11467                    .map(|s| s.len() as u64)
11468                    .unwrap_or(0),
11469                tripped: self.engine.is_tripped(&r.name),
11470                fires: self.engine.fire_count(&r.name),
11471                approximate: r.approximate,
11472                building: building.iter().find(|b| b.rule == r.name).cloned(),
11473            })
11474            .collect();
11475        Stats {
11476            nodes_live: self.ids.live_len(),
11477            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
11478            edges: self.topo_view().edge_count(),
11479            rules,
11480            chain_truncations: self.engine.chain_truncations(),
11481            history_floor: self.wal_horizon_floor,
11482            namespaces: self.namespace_stats(),
11483        }
11484    }
11485
11486    /// On-disk size of the WAL file in bytes.
11487    ///
11488    /// Reads file metadata without loading WAL contents.  Returns `Err` for
11489    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
11490    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
11491        let path = self.fs.wal_path().ok_or_else(|| {
11492            std::io::Error::new(
11493                std::io::ErrorKind::Unsupported,
11494                "wal_path not available for this Fs implementation",
11495            )
11496        })?;
11497        Ok(std::fs::metadata(path)?.len())
11498    }
11499
11500    /// Set the slow-query threshold.  Queries whose execution time equals or
11501    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
11502    ///
11503    /// Use this setter in tests — the environment variable
11504    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
11505    /// threads.
11506    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
11507        self.slow_query_threshold_ms = ms;
11508    }
11509
11510    /// Snapshot of the slow-query ring buffer and lifetime counter.
11511    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
11512        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
11513        SlowQuerySnapshot {
11514            threshold_ms: self.slow_query_threshold_ms,
11515            count: log.total,
11516            last: log.entries.iter().cloned().collect(),
11517        }
11518    }
11519
11520    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
11521    /// to compute uptime.
11522    pub fn started_at(&self) -> std::time::Instant {
11523        self.started_at
11524    }
11525
11526    /// On-disk snapshot format version this binary writes and reads.
11527    pub fn format_version() -> u16 {
11528        core_storage::snapshot::VERSION
11529    }
11530
11531    /// Test-support: total bytes appended (SimFs only usage).
11532    pub fn fs_total_appended(&self) -> usize
11533    where
11534        F: FsIntrospect,
11535    {
11536        self.fs.total_appended()
11537    }
11538
11539    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
11540    pub fn fs_sync_count(&self) -> usize
11541    where
11542        F: FsIntrospect,
11543    {
11544        self.fs.sync_count()
11545    }
11546
11547    /// Consume the db, returning its fs (for crash simulation).
11548    pub fn into_fs(self) -> F {
11549        self.fs
11550    }
11551
11552    pub fn snapshot(&mut self) -> Result<()> {
11553        self.snapshot_with(SnapshotOptions::default())
11554    }
11555
11556    /// Snapshot with explicit options.
11557    ///
11558    /// # `keep_wal`
11559    ///
11560    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
11561    ///   - The WAL is replaced with a minimal baseline containing one
11562    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
11563    ///     history is discarded; `open_at` can only reach post-snapshot commits.
11564    ///
11565    /// When `keep_wal` is `true`:
11566    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
11567    ///     via `open_at`.  The existing WAL already contains the original
11568    ///     `EnableFulltext` records, so no baseline re-write is needed; the
11569    ///     recovery guards in `apply()` silently skip any duplicate records on
11570    ///     replay.
11571    ///   - Crash window: a crash after the snapshot write but before the next
11572    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
11573    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
11574    ///     because every `apply()` arm is idempotent when replayed over an
11575    ///     already-current snapshot.
11576    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
11577        if self.read_only {
11578            return Err(GraphError::ReadOnly);
11579        }
11580        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
11581        // appending ends up holding a descriptor on an unlinked inode and loses
11582        // commits it believes durable. Snapshotting therefore requires the
11583        // cross-process write lock, exactly as appending does. Unlike the WAL
11584        // append path this does not go through `log_then_apply_with`, so both
11585        // guards are repeated here.
11586        if self.degraded {
11587            return Err(GraphError::Io(std::io::Error::other(
11588                "database degraded after group-commit fsync failure; reopen required",
11589            )));
11590        }
11591        if self.lock_denied {
11592            return Err(GraphError::Busy { holder: None });
11593        }
11594        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
11595        // Used by the archive path's conservative genesis-chain check: if a prior
11596        // snapshot exists but wal.truncated does not, we cannot distinguish a
11597        // legacy store (may have been truncated in an older code version) from a
11598        // new store that only used keep_wal=true.  Conservative: refuse genesis in
11599        // both cases.  Must be sampled here, before the snapshot write below.
11600        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false);
11601        self.ensure_v8_base_sections_loaded();
11602        // Ensure provenance is decoded before to_persist() clones it.
11603        self.engine.ensure_provenance_loaded_mut();
11604        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
11605        let rule_defs = rule_defs_typed
11606            .iter()
11607            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
11608            .collect();
11609        // Collect HNSW state and IVF state.  When indexes are not yet
11610        // populated (clean open, no mutation since open), pass the retained
11611        // raw bytes through directly so that migrate/snapshot does not
11612        // silently discard fitted approximate-rule indexes.
11613        let hnsw_state = self.engine.export_hnsw_state_passthrough();
11614        let ivf_bytes = if !self.engine.indexes_populated() {
11615            // Pass retained IVF bytes through unchanged (no re-encode).
11616            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
11617        } else {
11618            // Indexes live: encode from current state.
11619            let raw_ivf = self.engine.export_ivf_state();
11620            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
11621                .into_iter()
11622                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
11623                    (
11624                        name,
11625                        core_storage::snapshot::PerRuleIvfState {
11626                            src: core_storage::snapshot::SideIvfState {
11627                                centroids: sc,
11628                                clusters: sa,
11629                                drift: sd,
11630                            },
11631                            dst: core_storage::snapshot::SideIvfState {
11632                                centroids: dc,
11633                                clusters: da,
11634                                drift: dd,
11635                            },
11636                        },
11637                    )
11638                })
11639                .collect();
11640            if ivf_state_map.is_empty() {
11641                Vec::new()
11642            } else {
11643                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
11644            }
11645        };
11646        let view_defs: Vec<Vec<u8>> = self
11647            .view_store
11648            .views()
11649            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
11650            .collect();
11651        if self.base.is_some() {
11652            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
11653            // write it atomically, remap it as the new base, then clear the overlay.
11654            let meta = V8Meta {
11655                labels: self.labels.clone(),
11656                edge_props: self.edge_props.clone(),
11657                rule_defs,
11658                provenance,
11659                rule_tripped,
11660                rule_fires,
11661                ivf_bytes,
11662                view_defs,
11663                wal_truncated: !opts.keep_wal,
11664                hnsw: hnsw_state,
11665                last_change: self.last_change.clone(),
11666            };
11667            let mut buf: Vec<u8> = Vec::new();
11668            {
11669                // Clone the Arc so the old base stays alive while we encode.
11670                // The borrow of archived_csr (into old_base's mmap) is released
11671                // at the end of this block, before we replace self.base.
11672                let old_base = self.base.clone().expect("is_some checked above");
11673                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
11674                    detail: format!("v8 snapshot: topology section: {e:?}"),
11675                })?;
11676                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
11677                    detail: format!("v8 snapshot: columns section: {e:?}"),
11678                })?;
11679                // `None` when the base predates V9 — the migration path: its
11680                // string columns still carry their own tables and this snapshot
11681                // is the rewrite that collapses them into section 12.
11682                let archived_strings =
11683                    old_base
11684                        .string_table()
11685                        .transpose()
11686                        .map_err(|e| GraphError::Corrupt {
11687                            detail: format!("v8 snapshot: strings section: {e:?}"),
11688                        })?;
11689                let archived_edge_props =
11690                    old_base
11691                        .edge_props_section()
11692                        .map_err(|e| GraphError::Corrupt {
11693                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
11694                        })?;
11695                let edge_props_raw =
11696                    old_base
11697                        .edge_props_raw_bytes()
11698                        .map_err(|e| GraphError::Corrupt {
11699                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
11700                        })?;
11701                let prov_raw =
11702                    old_base
11703                        .provenance_raw_bytes()
11704                        .map_err(|e| GraphError::Corrupt {
11705                            detail: format!("v8 snapshot: provenance raw bytes: {e:?}"),
11706                        })?;
11707                encode_v8(
11708                    Some(archived_csr),
11709                    Some(archived_cols),
11710                    archived_strings,
11711                    Some((archived_edge_props, edge_props_raw)),
11712                    Some(prov_raw),
11713                    &self.topo,
11714                    &self.props,
11715                    &self.ids,
11716                    &self.syms,
11717                    &meta,
11718                    &mut buf,
11719                )?;
11720            }
11721            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11722            // Remap the freshly-written snapshot as the new base.
11723            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
11724            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11725                core_storage::v8::MappedBase::map(&snap_path)
11726            } else {
11727                core_storage::v8::MappedBase::from_bytes(buf)
11728            }
11729            .map_err(|e| GraphError::Corrupt {
11730                detail: format!("v8 snapshot: remap new base: {e:?}"),
11731            })?;
11732            self.base = Some(Arc::new(new_base));
11733            // Clear the overlay and prop tombstones — all data is now in the new base.
11734            self.topo = Topology::new();
11735            self.props = core_storage::columns::ColumnStore::new();
11736        } else {
11737            // Legacy path (V5–V7 stores without a V8 base).
11738            //
11739            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
11740            // clone and no encode_v8_from_state intermediate clones.  The big
11741            // structures (self.topo, self.props) are borrowed, not cloned.
11742            // self.edge_props is moved (not cloned) because we immediately clear it
11743            // when we remap the new V8 snapshot as self.base (see below).
11744            //
11745            // Eliminates from peak RSS vs. the old SnapshotState path:
11746            //   • self.topo.clone()      (~topology HashMap footprint)
11747            //   • self.props.clone()     (~column-store footprint)
11748            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
11749            let meta = V8Meta {
11750                labels: self.labels.clone(),
11751                wal_truncated: !opts.keep_wal,
11752                // Move edge_props out so the large overlay is freed when meta
11753                // drops at end of this block (self.edge_props is now empty; reads
11754                // after base assignment go through the mmap'd base section).
11755                edge_props: std::mem::take(&mut self.edge_props),
11756                rule_defs,
11757                provenance,
11758                rule_tripped,
11759                rule_fires,
11760                ivf_bytes,
11761                view_defs,
11762                hnsw: hnsw_state,
11763                last_change: self.last_change.clone(),
11764            };
11765            let mut buf = Vec::new();
11766            encode_v8(
11767                None,
11768                None,
11769                None,
11770                None,
11771                None,
11772                &self.topo,
11773                &self.props,
11774                &self.ids,
11775                &self.syms,
11776                &meta,
11777                &mut buf,
11778            )?;
11779            // meta (and the moved edge_props inside it) is no longer needed;
11780            // drop it before the write to keep the peak window narrow.
11781            drop(meta);
11782            self.fs.write_atomic(FileId::Snapshot, &buf)?;
11783            // Remap the freshly-written V8 snapshot as self.base.
11784            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
11785            // On SimFs (tests): pass buf to from_bytes.
11786            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
11787                drop(buf);
11788                core_storage::v8::MappedBase::map(&snap_path)
11789            } else {
11790                core_storage::v8::MappedBase::from_bytes(buf)
11791            }
11792            .map_err(|e| GraphError::Corrupt {
11793                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
11794            })?;
11795            self.base = Some(Arc::new(new_base));
11796            // Free the large heap-allocated decoded state — all data is now in the
11797            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
11798            // self.edge_props was already moved into meta and is effectively empty.
11799            self.topo = Topology::new();
11800            self.props = core_storage::columns::ColumnStore::new();
11801        }
11802
11803        if opts.archive_wal {
11804            // History-preserving snapshot (Task 4):
11805            //   1. Snapshot already written above (write_atomic → fsynced).
11806            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
11807            //      Crash window B: crash here leaves archive present, WAL
11808            //      absent.  Reopen: snapshot loaded (full state), no WAL
11809            //      replay.  Archive is NOT replayed into live state — it is
11810            //      pre-snapshot by construction.  Safe.
11811            //   3. Optionally write genesis marker (first archive only, no
11812            //      prior WAL truncation).
11813            //   4. Prune old archives (retention), update horizon floor.
11814            //      Pruning invalidates the genesis chain; delete marker.
11815            //   5. Write new minimal baseline WAL (write_atomic).
11816            //      Crash window C: crash here leaves new archive plus no live
11817            //      WAL.  Same as window B — handled above.
11818            //
11819            // Sample existing archives BEFORE the rename so we can detect
11820            // whether this is the first archive.
11821            let existing_archives = self.fs.list_archives()?;
11822            let is_first_archive = existing_archives.is_empty();
11823
11824            // Compute a globally-monotonic archive name: the name equals the
11825            // cumulative end-frame index of the archive in global commit space.
11826            //
11827            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
11828            // commit_seq is seeded from max(last_change), which underestimates
11829            // the WAL depth when trailing commits (e.g. insert_edge) do not
11830            // update last_change.  A session-2 archive could then receive a name
11831            // ≤ the session-1 archive, causing incorrect sort order or collision.
11832            //
11833            // Instead: read and decode the live WAL here (before the rename) to
11834            // get its exact frame count, then add it to the last known global
11835            // end-frame index (the name of the most recent existing archive, or
11836            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
11837            // snapshot is already serialising the full graph state, so the cost
11838            // is dominated.
11839            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
11840            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
11841            let archive_n = existing_archives
11842                .last()
11843                .copied()
11844                .unwrap_or(self.wal_horizon_floor)
11845                + live_frames_for_name.len() as u64;
11846            self.fs.archive_wal(archive_n)?;
11847
11848            // Genesis marker: written once when the first archive is taken
11849            // from a store that has never undergone a WAL-truncating snapshot.
11850            // When present, `open_at` may replay archive-resident commits from
11851            // empty state (the archive chain covers from global index 0).
11852            //
11853            // Two conditions must ALL hold:
11854            //   1. This is the first archive (existing_archives was empty).
11855            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
11856            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
11857            //      before truncating the WAL, so if any prior truncating snapshot was taken
11858            //      — even in a previous session — snapshot.bin is present and this condition
11859            //      is false.  This subsumes the cross-session truncation case without
11860            //      requiring a separate wal.truncated sidecar file.
11861            //      For legacy stores (snapshot.bin written by an older code version that
11862            //      may have truncated the WAL), the same conservative refusal applies:
11863            //      we cannot prove the chain is complete, so we refuse genesis (cost =
11864            //      no as-of-through-archives; never silent wrong data).
11865            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
11866            //      so SimFs always passes this check.
11867            if is_first_archive && !had_prior_snapshot {
11868                self.fs.write_genesis_marker()?;
11869                self.archive_genesis_chain = true;
11870            }
11871
11872            // Retention pruning: keep newest `keep` archives; delete oldest.
11873            // Pruning is the ONLY deletion site for archives.
11874            //
11875            // Crash-safety ordering (C1 fix):
11876            //   1. Count frames in surplus archives (reads only — no mutation).
11877            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
11878            //      rename (atomic).  A crash after this point leaves orphaned
11879            //      archives on disk, but the floor is correct.  The opening
11880            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
11881            //      the next open, so the store is always safe to reopen.
11882            //   3. Delete the genesis marker (floor > 0 already blocks open_at
11883            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
11884            //   4. Delete surplus archives.  A crash between any two deletes
11885            //      leaves the floor committed and orphaned archives cleaned at
11886            //      next open — never a stale floor with a missing archive prefix.
11887            if let Some(keep) = self.wal_archive_retention {
11888                if keep > 0 {
11889                    let archives = self.fs.list_archives()?;
11890                    // archives is sorted ascending (oldest first)
11891                    if archives.len() as u32 > keep {
11892                        let surplus = archives.len() - keep as usize;
11893                        // Step 1: count pruned frames (reads, no mutation).
11894                        let mut pruned_frames = 0u64;
11895                        for &n in &archives[..surplus] {
11896                            let bytes = self.fs.read_archive(n)?;
11897                            let (frames, _) = decode_all(&bytes);
11898                            pruned_frames += frames.len() as u64;
11899                        }
11900                        // Step 2: advance and persist floor FIRST.
11901                        self.wal_horizon_floor += pruned_frames;
11902                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
11903                        // Step 3: delete genesis marker (floor > 0 already
11904                        // blocks open_at; this is belt-and-suspenders cleanup).
11905                        if pruned_frames > 0 && self.archive_genesis_chain {
11906                            self.fs.delete_genesis_marker()?;
11907                            self.archive_genesis_chain = false;
11908                        }
11909                        // Step 4: delete surplus archives.  Crash here →
11910                        // orphaned archives; cleaned at next open.
11911                        for &n in &archives[..surplus] {
11912                            self.fs.delete_archive(n)?;
11913                        }
11914                    }
11915                }
11916            }
11917
11918            // Write new minimal baseline WAL (mirrors the keep_wal=false path).
11919            let mut baseline_wal: Vec<u8> = Vec::new();
11920            for (label, field) in self.fulltext.enabled_pairs() {
11921                let rec = WalRecord::EnableFulltext {
11922                    label: label.clone(),
11923                    field: field.clone(),
11924                };
11925                baseline_wal.extend_from_slice(&encode_record(&rec));
11926            }
11927            for (label, field) in self.prop_index.enabled_pairs() {
11928                let rec = WalRecord::EnableIndex {
11929                    label: label.clone(),
11930                    field: field.clone(),
11931                };
11932                baseline_wal.extend_from_slice(&encode_record(&rec));
11933            }
11934            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11935        } else if opts.keep_wal {
11936            // keep_wal=true: WAL is left untouched.  The existing WAL already
11937            // contains the EnableFulltext records from the original enable calls;
11938            // replay is idempotent (guards in apply() skip already-live entries).
11939            // No baseline re-write is needed or safe here — the full WAL history
11940            // must remain intact for open_at to reach pre-snapshot commits.
11941        } else {
11942            // keep_wal=false (default): truncate by replacing the WAL with a
11943            // minimal baseline of one EnableFulltext record per active pair.
11944            //
11945            // Crash-ordering: write_atomic is atomic.
11946            //   • Crash before snapshot write  → WAL unchanged.  Safe.
11947            //   • Crash after snapshot write but before this WAL write → full
11948            //     pre-snapshot WAL still present; open_with replays idempotently.
11949            //   • Crash after both writes → normal post-snapshot state.
11950            //
11951            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
11952            // for any archives taken AFTER this point (their WAL slices would
11953            // not start at genesis).  Delete any existing genesis marker so that
11954            // open_at refuses archive-resident commits.  Future sessions are
11955            // covered by had_prior_snapshot: snapshot.bin written here persists
11956            // across sessions and prevents a later archiving session from
11957            // incorrectly claiming a complete genesis chain.
11958            if self.archive_genesis_chain {
11959                self.fs.delete_genesis_marker()?;
11960                self.archive_genesis_chain = false;
11961            }
11962            let mut baseline_wal: Vec<u8> = Vec::new();
11963            for (label, field) in self.fulltext.enabled_pairs() {
11964                let rec = WalRecord::EnableFulltext {
11965                    label: label.clone(),
11966                    field: field.clone(),
11967                };
11968                baseline_wal.extend_from_slice(&encode_record(&rec));
11969            }
11970            for (label, field) in self.prop_index.enabled_pairs() {
11971                let rec = WalRecord::EnableIndex {
11972                    label: label.clone(),
11973                    field: field.clone(),
11974                };
11975                baseline_wal.extend_from_slice(&encode_record(&rec));
11976            }
11977            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
11978        }
11979        // After snapshot the overlay may have changed (V8 merge path clears
11980        // self.topo and self.props). Refresh the MVCC fold so future readers
11981        // see the post-snapshot state rather than stale overlay data.
11982        self.fold_now();
11983        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
11984        // markers this handle uses to detect other processes' work must be
11985        // re-taken from disk. Skipping this would make our own snapshot look
11986        // like a peer's on the next staleness check and force a needless
11987        // reload.
11988        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
11989        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
11990        Ok(())
11991    }
11992}
11993
11994/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
11995///
11996/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
11997/// callers can build a set of mutations without holding `&mut GraphDb` and
11998/// hand them off to the group-committing writer for durable, batched I/O.
11999pub enum BatchOp {
12000    InsertNode {
12001        label: String,
12002        key: String,
12003        props: Vec<(String, Value)>,
12004    },
12005    InsertEdge {
12006        edge_type: String,
12007        src_key: String,
12008        dst_key: String,
12009    },
12010    SetProp {
12011        key: String,
12012        field: String,
12013        value: Value,
12014    },
12015    RemoveProp {
12016        key: String,
12017        field: String,
12018    },
12019    DeleteEdge {
12020        edge_type: String,
12021        src_key: String,
12022        dst_key: String,
12023    },
12024    DeleteNode {
12025        key: String,
12026    },
12027    CreateRule(RuleDef),
12028    DeleteRule {
12029        name: String,
12030    },
12031    /// Rename a node's key. Validated: old must exist, new must not.
12032    RenameNode {
12033        old_key: String,
12034        new_key: String,
12035    },
12036    /// Insert an edge, auto-creating any missing endpoint as a plain node with
12037    /// `placeholder_label` and no props. Rules fire and last-change is updated
12038    /// for each created endpoint (normal InsertNode semantics in the batch frame).
12039    InsertEdgeUpsert {
12040        edge_type: String,
12041        src_key: String,
12042        dst_key: String,
12043        placeholder_label: String,
12044    },
12045}
12046
12047/// Three-way node visibility status used by `check_single_op_authz`.
12048enum NodeAuthzStatus {
12049    /// Node exists in the store and is in the role's read mask.
12050    Visible(String), // carries the node's label
12051    /// Node exists in the store but is NOT in the role's read mask.
12052    Hidden,
12053    /// Node does not exist in the store.
12054    Absent,
12055}
12056
12057/// Overlay of ops already accepted earlier in the same batch. Never written
12058/// back to the database — validation only.
12059#[derive(Default)]
12060struct Overlay {
12061    extra_keys: BTreeSet<String>,
12062    deleted_keys: BTreeSet<String>,
12063    extra_props: BTreeMap<(String, String), Value>,
12064    removed_props: BTreeSet<(String, String)>,
12065    extra_edges: BTreeSet<(String, String, String)>,
12066    deleted_edges: BTreeSet<(String, String, String)>,
12067    extra_rules: BTreeSet<String>,
12068    deleted_rules: BTreeSet<String>,
12069    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
12070    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
12071    /// sees only the rules already committed to the engine. Keyed by name so a
12072    /// later `DeleteRule` in the same batch drops the arc with the rule.
12073    extra_rule_arcs: BTreeMap<String, (String, String)>,
12074}
12075
12076/// Read-only view of live db state plus a batch overlay. Shared by single-op
12077/// public methods (empty overlay) and `commit_batch`.
12078struct MutPreview<'a, F: Fs> {
12079    db: &'a GraphDb<F>,
12080    overlay: Overlay,
12081}
12082
12083/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
12084/// `None` if `target` is unreachable.
12085///
12086/// Used for rule-chain cycle detection, where an arc is "a rule hops over
12087/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
12088/// reported path is stable for a given rule set, and iterative so a pathological
12089/// rule graph cannot overflow the stack.
12090fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
12091    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
12092    for (from, to) in arcs {
12093        adj.entry(from.as_str()).or_default().insert(to.as_str());
12094    }
12095    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
12096    let mut visited: BTreeSet<&str> = BTreeSet::new();
12097    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
12098    visited.insert(start);
12099    queue.push_back(start);
12100    while let Some(node) = queue.pop_front() {
12101        if node == target {
12102            let mut path = vec![node.to_string()];
12103            let mut cur = node;
12104            while let Some(&p) = parent.get(cur) {
12105                path.push(p.to_string());
12106                cur = p;
12107            }
12108            path.reverse();
12109            return Some(path);
12110        }
12111        for &next in adj.get(node).into_iter().flatten() {
12112            if visited.insert(next) {
12113                parent.insert(next, node);
12114                queue.push_back(next);
12115            }
12116        }
12117    }
12118    None
12119}
12120
12121impl<'a, F: Fs> MutPreview<'a, F> {
12122    fn new(db: &'a GraphDb<F>) -> Self {
12123        Self {
12124            db,
12125            overlay: Overlay::default(),
12126        }
12127    }
12128
12129    fn has_key(&self, key: &str) -> bool {
12130        if self.overlay.extra_keys.contains(key) {
12131            return true;
12132        }
12133        if self.overlay.deleted_keys.contains(key) {
12134            return false;
12135        }
12136        self.db.ids.get(key).is_some()
12137    }
12138
12139    fn has_prop(&self, key: &str, field: &str) -> bool {
12140        if !self.has_key(key) {
12141            return false;
12142        }
12143        let k = (key.to_string(), field.to_string());
12144        if self.overlay.removed_props.contains(&k) {
12145            return false;
12146        }
12147        if self.overlay.extra_props.contains_key(&k) {
12148            return true;
12149        }
12150        // Fresh identity (first insert in this batch, or delete+reinsert):
12151        // ignore props still sitting on the soon-to-be-tombstoned slot.
12152        if self.overlay.extra_keys.contains(key) {
12153            return false;
12154        }
12155        self.db.get_prop(key, field).is_some()
12156    }
12157
12158    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12159        let k = (
12160            edge_type.to_string(),
12161            src_key.to_string(),
12162            dst_key.to_string(),
12163        );
12164        if self.overlay.deleted_edges.contains(&k) {
12165            return false;
12166        }
12167        if self.overlay.extra_edges.contains(&k) {
12168            return true;
12169        }
12170        // A key created in this batch (including reinsert) has no db edges.
12171        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12172            return false;
12173        }
12174        if self.overlay.deleted_keys.contains(src_key)
12175            || self.overlay.deleted_keys.contains(dst_key)
12176        {
12177            return false;
12178        }
12179        let Some(src) = self.db.ids.get(src_key) else {
12180            return false;
12181        };
12182        let Some(dst) = self.db.ids.get(dst_key) else {
12183            return false;
12184        };
12185        let Some(sym) = self.db.syms.get(edge_type) else {
12186            return false;
12187        };
12188        self.db
12189            .topo_view()
12190            .neighbors(sym, Direction::Out, src)
12191            .binary_search(&dst)
12192            .is_ok()
12193    }
12194
12195    fn has_rule(&self, name: &str) -> bool {
12196        if self.overlay.extra_rules.contains(name) {
12197            return true;
12198        }
12199        if self.overlay.deleted_rules.contains(name) {
12200            return false;
12201        }
12202        self.db.engine.rules().any(|r| r.name == name)
12203    }
12204
12205    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12206        if self.overlay.extra_keys.contains(src_key) || self.overlay.extra_keys.contains(dst_key) {
12207            return false;
12208        }
12209        if self.overlay.deleted_keys.contains(src_key)
12210            || self.overlay.deleted_keys.contains(dst_key)
12211        {
12212            return false;
12213        }
12214        let Some(src) = self.db.ids.get(src_key) else {
12215            return false;
12216        };
12217        let Some(dst) = self.db.ids.get(dst_key) else {
12218            return false;
12219        };
12220        let Some(et) = self.db.syms.get(edge_type) else {
12221            return false;
12222        };
12223        // extra_rules is deliberately not consulted: a CreateRule earlier in
12224        // this batch has not fired, so it contributes no provenance. That is
12225        // the documented rule-window gap (see GraphDb::batch).
12226        if self.overlay.deleted_rules.is_empty() {
12227            return self.db.engine.is_owned(et, src, dst);
12228        }
12229        for (rule, triples) in self.db.engine.provenance() {
12230            if self.overlay.deleted_rules.contains(rule) {
12231                continue;
12232            }
12233            if triples.contains(&(et, src, dst)) {
12234                return true;
12235            }
12236        }
12237        false
12238    }
12239
12240    fn check_insert_node(&self, key: &str) -> Result<()> {
12241        if self.has_key(key) {
12242            Err(GraphError::DuplicateKey { key: key.into() })
12243        } else {
12244            Ok(())
12245        }
12246    }
12247
12248    fn check_live_key(&self, key: &str) -> Result<()> {
12249        if self.has_key(key) {
12250            Ok(())
12251        } else {
12252            Err(GraphError::KeyNotFound { key: key.into() })
12253        }
12254    }
12255
12256    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12257        for k in [src_key, dst_key] {
12258            if !self.has_key(k) {
12259                return Err(GraphError::KeyNotFound { key: k.into() });
12260            }
12261        }
12262        if self.is_rule_owned(edge_type, src_key, dst_key) {
12263            return Err(GraphError::RuleOwned {
12264                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
12265            });
12266        }
12267        // A user-written edge stays inside one namespace. Derived edges do not
12268        // come through here — the engine adds them directly — and the rule
12269        // scoping check is what keeps those pure.
12270        let src_ns = self.namespace_in_batch(src_key);
12271        let dst_ns = self.namespace_in_batch(dst_key);
12272        if src_ns != dst_ns {
12273            return Err(GraphError::CrossNamespace {
12274                src: src_key.to_string(),
12275                src_ns,
12276                dst: dst_key.to_string(),
12277                dst_ns,
12278            });
12279        }
12280        Ok(!self.has_edge(edge_type, src_key, dst_key))
12281    }
12282
12283    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
12284        self.check_live_key(key)?;
12285        // Removing `ns` is changing the namespace — to `default`, the namespace
12286        // an absent property names. It goes through this one choke-point and NOT
12287        // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
12288        // the immutability rule has to be stated here as well. Without it the
12289        // node silently lands in `default` on the next open: the cross-namespace
12290        // edge guard is defeated and a default-bound role reads a tenant's node.
12291        if field == NS_PROP {
12292            let from = self.namespace_in_batch(key);
12293            if from != NS_DEFAULT {
12294                return Err(GraphError::NamespaceImmutable {
12295                    key: key.to_string(),
12296                    from,
12297                    to: NS_DEFAULT.to_string(),
12298                });
12299            }
12300            // Already in `default`: the removal changes no namespace. It is the
12301            // no-op `set_prop` to the current namespace is, not an error.
12302            return Ok(false);
12303        }
12304        Ok(self.has_prop(key, field))
12305    }
12306
12307    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
12308        for k in [src_key, dst_key] {
12309            if !self.has_key(k) {
12310                return Err(GraphError::KeyNotFound { key: k.into() });
12311            }
12312        }
12313        // Provenance-owned OR a live rule would derive this pair. User-first
12314        // edges that a later rule matches are not in `owned`, but deleting
12315        // them would leave a hole `rebuild_rule` immediately fills.
12316        if self.is_rule_owned(edge_type, src_key, dst_key) {
12317            return Err(GraphError::RuleOwned {
12318                detail: format!(
12319                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12320                     delete or change the owning rule"
12321                ),
12322            });
12323        }
12324        if self.would_derive(edge_type, src_key, dst_key) {
12325            return Err(GraphError::RuleOwned {
12326                detail: format!(
12327                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
12328                     delete or change the owning rule, or a live rule would re-derive it"
12329                ),
12330            });
12331        }
12332        Ok(self.has_edge(edge_type, src_key, dst_key))
12333    }
12334
12335    /// True if any live rule (minus overlay-deleted names) would derive
12336    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
12337    /// CreateRule names in `extra_rules` are ignored — same documented
12338    /// same-batch rule-window as [`Self::is_rule_owned`].
12339    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
12340        if src_key == dst_key {
12341            return false;
12342        }
12343        let Some(src_label) = self.label_of(src_key) else {
12344            return false;
12345        };
12346        let Some(dst_label) = self.label_of(dst_key) else {
12347            return false;
12348        };
12349        for rule in self.db.engine.rules() {
12350            if self.overlay.deleted_rules.contains(&rule.name) {
12351                continue;
12352            }
12353            if rule.edge_type != edge_type {
12354                continue;
12355            }
12356            if rule.src_label != src_label || rule.dst_label != dst_label {
12357                continue;
12358            }
12359            let src_props = |f: &str| self.prop_value(src_key, f);
12360            let dst_props = |f: &str| self.prop_value(dst_key, f);
12361            let src_view = NodeView {
12362                key: src_key,
12363                props: &src_props,
12364            };
12365            let dst_view = NodeView {
12366                key: dst_key,
12367                props: &dst_props,
12368            };
12369            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
12370                return true;
12371            }
12372        }
12373        false
12374    }
12375
12376    fn label_of(&self, key: &str) -> Option<String> {
12377        if self.overlay.deleted_keys.contains(key) {
12378            return None;
12379        }
12380        // Fresh identities created in this batch have no stored label in the
12381        // overlay; they cannot be provenance-owned yet either.
12382        let id = self.db.ids.get(key)?;
12383        let sym = self.db.labels.get(id as usize).copied()?;
12384        if sym == u32::MAX {
12385            return None;
12386        }
12387        self.db.syms.resolve(sym).map(str::to_string)
12388    }
12389
12390    /// The namespace `key` is in as this batch sees it — including a node
12391    /// inserted earlier in the same batch, which the store does not have yet.
12392    fn namespace_in_batch(&self, key: &str) -> String {
12393        namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
12394    }
12395
12396    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
12397        if !self.has_key(key) {
12398            return None;
12399        }
12400        let k = (key.to_string(), field.to_string());
12401        if self.overlay.removed_props.contains(&k) {
12402            return None;
12403        }
12404        if let Some(v) = self.overlay.extra_props.get(&k) {
12405            return Some(v.clone());
12406        }
12407        if self.overlay.extra_keys.contains(key) {
12408            return None;
12409        }
12410        self.db.get_prop(key, field)
12411    }
12412
12413    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
12414        def.validate()
12415            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
12416        if self.has_rule(&def.name) {
12417            return Err(GraphError::RuleInvalid {
12418                detail: format!("rule {:?} already exists", def.name),
12419            });
12420        }
12421        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
12422        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
12423        // `edge_type`". A cycle in that graph is a rule set that would re-fire
12424        // itself forever; the engine's depth cap would silently truncate it
12425        // instead, leaving an arbitrary partial result. Reject it here, the one
12426        // place that sees the whole rule set.
12427        //
12428        // Rules accepted earlier in the same batch count too: the overlay
12429        // carries their arcs, so a cycle cannot be assembled one op at a time.
12430        if let Some(via) = def.via_edge.as_deref() {
12431            if via == def.edge_type {
12432                return Err(GraphError::RuleInvalid {
12433                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
12434                });
12435            }
12436            let mut arcs: Vec<(String, String)> = self
12437                .db
12438                .engine
12439                .rules()
12440                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
12441                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
12442                .collect();
12443            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
12444            arcs.push((via.to_string(), def.edge_type.clone()));
12445            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
12446                return Err(GraphError::RuleInvalid {
12447                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
12448                });
12449            }
12450        }
12451        Ok(())
12452    }
12453
12454    fn check_delete_rule(&self, name: &str) -> Result<()> {
12455        if self.has_rule(name) {
12456            Ok(())
12457        } else {
12458            Err(GraphError::RuleNotFound { name: name.into() })
12459        }
12460    }
12461
12462    fn note_insert_node(&mut self, key: &str, props: &[(String, Value)]) {
12463        self.overlay.deleted_keys.remove(key);
12464        self.overlay.extra_keys.insert(key.to_string());
12465        self.overlay.extra_props.retain(|(k, _), _| k != key);
12466        self.overlay.removed_props.retain(|(k, _)| k != key);
12467        for (field, value) in props {
12468            self.overlay
12469                .extra_props
12470                .insert((key.to_string(), field.clone()), value.clone());
12471        }
12472    }
12473
12474    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12475        let k = (
12476            edge_type.to_string(),
12477            src_key.to_string(),
12478            dst_key.to_string(),
12479        );
12480        self.overlay.deleted_edges.remove(&k);
12481        self.overlay.extra_edges.insert(k);
12482    }
12483
12484    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
12485        let k = (key.to_string(), field.to_string());
12486        self.overlay.removed_props.remove(&k);
12487        self.overlay.extra_props.insert(k, value.clone());
12488    }
12489
12490    fn note_remove_prop(&mut self, key: &str, field: &str) {
12491        let k = (key.to_string(), field.to_string());
12492        self.overlay.extra_props.remove(&k);
12493        self.overlay.removed_props.insert(k);
12494    }
12495
12496    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
12497        let k = (
12498            edge_type.to_string(),
12499            src_key.to_string(),
12500            dst_key.to_string(),
12501        );
12502        self.overlay.extra_edges.remove(&k);
12503        self.overlay.deleted_edges.insert(k);
12504    }
12505
12506    fn note_delete_node(&mut self, key: &str) {
12507        self.overlay.extra_keys.remove(key);
12508        self.overlay.deleted_keys.insert(key.to_string());
12509        self.overlay.extra_props.retain(|(k, _), _| k != key);
12510        self.overlay.removed_props.retain(|(k, _)| k != key);
12511        self.overlay
12512            .extra_edges
12513            .retain(|(_, s, d)| s != key && d != key);
12514        self.overlay
12515            .deleted_edges
12516            .retain(|(_, s, d)| s != key && d != key);
12517    }
12518
12519    fn note_create_rule(&mut self, def: &RuleDef) {
12520        self.overlay.deleted_rules.remove(&def.name);
12521        self.overlay.extra_rules.insert(def.name.clone());
12522        // Rules accepted earlier in this batch are not in the engine yet, so
12523        // the cycle check would not see their arcs. Keep the arc, not just the
12524        // name, so a batch cannot smuggle in a cycle one op at a time.
12525        if let Some(via) = def.via_edge.clone() {
12526            self.overlay
12527                .extra_rule_arcs
12528                .insert(def.name.clone(), (via, def.edge_type.clone()));
12529        }
12530    }
12531
12532    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
12533        if !self.has_key(old) {
12534            return Err(GraphError::KeyNotFound { key: old.into() });
12535        }
12536        if self.has_key(new) {
12537            return Err(GraphError::DuplicateKey { key: new.into() });
12538        }
12539        Ok(())
12540    }
12541
12542    fn note_rename_node(&mut self, old: &str, new: &str) {
12543        // Mark old as deleted so subsequent batch ops cannot reference it.
12544        self.overlay.extra_keys.remove(old);
12545        self.overlay.deleted_keys.insert(old.to_string());
12546        // Mark new as extra so subsequent batch ops can reference it.
12547        self.overlay.deleted_keys.remove(new);
12548        self.overlay.extra_keys.insert(new.to_string());
12549        // Migrate any overlay props from old key to new key.
12550        let new_str = new.to_string();
12551        let transferred: Vec<((String, String), Value)> = self
12552            .overlay
12553            .extra_props
12554            .iter()
12555            .filter(|((k, _), _)| k.as_str() == old)
12556            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
12557            .collect();
12558        self.overlay
12559            .extra_props
12560            .retain(|(k, _), _| k.as_str() != old);
12561        for (k, v) in transferred {
12562            self.overlay.extra_props.insert(k, v);
12563        }
12564        // Migrate removed_props.
12565        let transferred_removed: Vec<(String, String)> = self
12566            .overlay
12567            .removed_props
12568            .iter()
12569            .filter(|(k, _)| k.as_str() == old)
12570            .map(|(_, f)| (new_str.clone(), f.clone()))
12571            .collect();
12572        self.overlay
12573            .removed_props
12574            .retain(|(k, _)| k.as_str() != old);
12575        for k in transferred_removed {
12576            self.overlay.removed_props.insert(k);
12577        }
12578    }
12579
12580    fn note_delete_rule(&mut self, name: &str) {
12581        self.overlay.extra_rules.remove(name);
12582        // Drop its chain arc too: a rule created and then deleted in the same
12583        // batch must not make a later, legal rule look like a cycle.
12584        self.overlay.extra_rule_arcs.remove(name);
12585        self.overlay.deleted_rules.insert(name.to_string());
12586        // Treat the deleted rule's current provenance as gone so a later
12587        // delete_edge of those triples is a no-op (matches sequential).
12588        if let Some(triples) = self.db.engine.provenance().get(name) {
12589            for &(et, s, d) in triples {
12590                let Some(etype) = self.db.syms.resolve(et) else {
12591                    continue;
12592                };
12593                let Some(src) = self.db.ids.key_of(s) else {
12594                    continue;
12595                };
12596                let Some(dst) = self.db.ids.key_of(d) else {
12597                    continue;
12598                };
12599                let k = (etype.to_string(), src.to_string(), dst.to_string());
12600                self.overlay.extra_edges.remove(&k);
12601                self.overlay.deleted_edges.insert(k);
12602            }
12603        }
12604    }
12605}
12606
12607/// Collects mutations and commits them as one WAL `Batch` frame.
12608///
12609/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
12610/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
12611/// See [`GraphDb::batch`] for validation and atomicity rules.
12612pub struct BatchBuilder<'a, F: Fs> {
12613    db: &'a mut GraphDb<F>,
12614    ops: Vec<BatchOp>,
12615}
12616
12617impl<'a, F: Fs> BatchBuilder<'a, F> {
12618    pub fn insert_node(
12619        &mut self,
12620        label: &str,
12621        key: &str,
12622        props: Vec<(String, Value)>,
12623    ) -> &mut Self {
12624        self.ops.push(BatchOp::InsertNode {
12625            label: label.into(),
12626            key: key.into(),
12627            props,
12628        });
12629        self
12630    }
12631
12632    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12633        self.ops.push(BatchOp::InsertEdge {
12634            edge_type: edge_type.into(),
12635            src_key: src_key.into(),
12636            dst_key: dst_key.into(),
12637        });
12638        self
12639    }
12640
12641    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
12642        self.ops.push(BatchOp::SetProp {
12643            key: key.into(),
12644            field: field.into(),
12645            value,
12646        });
12647        self
12648    }
12649
12650    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
12651        self.ops.push(BatchOp::RemoveProp {
12652            key: key.into(),
12653            field: field.into(),
12654        });
12655        self
12656    }
12657
12658    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
12659        self.ops.push(BatchOp::DeleteEdge {
12660            edge_type: edge_type.into(),
12661            src_key: src_key.into(),
12662            dst_key: dst_key.into(),
12663        });
12664        self
12665    }
12666
12667    pub fn delete_node(&mut self, key: &str) -> &mut Self {
12668        self.ops.push(BatchOp::DeleteNode { key: key.into() });
12669        self
12670    }
12671
12672    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
12673        self.ops.push(BatchOp::CreateRule(def));
12674        self
12675    }
12676
12677    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
12678        self.ops.push(BatchOp::DeleteRule { name: name.into() });
12679        self
12680    }
12681
12682    /// Queue a node-rename in this batch.
12683    ///
12684    /// Validation (old exists, new not taken) runs at commit time.
12685    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
12686        self.ops.push(BatchOp::RenameNode {
12687            old_key: old_key.into(),
12688            new_key: new_key.into(),
12689        });
12690        self
12691    }
12692
12693    /// Queue an edge insert with endpoint auto-creation.
12694    ///
12695    /// Any missing endpoint is created as a plain node `{key, label:
12696    /// placeholder_label, no props}` inside this batch frame. Rules fire and
12697    /// last-change is updated for each auto-created node.
12698    pub fn insert_edge_upsert(
12699        &mut self,
12700        edge_type: &str,
12701        src_key: &str,
12702        dst_key: &str,
12703        placeholder_label: &str,
12704    ) -> &mut Self {
12705        self.ops.push(BatchOp::InsertEdgeUpsert {
12706            edge_type: edge_type.into(),
12707            src_key: src_key.into(),
12708            dst_key: dst_key.into(),
12709            placeholder_label: placeholder_label.into(),
12710        });
12711        self
12712    }
12713
12714    /// Validate every queued op, then log one `Batch` frame and apply.
12715    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
12716    /// A second `commit()` after a successful one is an empty-batch no-op
12717    /// (queued ops were taken).
12718    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
12719    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
12720    ///
12721    /// **Rule-window limitation:** batch validation cannot see edges that a
12722    /// rule created earlier in the *same* batch will derive at apply time, so
12723    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
12724    /// where sequential calls would return `Err(RuleOwned)`. State integrity
12725    /// is unaffected (idempotent apply, provenance intact). Create rules in
12726    /// their own batch, or sequentially, when later ops may touch derived
12727    /// edges.
12728    /// Validate every queued op and commit atomically.
12729    ///
12730    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
12731    /// WAL records actually written (duplicate edges are silent no-ops and are
12732    /// NOT counted). Both are 0 when the batch is empty or all-noop.
12733    pub fn commit(&mut self) -> Result<(usize, usize)> {
12734        let ops = std::mem::take(&mut self.ops);
12735        self.db.commit_batch(ops)
12736    }
12737
12738    /// Same as [`commit`](Self::commit) but tail the inner events with
12739    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
12740    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
12741        let ops = std::mem::take(&mut self.ops);
12742        self.db
12743            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
12744    }
12745}
12746
12747pub struct NodeRef<'a, F: Fs> {
12748    db: &'a GraphDb<F>,
12749    id: u32,
12750}
12751
12752impl<'a, F: Fs> NodeRef<'a, F> {
12753    pub fn key(&self) -> &str {
12754        self.db.ids.key_of(self.id).expect("dense ids")
12755    }
12756
12757    pub fn label(&self) -> &str {
12758        let sym = self
12759            .db
12760            .labels
12761            .get(self.id as usize)
12762            .copied()
12763            .filter(|&s| s != u32::MAX)
12764            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12765        self.db.syms.resolve(sym).expect("interned label symbol")
12766    }
12767
12768    pub fn prop(&self, field: &str) -> Option<Value> {
12769        self.db
12770            .props_view()
12771            .get(self.id, field)
12772            .map(|vr| vr.into_value())
12773    }
12774
12775    /// All stored fields for this node, sorted by field name.
12776    ///
12777    /// Reads from the full base+overlay view so that props stored only in the
12778    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
12779    pub fn props(&self) -> BTreeMap<String, Value> {
12780        let mut out = BTreeMap::new();
12781        let pv = self.db.props_view();
12782        for field in pv.field_names() {
12783            if let Some(vr) = pv.get(self.id, &field) {
12784                out.insert(field, vr.into_value());
12785            }
12786        }
12787        out
12788    }
12789
12790    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
12791    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
12792        let view = self.db.view();
12793        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
12794            names
12795                .iter()
12796                .filter_map(|name| view.syms.get(name))
12797                .collect()
12798        });
12799        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
12800        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
12801        for (nid, d) in nb.nodes {
12802            let key = view.key_of(nid);
12803            let label = view
12804                .label_of(nid)
12805                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
12806            rs.push_row(vec![
12807                Some(Value::Str(key.to_string())),
12808                Some(Value::Str(label.to_string())),
12809                Some(Value::Int(d as i64)),
12810            ]);
12811        }
12812        rs
12813    }
12814
12815    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
12816    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
12817        let view = self.db.view();
12818        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12819        for e in expand(&view, self.id, None, Dir::Both) {
12820            // Skip edges with unknown etypes (only possible from corrupt large
12821            // TOPOLOGY section; function returns BTreeMap not Result).
12822            let Some(etype) = view.syms.resolve(e.etype) else {
12823                continue;
12824            };
12825            let etype = etype.to_string();
12826            let nbr = if e.src == self.id { e.dst } else { e.src };
12827            groups
12828                .entry(etype)
12829                .or_default()
12830                .insert(view.key_of(nbr).to_string());
12831        }
12832        groups
12833            .into_iter()
12834            .map(|(k, v)| (k, v.into_iter().collect()))
12835            .collect()
12836    }
12837}
12838
12839#[cfg(test)]
12840mod tests {
12841    use super::*;
12842    use core_rules::Predicate;
12843
12844    fn tmp_dir(name: &str) -> std::path::PathBuf {
12845        let d =
12846            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
12847        let _ = std::fs::remove_dir_all(&d);
12848        d
12849    }
12850
12851    fn fk_rule() -> RuleDef {
12852        RuleDef {
12853            name: "works_at".into(),
12854            src_label: "Person".into(),
12855            dst_label: "Org".into(),
12856            predicate: Predicate::KeyMatch {
12857                field: "org_id".into(),
12858            },
12859            edge_type: "WORKS_AT".into(),
12860            weight_prop: None,
12861            max_edges: None,
12862            approximate: false,
12863            via_label: None,
12864            via_edge: None,
12865            via_dir: None,
12866            namespace: None,
12867        }
12868    }
12869
12870    /// Regression guard for the no-views delta-copy fast path.
12871    ///
12872    /// When no views are defined, `pending_deltas_since().to_vec()` must never
12873    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
12874    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
12875    /// a count of 0 after the entire sequence proves the guard fires correctly.
12876    #[test]
12877    fn no_delta_copy_when_no_views() {
12878        DELTA_COPY_COUNT.with(|c| c.set(0));
12879        let dir = tmp_dir("no-delta-copy");
12880        {
12881            let mut db = GraphDb::open(&dir).unwrap();
12882            // Insert 50 Org + 50 Person nodes with FK links.
12883            for i in 0..50u32 {
12884                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12885            }
12886            for i in 0..50u32 {
12887                db.insert_node(
12888                    "Person",
12889                    &format!("p{i}"),
12890                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12891                )
12892                .unwrap();
12893            }
12894            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
12895            db.create_rule(fk_rule()).unwrap();
12896
12897            // Counter must stay 0 — no views, no copies.
12898            let copies = DELTA_COPY_COUNT.with(|c| c.get());
12899            assert_eq!(
12900                copies, 0,
12901                "pending_deltas_since().to_vec() called despite no views"
12902            );
12903
12904            // Derived edges must still be correct (the guard skips only the
12905            // empty delta propagation loop, not the rule application itself).
12906            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
12907            assert_eq!(
12908                nbrs,
12909                vec!["o0"],
12910                "rule must derive edges even with no views"
12911            );
12912        }
12913        let _ = std::fs::remove_dir_all(&dir);
12914    }
12915
12916    /// Gating regression: subscribe AFTER a backfill must see no stale events.
12917    /// subscribe BEFORE a backfill must see every edge-fire event.
12918    #[test]
12919    fn subscribe_after_backfill_no_stale_events() {
12920        let dir = tmp_dir("sub-after-backfill");
12921        {
12922            let mut db = GraphDb::open(&dir).unwrap();
12923            for i in 0..10u32 {
12924                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12925                db.insert_node(
12926                    "Person",
12927                    &format!("p{i}"),
12928                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12929                )
12930                .unwrap();
12931            }
12932            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
12933            db.create_rule(fk_rule()).unwrap();
12934
12935            // Subscribe AFTER the backfill — queue must be empty (no stale events).
12936            let sub = db.subscribe_all_rules().unwrap();
12937            // No events should have queued for the prior backfill.
12938            assert!(
12939                sub.try_recv().is_none(),
12940                "subscribe after backfill must see no stale events"
12941            );
12942
12943            // Inserting a new node now should fire an event (emit_deltas is now true).
12944            db.insert_node("Org", "o_new", vec![]).unwrap();
12945            db.insert_node(
12946                "Person",
12947                "p_new",
12948                vec![("org_id".into(), Value::Str("o_new".into()))],
12949            )
12950            .unwrap();
12951            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
12952            assert!(
12953                ev.is_some(),
12954                "edge-fire event must arrive after subscribe (emit_deltas=true)"
12955            );
12956        }
12957        let _ = std::fs::remove_dir_all(&dir);
12958    }
12959
12960    /// Gating regression: subscribe BEFORE a backfill → events flow.
12961    #[test]
12962    fn subscribe_before_backfill_events_flow() {
12963        let dir = tmp_dir("sub-before-backfill");
12964        {
12965            let mut db = GraphDb::open(&dir).unwrap();
12966            // Subscribe FIRST — emit_deltas becomes true.
12967            let sub = db.subscribe_all_rules().unwrap();
12968
12969            for i in 0..5u32 {
12970                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
12971                db.insert_node(
12972                    "Person",
12973                    &format!("p{i}"),
12974                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
12975                )
12976                .unwrap();
12977            }
12978            // Backfill fires with emit_deltas=true → events queued.
12979            db.create_rule(fk_rule()).unwrap();
12980
12981            // Should receive at least one edge-fired event from the backfill.
12982            let mut received = 0usize;
12983            while sub.try_recv().is_some() {
12984                received += 1;
12985            }
12986            assert!(
12987                received > 0,
12988                "subscribe before backfill must receive edge-fire events (got 0)"
12989            );
12990        }
12991        let _ = std::fs::remove_dir_all(&dir);
12992    }
12993
12994    /// Companion: when a view IS defined, the delta path fires and view values update.
12995    #[test]
12996    fn delta_copy_fires_when_view_exists() {
12997        use core_rules::ViewSource;
12998        DELTA_COPY_COUNT.with(|c| c.set(0));
12999        let dir = tmp_dir("delta-copy-with-view");
13000        {
13001            let mut db = GraphDb::open(&dir).unwrap();
13002            db.insert_node("Org", "o1", vec![]).unwrap();
13003            db.insert_node(
13004                "Person",
13005                "p1",
13006                vec![("org_id".into(), Value::Str("o1".into()))],
13007            )
13008            .unwrap();
13009            // Declare a Degree view so is_empty() returns false.
13010            db.create_view(ViewDef {
13011                name: "degree_out".into(),
13012                label: "Person".into(),
13013                view_prop: "degree_out".into(),
13014                source: ViewSource::Degree {
13015                    edge_type: "WORKS_AT".into(),
13016                    direction: Direction::Out,
13017                },
13018            })
13019            .unwrap();
13020            db.create_rule(fk_rule()).unwrap();
13021
13022            // At least one delta copy should have happened (CreateRule backfill).
13023            let copies = DELTA_COPY_COUNT.with(|c| c.get());
13024            assert!(
13025                copies > 0,
13026                "expected delta copy to fire when a view is defined"
13027            );
13028
13029            // View value should be computed: p1 has one WORKS_AT out-edge.
13030            let info = db.node_info("p1").unwrap();
13031            let degree = info.props.get("degree_out");
13032            assert!(
13033                degree.is_some(),
13034                "view prop should be written to node props"
13035            );
13036        }
13037        let _ = std::fs::remove_dir_all(&dir);
13038    }
13039
13040    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
13041    /// derived-edge-driven view values reflect the as-of state rather than just
13042    /// the initial backfill written at `CreateView` time.
13043    ///
13044    /// Base WAL frames (indices 0..=5 before history markers):
13045    ///   0: insert Org "o1"
13046    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
13047    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
13048    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
13049    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
13050    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
13051    ///
13052    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
13053    /// no-op), so the total commit count is higher than the base frame count.
13054    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
13055    ///
13056    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
13057    /// initial backfill value (0) instead of reflecting the replayed derived edges.
13058    #[test]
13059    fn open_at_derived_edge_view_values_correct() {
13060        use core_rules::ViewSource;
13061        let dir = tmp_dir("open-at-view-rebuild");
13062        {
13063            let mut db = GraphDb::open(&dir).unwrap();
13064            // frame 0
13065            db.insert_node("Org", "o1", vec![]).unwrap();
13066            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
13067            db.create_view(ViewDef {
13068                name: "employee_count".into(),
13069                label: "Org".into(),
13070                view_prop: "emp".into(),
13071                source: ViewSource::Degree {
13072                    edge_type: "WORKS_AT".into(),
13073                    direction: Direction::In,
13074                },
13075            })
13076            .unwrap();
13077            // frame 2: create rule — no Persons yet; backfill is a no-op
13078            db.create_rule(fk_rule()).unwrap();
13079            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
13080            db.insert_node(
13081                "Person",
13082                "p1",
13083                vec![("org_id".into(), Value::Str("o1".into()))],
13084            )
13085            .unwrap();
13086            // frame 4: p2 — degree = 2
13087            db.insert_node(
13088                "Person",
13089                "p2",
13090                vec![("org_id".into(), Value::Str("o1".into()))],
13091            )
13092            .unwrap();
13093            // frame 5: p3 — degree = 3
13094            db.insert_node(
13095                "Person",
13096                "p3",
13097                vec![("org_id".into(), Value::Str("o1".into()))],
13098            )
13099            .unwrap();
13100            // Sanity: normal open sees degree = 3.
13101            assert_eq!(
13102                db.get_view_prop("o1", "emp"),
13103                Some(Value::Int(3)),
13104                "normal db must show degree 3 after 3 derived edges"
13105            );
13106        } // WAL flushed
13107
13108        // Re-open normally to get the authoritative reference value.
13109        let normal_db = GraphDb::open(&dir).unwrap();
13110        let normal_emp = normal_db.get_view_prop("o1", "emp");
13111        assert_eq!(
13112            normal_emp,
13113            Some(Value::Int(3)),
13114            "re-opened normal db must show degree 3"
13115        );
13116
13117        // Latest as-of (last WAL commit): must match the normal open.
13118        // History-marker frames are appended after each rule-fire, so the total
13119        // commit count is computed dynamically rather than hardcoded.
13120        let total = crate::wal_commit_count_at(&dir).unwrap();
13121        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
13122        assert_eq!(
13123            aof_latest.get_view_prop("o1", "emp"),
13124            normal_emp,
13125            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
13126        );
13127
13128        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
13129        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
13130        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
13131        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
13132        assert_eq!(
13133            aof_mid.get_view_prop("o1", "emp"),
13134            Some(Value::Int(1)),
13135            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
13136        );
13137
13138        let _ = std::fs::remove_dir_all(&dir);
13139    }
13140
13141    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
13142    /// as-of instances never commit, so distribute_events never runs and any
13143    /// subscription would wait forever.
13144    #[test]
13145    fn subscribe_on_as_of_returns_read_only_error() {
13146        let dir = tmp_dir("sub-as-of-read-only");
13147        {
13148            let mut db = GraphDb::open(&dir).unwrap();
13149            db.insert_node("Org", "o1", vec![]).unwrap();
13150            db.create_rule(fk_rule()).unwrap();
13151        }
13152        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
13153
13154        assert!(
13155            matches!(
13156                aof.subscribe_all_rules(),
13157                Err(core_storage::GraphError::ReadOnly)
13158            ),
13159            "subscribe_all_rules on as-of must return ReadOnly"
13160        );
13161        assert!(
13162            matches!(
13163                aof.subscribe_writes(),
13164                Err(core_storage::GraphError::ReadOnly)
13165            ),
13166            "subscribe_writes on as-of must return ReadOnly"
13167        );
13168        assert!(
13169            matches!(
13170                aof.subscribe_rule("works_at"),
13171                Err(core_storage::GraphError::ReadOnly)
13172            ),
13173            "subscribe_rule on as-of must return ReadOnly"
13174        );
13175        let _ = std::fs::remove_dir_all(&dir);
13176    }
13177
13178    /// Regression: a failed dense WAL rewrite must not leave speculative
13179    /// interns in `syms`. If it does, the next successful mutation logs an
13180    /// `Intern` record with an inflated id; replay (which never saw the
13181    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
13182    #[test]
13183    fn dense_rewrite_error_rolls_back_speculative_interns() {
13184        let dir = tmp_dir("dense-rewrite-rollback");
13185        {
13186            let mut db = GraphDb::open(&dir).unwrap();
13187            db.insert_node("Person", "a", vec![]).unwrap();
13188
13189            // Bypass MutPreview validation to hit the rewrite's own error path
13190            // (same shape as an id-exhaustion failure mid-rewrite). The
13191            // InsertEdge arm interns the edge type before it resolves keys.
13192            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
13193                edge_type: "ORPHAN_TYPE".into(),
13194                src_key: "missing".into(),
13195                dst_key: "a".into(),
13196            }]);
13197            assert!(err.is_err(), "rewrite of a missing src key must fail");
13198            assert_eq!(
13199                db.syms.get("ORPHAN_TYPE"),
13200                None,
13201                "failed rewrite must roll back speculative interns"
13202            );
13203
13204            // A later successful mutation must produce a replayable WAL.
13205            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
13206        }
13207        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
13208        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
13209        let _ = std::fs::remove_dir_all(&dir);
13210    }
13211}