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;
37
38/// The reserved edge property holding a pair's insert count (§5.13).
39///
40/// Absent means 1 — the count is written only from the second insert of a
41/// triple onward, and only on a store that called
42/// [`GraphDb::enable_multiplicity`]. The engine owns the name: Cypher `SET` on
43/// it is refused, as the other reserved names are.
44pub const EDGE_COUNT_PROP: &str = "count";
45use serde::{Deserialize, Serialize};
46use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
47use std::sync::Arc;
48
49/// Print a timing checkpoint when MUSHROOMDB_TRACE_OPEN is set.
50/// Zero-cost when the env var is absent (the var check is O(1) after first call).
51macro_rules! trace_open {
52    ($phase:literal, $t:expr) => {
53        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
54            eprintln!(
55                "[MUSHROOMDB_TRACE_OPEN] {:40} {:>9.3?}",
56                $phase,
57                $t.elapsed()
58            );
59        }
60    };
61}
62
63/// Print a migration phase checkpoint when MUSHROOMDB_TRACE_MIGRATE is set.
64/// Zero-cost when the env var is absent (the var check is O(1) after first call).
65macro_rules! trace_migrate {
66    ($phase:literal, $t:expr) => {
67        if std::env::var("MUSHROOMDB_TRACE_MIGRATE").is_ok() {
68            eprintln!(
69                "[MUSHROOMDB_TRACE_MIGRATE] {:40} {:>9.3?}",
70                $phase,
71                $t.elapsed()
72            );
73        }
74    };
75}
76
77// Test-only: counts how many times `pending_deltas_since().to_vec()` actually
78// executes (i.e., at least one view is defined). Used to verify the fast-path
79// guard skips the allocation when `view_store.is_empty()`.
80#[cfg(test)]
81thread_local! {
82    static DELTA_COPY_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
83}
84
85// Per-thread count of query-subscription `execute` calls in `distribute_events`.
86//
87// Incremented each time a query subscription actually runs its plan (i.e.,
88// the label-skip fast-path did not fire). Because `distribute_events` is
89// called synchronously on the writer thread, this thread-local correctly
90// isolates each test thread's count even when integration tests run in
91// parallel. Read via [`query_sub_exec_count`].
92thread_local! {
93    static QUERY_SUB_EXECS_TL: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
94}
95
96/// Return the number of query-subscription re-executions logged on this
97/// thread since the process started (or since last reset via
98/// [`reset_query_sub_exec_count`]).
99///
100/// Primarily for integration tests that verify the label-skip fast-path.
101#[doc(hidden)]
102pub fn query_sub_exec_count() -> usize {
103    QUERY_SUB_EXECS_TL.with(|c| c.get())
104}
105
106/// Reset the per-thread query-subscription execution counter to zero.
107#[doc(hidden)]
108pub fn reset_query_sub_exec_count() {
109    QUERY_SUB_EXECS_TL.with(|c| c.set(0));
110}
111
112// Exact-versus-approximate warnings emitted on this thread. Thread-local for
113// the same reason [`QUERY_SUB_EXECS_TL`] is: integration tests run in parallel
114// and each gets its own thread, so a neighbour's masked search cannot be
115// mistaken for this test's.
116thread_local! {
117    static AMBIGUOUS_EXACTNESS_WARNS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
118    static AMBIGUOUS_EXACTNESS_LAST: std::cell::RefCell<Option<String>> =
119        const { std::cell::RefCell::new(None) };
120}
121
122/// How many times a masked, non-exact vector search has explained itself on
123/// this thread since the last [`ambiguous_exactness_warns_reset`].
124///
125/// The line itself is the product; this counter exists so a test can assert it
126/// is printed **once per index** rather than once per call.
127///
128/// **Single-threaded assertions only.** The suppression set this counts is a
129/// `Mutex<HashSet<_>>` on the `GraphDb` — shared by every thread — while the
130/// counter is thread-local. Under a concurrent caller (`serve`, which is the
131/// deployment the warning exists for) the thread that prints the line is not
132/// necessarily the thread that asked, so a zero here does not mean the line was
133/// not printed and a one does not mean it was printed once. It answers
134/// "once per index" only in a test that owns the store.
135#[doc(hidden)]
136pub fn ambiguous_exactness_warns() -> u64 {
137    AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.get())
138}
139
140/// The most recent exactness warning printed on this thread, verbatim.
141///
142/// The advice a caller reads has to be advice that caller can act on, which the
143/// counter alone cannot witness — see
144/// `the_hybrid_path_advises_a_call_the_hybrid_caller_can_make`. Carries the
145/// same single-threaded caveat as [`ambiguous_exactness_warns`].
146#[doc(hidden)]
147pub fn ambiguous_exactness_last_warning() -> Option<String> {
148    AMBIGUOUS_EXACTNESS_LAST.with(|c| c.borrow().clone())
149}
150
151/// Reset this thread's exactness-warning counter and recorded line.
152#[doc(hidden)]
153pub fn ambiguous_exactness_warns_reset() {
154    AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.set(0));
155    AMBIGUOUS_EXACTNESS_LAST.with(|c| *c.borrow_mut() = None);
156}
157
158/// Which signature reached the approximate masked vector leg.
159///
160/// One code path, two entry points, and the advice cannot be the same: a
161/// warning that names an argument the caller's function does not take sends
162/// them looking for a parameter that is not there. `search_hybrid` and
163/// `search_hybrid_scoped` take `(text_field, query_text, vector_field,
164/// query_vec, label, k[, mask])` — no `exact`, no `where`.
165///
166/// The warning is not suppressed on the hybrid path. The approximation is the
167/// same one, and a caller who read `mask=` as a promise of exhaustiveness is
168/// the reader it was written for whichever door they came in by; only the
169/// remedy differs, so only the remedy changes.
170#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
171enum ExactnessCaller {
172    /// `find_similar` / `find_similar_vector_*` — `exact` and `where` are its
173    /// own parameters.
174    Vector,
175    /// `search_hybrid` / `search_hybrid_scoped` — neither argument exists, and
176    /// the leg is one half of a fusion.
177    Hybrid,
178}
179
180impl ExactnessCaller {
181    fn subject(self) -> &'static str {
182        match self {
183            Self::Vector => "a masked vector search",
184            Self::Hybrid => "the vector leg of a masked hybrid search",
185        }
186    }
187
188    fn advice(self) -> &'static str {
189        match self {
190            Self::Vector => "pass exact=True or a where= predicate.",
191            // Names the call that does take the argument, because this one
192            // does not: the caller's own next step, not a parameter hunt.
193            Self::Hybrid => {
194                "run the vector leg on its own with find_similar(field, vector, mask=…, \
195                 exact=True) and fuse it with search() yourself — search_hybrid itself \
196                 takes no exactness argument."
197            }
198        }
199    }
200}
201
202/// Internal state for a single `subscribe_query` subscription.
203///
204/// On every commit, `distribute_events` re-executes `ops` against the current
205/// graph state, diffs the result against `prev_rows`, and pushes
206/// `DbEvent::QueryRowAdded` / `QueryRowRemoved` events to `inner`.
207///
208/// **Full re-run per commit; use LIMIT to bound execution cost.**
209/// (Differential evaluation is roadmap / Phase 5.)
210pub(crate) struct QuerySubEntry {
211    /// Compiled plan for the subscribed Cypher query.
212    ops: Vec<PlanOp>,
213    /// Column names from the first execution (fixed for the subscription lifetime).
214    columns: Vec<String>,
215    /// Serialized (JSON) row key → row data, representing the result set at
216    /// the end of the last commit. Used to diff against the new result.
217    prev_row_map: std::collections::HashMap<String, Vec<Option<Value>>>,
218    /// Weak pointer to the subscriber queue; dead Weak → subscription dropped.
219    inner: std::sync::Weak<SubInner>,
220    /// Interned label sym captured at subscribe time from the plan's leading scan
221    /// (`ScanLabel`, `IndexScan`, or `IndexIntersect` with a concrete label).
222    ///
223    /// `None` means the plan has an `Expand` op (or no recognizable leading scan
224    /// with a concrete label), and this subscription must re-execute on every
225    /// commit without skipping. This is the conservative v0.4.3 boundary: Expand
226    /// queries are never skipped because edges can alter join results regardless
227    /// of which node labels were written.
228    scan_label: Option<u32>,
229}
230
231/// A post-commit mutation notification.
232///
233/// Emitted from `log_then_apply` after the WAL append, fsync, and
234/// in-memory `apply` all succeed. Never emitted for rejected operations
235/// (validation errors, [`GraphError::RuleOwned`], duplicate keys, no-op
236/// deletes/removes). Event payloads carry user keys and rule names, never
237/// internal ids.
238///
239/// **Replay:** [`GraphDb::open`] / [`GraphDb::open_with`] replay the WAL via
240/// `apply` only. Emission lives exclusively in `log_then_apply`, so
241/// recovery is silent even if a sink were installed (it cannot be: the
242/// sink is in-memory and set after open).
243///
244/// **Ordering:** a `Batch` WAL frame emits one event per inner record, then
245/// [`MutationEvent::BatchApplied`]. An ingest commit emits those same inner
246/// events, then [`MutationEvent::Ingested`] (not `BatchApplied`). An empty
247/// or all-noop batch writes no WAL and emits nothing (including no summary).
248///
249/// **Derived edges:** rule-created or retracted edges are not individually
250/// evented — they are recoverable from the triggering mutation plus the live
251/// rule set. Only the triggering record is emitted.
252///
253/// **Wire form:** externally tagged snake_case JSON
254/// (`{"node_inserted":{"label":"A","key":"k"}}`).
255#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
256#[serde(rename_all = "snake_case")]
257pub enum MutationEvent {
258    NodeInserted {
259        label: String,
260        key: String,
261    },
262    PropSet {
263        key: String,
264        field: String,
265    },
266    PropRemoved {
267        key: String,
268        field: String,
269    },
270    EdgeInserted {
271        edge_type: String,
272        src: String,
273        dst: String,
274    },
275    EdgeDeleted {
276        edge_type: String,
277        src: String,
278        dst: String,
279    },
280    NodeDeleted {
281        key: String,
282    },
283    RuleCreated {
284        name: String,
285    },
286    RuleDeleted {
287        name: String,
288    },
289    RuleRebuilt {
290        name: String,
291    },
292    BatchApplied {
293        ops: usize,
294    },
295    Ingested {
296        label: String,
297        inserted: usize,
298    },
299}
300
301/// Whether `rec` is the frame [`GraphDb::create_rule`] logs: a `CreateRule`,
302/// alone or behind the `Intern` record for its edge type.
303///
304/// `create_rule` goes through the dense rewrite like every other write, so its
305/// frame is `Batch([Intern { edge_type }, CreateRule])` rather than a bare
306/// `CreateRule`. Anything that asks "was this commit a rule creation" must
307/// accept both shapes, or it silently stops recognising the one the
308/// standalone call writes. A batch carrying anything else is a user batch and
309/// is not this frame.
310fn is_create_rule_frame(rec: &WalRecord) -> bool {
311    match rec {
312        WalRecord::CreateRule { .. } => true,
313        WalRecord::Batch(inner) => {
314            inner
315                .iter()
316                .any(|r| matches!(r, WalRecord::CreateRule { .. }))
317                && inner
318                    .iter()
319                    .all(|r| matches!(r, WalRecord::CreateRule { .. } | WalRecord::Intern { .. }))
320        }
321        _ => false,
322    }
323}
324
325fn event_from_record(rec: &WalRecord, intern: &Interner, ids: &IdMap) -> Option<MutationEvent> {
326    match rec {
327        WalRecord::InsertNode { label, key, .. } => Some(MutationEvent::NodeInserted {
328            label: label.clone(),
329            key: key.clone(),
330        }),
331        WalRecord::InsertNodeId { label, key, .. } => Some(MutationEvent::NodeInserted {
332            label: intern.resolve(*label)?.to_string(),
333            key: key.clone(),
334        }),
335        WalRecord::SetProp { key, field, .. } => Some(MutationEvent::PropSet {
336            key: key.clone(),
337            field: field.clone(),
338        }),
339        WalRecord::SetPropId { id, field, .. } => Some(MutationEvent::PropSet {
340            key: ids.key_of(*id)?.to_string(),
341            field: intern.resolve(*field)?.to_string(),
342        }),
343        WalRecord::RemoveProp { key, field } => Some(MutationEvent::PropRemoved {
344            key: key.clone(),
345            field: field.clone(),
346        }),
347        WalRecord::InsertEdge {
348            edge_type,
349            src_key,
350            dst_key,
351        } => Some(MutationEvent::EdgeInserted {
352            edge_type: edge_type.clone(),
353            src: src_key.clone(),
354            dst: dst_key.clone(),
355        }),
356        WalRecord::InsertEdgeId { etype, src, dst } => Some(MutationEvent::EdgeInserted {
357            edge_type: intern.resolve(*etype)?.to_string(),
358            src: ids.key_of(*src)?.to_string(),
359            dst: ids.key_of(*dst)?.to_string(),
360        }),
361        WalRecord::DeleteEdge {
362            edge_type,
363            src_key,
364            dst_key,
365        } => Some(MutationEvent::EdgeDeleted {
366            edge_type: edge_type.clone(),
367            src: src_key.clone(),
368            dst: dst_key.clone(),
369        }),
370        WalRecord::DeleteNode { key } => Some(MutationEvent::NodeDeleted { key: key.clone() }),
371        WalRecord::CreateRule { def_bytes } => {
372            let def: RuleDef = decode_rule_def(def_bytes).ok()?;
373            Some(MutationEvent::RuleCreated { name: def.name })
374        }
375        WalRecord::DeleteRule { name } => Some(MutationEvent::RuleDeleted { name: name.clone() }),
376        WalRecord::RebuildRule { name } => Some(MutationEvent::RuleRebuilt { name: name.clone() }),
377        WalRecord::Batch(_)
378        | WalRecord::CreateView { .. }
379        | WalRecord::DeleteView { .. }
380        | WalRecord::EnableFulltext { .. }
381        | WalRecord::DisableFulltext { .. }
382        | WalRecord::EnableIndex { .. }
383        | WalRecord::DisableIndex { .. }
384        | WalRecord::Intern { .. }
385        // History markers are no-ops for mutation events — they carry no new
386        // state and rules re-derive deterministically on replay.
387        | WalRecord::DerivedEdgeAdded { .. }
388        | WalRecord::DerivedEdgeRetracted { .. }
389        // A count changes neither the node nor the edge population: the pair it
390        // counts was already there, which is why it is written at all.
391        | WalRecord::SetEdgeCount { .. }
392        // RenameNode carries no node/edge count change; no special event.
393        | WalRecord::RenameNode { .. } => None,
394    }
395}
396
397/// Whether `rec` only declares or withdraws something about the store — a
398/// full-text pair, an equality index, the multiplicity opt-in — and writes no
399/// node, edge or property.
400fn is_declaration(rec: &WalRecord) -> bool {
401    matches!(
402        rec,
403        WalRecord::EnableFulltext { .. }
404            | WalRecord::DisableFulltext { .. }
405            | WalRecord::EnableIndex { .. }
406            | WalRecord::DisableIndex { .. }
407    ) || *rec == core_storage::wal::MULTIPLICITY_ENABLED
408}
409
410/// Database-wide counters plus per-rule budget/fire stats.
411#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
412pub struct Stats {
413    pub nodes_live: usize,
414    pub nodes_tombstoned: usize,
415    pub edges: u64,
416    pub rules: Vec<RuleStats>,
417    /// How many writes hit the rule-chaining depth cap with work still pending,
418    /// since this handle was opened. Non-zero means some derived edges beyond
419    /// the cap are stale and no single later write will repair them: split the
420    /// rule chain or shorten it. Never persisted, so it resets on reopen.
421    #[serde(default)]
422    pub chain_truncations: u64,
423    /// The oldest commit index history still reaches (the WAL horizon floor).
424    /// `0` means nothing has been pruned and history is complete; a non-zero
425    /// value means events before that commit were pruned and are gone.
426    #[serde(default)]
427    pub history_floor: u64,
428    /// Live node counts per namespace, in name order. Always carries
429    /// `default` — a store is at least its default namespace — so a
430    /// single-tenant store reads `[{"name":"default", …}]` and a reader can
431    /// tell "no namespaces in use" from one entry.
432    #[serde(default)]
433    pub namespaces: Vec<NamespaceStats>,
434}
435
436/// Live node count for one namespace; one entry of [`Stats::namespaces`].
437#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
438pub struct NamespaceStats {
439    pub name: String,
440    pub nodes_live: usize,
441}
442
443/// One rule's provenance size, trip latch, and fire counter.
444///
445/// `tripped` is a one-way latch: once set, the engine adds no new edges for
446/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
447/// set then fits). `fires` counts `on_node_changed` evaluations plus
448/// backfill/rebuild participant ticks (rebuild counts even when it is a
449/// provenance no-op).
450#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
451pub struct RuleStats {
452    pub name: String,
453    pub edges: u64,
454    pub tripped: bool,
455    pub fires: u64,
456    /// Whether this rule uses the approximate IVF-Flat candidate path.
457    pub approximate: bool,
458    /// `Some` while this rule's vector index is still being built.
459    ///
460    /// The rule derives **no** edges until it is `None`: the backfill is one
461    /// commit that runs after the index is whole, so a caller never sees a
462    /// partial edge set. Absent from the JSON when the rule is not building,
463    /// which is every rule created over a corpus at or below
464    /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub building: Option<BuildProgress>,
467}
468
469/// One entry in the slow-query ring buffer.
470#[derive(Debug, Clone, Serialize)]
471pub struct SlowQueryEntry {
472    /// Execution time in whole milliseconds.
473    pub ms: u64,
474    /// The Cypher query string that was slow.
475    pub query: String,
476    /// The commit sequence number at the time the query ran.
477    pub at_commit: u64,
478}
479
480/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
481#[derive(Debug, Clone, Serialize)]
482pub struct SlowQuerySnapshot {
483    /// Current threshold in milliseconds (0 = disabled).
484    pub threshold_ms: u64,
485    /// Total number of slow queries ever recorded (not capped by ring size).
486    pub count: u64,
487    /// Most-recent slow queries (up to 16), oldest first.
488    pub last: Vec<SlowQueryEntry>,
489}
490
491/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
492/// write to it without a mutable borrow.
493struct SlowQueryLog {
494    entries: std::collections::VecDeque<SlowQueryEntry>,
495    total: u64,
496}
497
498/// Maximum number of entries kept in the slow-query ring buffer.
499const SLOW_QUERY_RING_CAP: usize = 16;
500
501/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
502/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
503#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
504pub struct PredicateSummary {
505    pub kind: String,
506    pub fields: Vec<String>,
507    pub min: Option<f64>,
508    pub tolerance: Option<f64>,
509    pub km: Option<f64>,
510    pub parts: Option<Vec<PredicateSummary>>,
511    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
512    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
513    #[serde(default)]
514    pub approximate: bool,
515}
516
517impl From<&Predicate> for PredicateSummary {
518    fn from(p: &Predicate) -> Self {
519        match p {
520            Predicate::KeyMatch { field } => PredicateSummary {
521                kind: "key_match".into(),
522                fields: vec![field.clone()],
523                min: None,
524                tolerance: None,
525                km: None,
526                parts: None,
527                approximate: false,
528            },
529            Predicate::FieldEqual { field } => PredicateSummary {
530                kind: "field_equal".into(),
531                fields: vec![field.clone()],
532                min: None,
533                tolerance: None,
534                km: None,
535                parts: None,
536                approximate: false,
537            },
538            Predicate::Overlap { field, min } => PredicateSummary {
539                kind: "overlap".into(),
540                fields: vec![field.clone()],
541                min: Some(*min),
542                tolerance: None,
543                km: None,
544                parts: None,
545                approximate: false,
546            },
547            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
548                kind: "numeric_within".into(),
549                fields: vec![field.clone()],
550                min: None,
551                tolerance: Some(*tolerance),
552                km: None,
553                parts: None,
554                approximate: false,
555            },
556            Predicate::GeoRadius { field, km } => PredicateSummary {
557                kind: "geo_radius".into(),
558                fields: vec![field.clone()],
559                min: None,
560                tolerance: None,
561                km: Some(*km),
562                parts: None,
563                approximate: false,
564            },
565            Predicate::VectorSimilar { field, min } => PredicateSummary {
566                kind: "vector_similar".into(),
567                fields: vec![field.clone()],
568                min: Some(*min),
569                tolerance: None,
570                km: None,
571                parts: None,
572                approximate: false,
573            },
574            Predicate::All(inner) => {
575                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
576                let mut fields = Vec::new();
577                for part in &parts {
578                    for f in &part.fields {
579                        if !fields.contains(f) {
580                            fields.push(f.clone());
581                        }
582                    }
583                }
584                PredicateSummary {
585                    kind: "all".into(),
586                    fields,
587                    min: None,
588                    tolerance: None,
589                    km: None,
590                    parts: Some(parts),
591                    approximate: false,
592                }
593            }
594            Predicate::Any(inner) => {
595                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
596                let mut fields = Vec::new();
597                for part in &parts {
598                    for f in &part.fields {
599                        if !fields.contains(f) {
600                            fields.push(f.clone());
601                        }
602                    }
603                }
604                PredicateSummary {
605                    kind: "any".into(),
606                    fields,
607                    min: None,
608                    tolerance: None,
609                    km: None,
610                    parts: Some(parts),
611                    approximate: false,
612                }
613            }
614        }
615    }
616}
617
618/// Snapshot of a live node's key, label, and columnar properties.
619///
620/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
621/// regardless of insert order or the columnar store's `HashMap` iteration.
622///
623/// Deliberately does not derive `Serialize`: `Value`'s serde form is
624/// internally tagged. Wire JSON is built by `value_to_json` in the server.
625#[derive(Debug, Clone, PartialEq)]
626pub struct NodeInfo {
627    pub key: String,
628    pub label: String,
629    pub props: BTreeMap<String, Value>,
630}
631
632/// Counts returned by [`GraphDb::delete_node`].
633#[derive(Debug, Clone, PartialEq, Eq, Default)]
634pub struct DeleteReport {
635    /// Number of manual (user-inserted) edges removed.
636    pub manual_edges: u64,
637    /// Number of derived (rule-owned) edges retracted.
638    pub derived_edges: u64,
639}
640
641/// One directed edge incident on a node, with provenance membership.
642///
643/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
644/// Plan-8 `by_node` provenance index.
645#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
646pub struct EdgeInfo {
647    pub edge_type: String,
648    pub src_key: String,
649    pub dst_key: String,
650    pub derived: bool,
651}
652
653/// One directed edge incident on a node at a point in WAL history, with the
654/// rule that derived it when it is rule-owned.
655///
656/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
657/// and by [`GraphDb::what_if_set_prop`].
658#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
659pub struct EdgeAt {
660    pub edge_type: String,
661    pub src_key: String,
662    pub dst_key: String,
663    /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
664    /// live provenance entry).
665    pub derived: bool,
666    /// The rule that derived the edge. `None` for a manual edge.
667    pub rule: Option<String>,
668}
669
670/// The derived edges a hypothetical property change would retract and derive.
671///
672/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
673/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
674#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
675pub struct WhatIf {
676    /// Derived edges that exist now and would be retracted.
677    pub lost: Vec<EdgeAt>,
678    /// Derived edges that do not exist now and would be derived.
679    pub gained: Vec<EdgeAt>,
680}
681
682/// An edge with mask-aware endpoint visibility.
683///
684/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
685/// mode — hidden endpoints carry `*_restricted: true`.
686#[derive(Debug, Clone, PartialEq, Eq)]
687pub struct MaskedEdge {
688    pub edge_type: String,
689    pub src_key: String,
690    /// `true` when `src_key` is in the DB but hidden from the mask.
691    pub src_restricted: bool,
692    pub dst_key: String,
693    /// `true` when `dst_key` is in the DB but hidden from the mask.
694    pub dst_restricted: bool,
695    pub derived: bool,
696}
697
698/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
699///
700/// `None` from that method means the key does not exist (→ 404).
701/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
702#[derive(Debug, PartialEq)]
703pub enum MaskedNodeResult {
704    Visible(NodeInfo),
705    /// Node exists in the DB but is hidden from this mask.
706    Restricted,
707}
708
709/// One rule-owned edge between two nodes, with the rule name, edge type,
710/// direction (src_key → dst_key), and weight if the rule stores one.
711#[derive(Debug, Clone, PartialEq, Serialize)]
712pub struct Explanation {
713    pub rule: String,
714    pub edge_type: String,
715    pub src_key: String,
716    pub dst_key: String,
717    pub weight: Option<f64>,
718    pub predicate: PredicateSummary,
719    /// For a via-hop rule, the edge type the rule hops over to reach its
720    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
721    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
722    /// was written by another rule in the same commit.
723    #[serde(default)]
724    pub via_edge: Option<String>,
725}
726
727/// Report returned by [`GraphDb::backup_to`].
728#[derive(Debug, Clone)]
729pub struct BackupReport {
730    /// Filenames copied into the destination directory (sorted ascending).
731    pub files: Vec<String>,
732    /// Total bytes written across all copied files.
733    pub bytes: u64,
734    /// `true` when the destination opened cleanly and passed post-copy checks.
735    ///
736    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
737    /// matched **and** the destination opened without error.
738    ///
739    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
740    /// CRC-check; `verified` is `true` when the destination opened and
741    /// replayed the WAL without error (record-level checksums in the WAL
742    /// provide the integrity signal, not section CRCs).
743    pub verified: bool,
744}
745
746/// One directed edge in export form, with optional rule attribution for derived edges.
747///
748/// Returned by [`GraphDb::all_edges_for_export`].
749///
750/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
751/// order. Callers that need a stable edge ordering already sort by
752/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
753#[derive(Debug, Clone, PartialEq, PartialOrd)]
754pub struct ExportEdge {
755    pub edge_type: String,
756    pub src: String,
757    pub dst: String,
758    pub derived: bool,
759    /// Rule name that created this edge, if derived. `None` for manual edges.
760    pub rule: Option<String>,
761    /// The creating rule's declared `weight_prop`, read off this edge, when
762    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
763    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
764    pub weight: Option<f64>,
765}
766
767/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
768///
769/// Deliberately per *type* and not per edge: everything here is a summary a
770/// caller can print in one line, and none of it costs a record per edge.
771#[derive(Debug, Clone, PartialEq, Eq)]
772pub struct EdgeTypeCensus {
773    pub edge_type: String,
774    /// Directed edges of this type. Counted the way
775    /// [`GraphDb::edge_count`] counts: each edge once, from its source.
776    pub edges: u64,
777    /// Every label seen on a source of this type, sorted.
778    pub src_labels: Vec<String>,
779    /// Every label seen on a destination of this type, sorted.
780    pub dst_labels: Vec<String>,
781    /// The rules that declare this `edge_type`, sorted. Empty for a type
782    /// written by hand.
783    pub rules: Vec<String>,
784    /// `(src key, dst key)` of the first edge of this type in the store's own
785    /// id order — a real pair to quote in an example.
786    pub sample: Option<(String, String)>,
787}
788
789/// Construct the standard write-query result set (columns: created, properties_set, deleted).
790fn write_result_set() -> ResultSet {
791    ResultSet::new(vec![
792        "created".into(),
793        "properties_set".into(),
794        "deleted".into(),
795    ])
796}
797
798fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
799    match op {
800        Operand::Lit(v) => Ok(v.clone()),
801        Operand::Param(name) => params
802            .get(name)
803            .cloned()
804            .ok_or_else(|| GraphError::QueryError {
805                detail: format!("missing parameter `{name}`"),
806            }),
807        _ => Err(GraphError::QueryError {
808            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
809        }),
810    }
811}
812
813fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
814    match op {
815        Operand::Prop { var, .. } | Operand::Var(var) => {
816            if !out.contains(var) {
817                out.push(var.clone());
818            }
819        }
820        Operand::FuncCall { args, .. } => {
821            for arg in args {
822                operand_node_vars(arg, out);
823            }
824        }
825        Operand::BinArith { left, right, .. } => {
826            operand_node_vars(left, out);
827            operand_node_vars(right, out);
828        }
829        Operand::Case { branches, default } => {
830            // Branch conditions reference vars already bound (and mask-filtered)
831            // by the MATCH phase, so collecting from the value operands + ELSE
832            // is sufficient for RETURN-projection var discovery.
833            for (_, value) in branches {
834                operand_node_vars(value, out);
835            }
836            if let Some(d) = default {
837                operand_node_vars(d, out);
838            }
839        }
840        Operand::Index { base, index } => {
841            operand_node_vars(base, out);
842            operand_node_vars(index, out);
843        }
844        Operand::Lit(_) | Operand::Param(_) => {}
845    }
846}
847
848fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
849    let mut out = Vec::new();
850    for item in items {
851        match &item.value {
852            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
853                if !out.contains(v) {
854                    out.push(v.clone());
855                }
856            }
857            RetVal::FuncCall { args, .. } => {
858                for arg in args {
859                    operand_node_vars(arg, &mut out);
860                }
861            }
862            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
863            RetVal::Agg { .. } => {}
864        }
865    }
866    out
867}
868
869fn add_var(out: &mut Vec<String>, v: &str) {
870    if !out.iter().any(|x| x == v) {
871        out.push(v.to_string());
872    }
873}
874
875fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
876    let mut out = Vec::new();
877    for p in pats {
878        if let Some(v) = &p.start.var {
879            add_var(&mut out, v);
880        }
881        for (_, dest) in &p.chain {
882            if let Some(v) = &dest.var {
883                add_var(&mut out, v);
884            }
885        }
886    }
887    out
888}
889
890fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
891    let mut out = Vec::new();
892    for p in pats {
893        for (rel, _) in &p.chain {
894            if rel.hops.is_none() {
895                if let Some(v) = &rel.var {
896                    add_var(&mut out, v);
897                }
898            }
899        }
900    }
901    out
902}
903
904fn rel_type_alias(var: &str) -> String {
905    format!("__rt_{var}")
906}
907
908fn ret_column_name(item: &RetItem) -> String {
909    if let Some(alias) = &item.alias {
910        return alias.clone();
911    }
912    // The same naming rule the planner and the executor use, so a
913    // write-statement RETURN names its columns exactly as a read query does.
914    // An aggregate is not legal in a write-statement RETURN; it keeps the
915    // placeholder it always had.
916    ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
917}
918
919fn eval_set_return_operand<F: Fs>(
920    db: &GraphDb<F>,
921    match_rs: &ResultSet,
922    row: usize,
923    rel_vars: &[String],
924    op: &Operand,
925    params: &BTreeMap<String, Value>,
926) -> Result<Option<Value>> {
927    match op {
928        Operand::Lit(v) => Ok(Some(v.clone())),
929        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
930            detail: format!("missing parameter `{name}`"),
931        }).map(Some),
932        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
933            detail: format!(
934                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
935            ),
936        }),
937        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
938        Operand::Prop { var, field } => {
939            if rel_vars.iter().any(|r| r == var) {
940                return Ok(None);
941            }
942            let Some(Value::Str(key)) = match_rs.get(row, var) else {
943                return Ok(None);
944            };
945            if let Some(v) = db.get_prop(key, field) {
946                return Ok(Some(v));
947            }
948            // Same stored-wins identity fallback as the read path:
949            // n.key / n.id / n.label, not only get_prop.
950            Ok(match field.as_str() {
951                "key" | "id" => Some(Value::Str(key.clone())),
952                "label" => db
953                    .node_ref(key)
954                    .map(|n| Value::Str(n.label().to_owned())),
955                _ => None,
956            })
957        }
958        Operand::FuncCall { name, args } => {
959            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
960        }
961        Operand::BinArith { op, left, right } => {
962            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
963            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
964            eval_set_return_arith(op, lv, rv)
965        }
966        Operand::Case { branches, default } => {
967            for (cond, value) in branches {
968                if eval_set_return_expr(db, match_rs, row, rel_vars, cond, params, 0)? {
969                    return eval_set_return_operand(db, match_rs, row, rel_vars, value, params);
970                }
971            }
972            match default {
973                Some(d) => eval_set_return_operand(db, match_rs, row, rel_vars, d, params),
974                None => Ok(None),
975            }
976        }
977        Operand::Index { base, index } => {
978            let base_val = eval_set_return_operand(db, match_rs, row, rel_vars, base, params)?;
979            let idx_val = eval_set_return_operand(db, match_rs, row, rel_vars, index, params)?;
980            Ok(core_query::value_ops::index_list(base_val, idx_val))
981        }
982    }
983}
984
985fn eval_set_return_expr<F: Fs>(
986    db: &GraphDb<F>,
987    match_rs: &ResultSet,
988    row: usize,
989    rel_vars: &[String],
990    expr: &Expr,
991    params: &BTreeMap<String, Value>,
992    depth: u32,
993) -> Result<bool> {
994    if depth > 256 {
995        return Err(GraphError::QueryError {
996            detail: "expression nesting too deep".into(),
997        });
998    }
999    match expr {
1000        Expr::And(lhs, rhs) => {
1001            let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
1002            let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
1003            Ok(l && r)
1004        }
1005        Expr::Or(lhs, rhs) => {
1006            let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
1007            let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
1008            Ok(l || r)
1009        }
1010        Expr::Not(inner) => Ok(!eval_set_return_expr(
1011            db,
1012            match_rs,
1013            row,
1014            rel_vars,
1015            inner,
1016            params,
1017            depth + 1,
1018        )?),
1019        Expr::Cmp { lhs, op, rhs } => {
1020            let l = eval_set_return_operand(db, match_rs, row, rel_vars, lhs, params)?;
1021            let r = eval_set_return_operand(db, match_rs, row, rel_vars, rhs, params)?;
1022            match (l, r) {
1023                (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
1024                _ => Ok(false),
1025            }
1026        }
1027        Expr::Truthy(op) => {
1028            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1029            Ok(match val {
1030                None => false,
1031                Some(Value::Bool(b)) => b,
1032                Some(Value::Int(n)) => n != 0,
1033                Some(Value::Float(f)) => f != 0.0,
1034                Some(Value::Str(s)) => !s.is_empty(),
1035                Some(Value::List(v)) => !v.is_empty(),
1036                Some(Value::Map(m)) => !m.is_empty(),
1037            })
1038        }
1039        Expr::IsNull(op) => {
1040            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1041            Ok(val.is_none())
1042        }
1043        Expr::IsNotNull(op) => {
1044            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1045            Ok(val.is_some())
1046        }
1047        Expr::In { expr, list } => {
1048            let Some(needle) = eval_set_return_operand(db, match_rs, row, rel_vars, expr, params)?
1049            else {
1050                return Ok(false);
1051            };
1052            for item_op in list {
1053                match eval_set_return_operand(db, match_rs, row, rel_vars, item_op, params)? {
1054                    None => {}
1055                    Some(Value::List(items)) => {
1056                        for item in items {
1057                            if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) {
1058                                return Ok(true);
1059                            }
1060                        }
1061                    }
1062                    Some(item) if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) => {
1063                        return Ok(true);
1064                    }
1065                    Some(_) => {}
1066                }
1067            }
1068            Ok(false)
1069        }
1070    }
1071}
1072
1073fn eval_set_return_arith(
1074    op: &ArithOp,
1075    lv: Option<Value>,
1076    rv: Option<Value>,
1077) -> Result<Option<Value>> {
1078    match (lv, rv) {
1079        (None, _) | (_, None) => Ok(None),
1080        (Some(Value::Int(a)), Some(Value::Int(b))) => {
1081            let result = match op {
1082                ArithOp::Sub => a.saturating_sub(b),
1083                ArithOp::Mul => a.saturating_mul(b),
1084                ArithOp::Add => a.saturating_add(b),
1085                ArithOp::Div => {
1086                    if b == 0 {
1087                        return Err(GraphError::QueryError {
1088                            detail: "division by zero".into(),
1089                        });
1090                    }
1091                    a.checked_div(b).unwrap_or(i64::MAX)
1092                }
1093            };
1094            Ok(Some(Value::Int(result)))
1095        }
1096        (Some(lv), Some(rv)) => {
1097            let a = match &lv {
1098                Value::Float(f) => *f,
1099                Value::Int(i) => *i as f64,
1100                _ => {
1101                    return Err(GraphError::QueryError {
1102                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
1103                    })
1104                }
1105            };
1106            let b = match &rv {
1107                Value::Float(f) => *f,
1108                Value::Int(i) => *i as f64,
1109                _ => {
1110                    return Err(GraphError::QueryError {
1111                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
1112                    })
1113                }
1114            };
1115            let result = match op {
1116                ArithOp::Sub => a - b,
1117                ArithOp::Mul => a * b,
1118                ArithOp::Add => a + b,
1119                ArithOp::Div => {
1120                    if b == 0.0 {
1121                        return Err(GraphError::QueryError {
1122                            detail: "division by zero".into(),
1123                        });
1124                    }
1125                    a / b
1126                }
1127            };
1128            Ok(Some(Value::Float(result)))
1129        }
1130    }
1131}
1132
1133fn eval_set_return_func<F: Fs>(
1134    db: &GraphDb<F>,
1135    match_rs: &ResultSet,
1136    row: usize,
1137    rel_vars: &[String],
1138    name: &str,
1139    args: &[Operand],
1140    params: &BTreeMap<String, Value>,
1141) -> Result<Option<Value>> {
1142    let norm = name.to_ascii_lowercase();
1143    if norm == "type" {
1144        if args.len() != 1 {
1145            return Err(GraphError::QueryError {
1146                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
1147            });
1148        }
1149        let Operand::Var(rel) = &args[0] else {
1150            return Err(GraphError::QueryError {
1151                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
1152            });
1153        };
1154        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
1155    }
1156    if norm == "key" || norm == "id" {
1157        let fname = if norm == "id" { "id" } else { "key" };
1158        if args.len() != 1 {
1159            return Err(GraphError::QueryError {
1160                detail: format!("{fname}() requires exactly 1 argument, got {}", args.len()),
1161            });
1162        }
1163        let Operand::Var(var) = &args[0] else {
1164            return Err(GraphError::QueryError {
1165                detail: format!("{fname}() argument must be a node variable (e.g. {fname}(n))"),
1166            });
1167        };
1168        if rel_vars.iter().any(|r| r == var) {
1169            return Err(GraphError::QueryError {
1170                detail: format!("{fname}() argument `{var}` is a relationship, not a node"),
1171            });
1172        }
1173        // MATCH rows bind node variables to their key string, so the column
1174        // value *is* the key. `id()` aliases `key()`.
1175        return Ok(match_rs.get(row, var).cloned());
1176    }
1177    let mut vals = Vec::with_capacity(args.len());
1178    for arg in args {
1179        vals.push(eval_set_return_operand(
1180            db, match_rs, row, rel_vars, arg, params,
1181        )?);
1182    }
1183    match norm.as_str() {
1184        "tolower" => {
1185            if vals.len() != 1 {
1186                return Err(GraphError::QueryError {
1187                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
1188                });
1189            }
1190            Ok(vals[0].clone().map(|val| match val {
1191                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1192                other => other,
1193            }))
1194        }
1195        "toupper" => {
1196            if vals.len() != 1 {
1197                return Err(GraphError::QueryError {
1198                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
1199                });
1200            }
1201            Ok(vals[0].clone().map(|val| match val {
1202                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1203                other => other,
1204            }))
1205        }
1206        "size" => match vals.first().cloned().flatten() {
1207            None => Ok(None),
1208            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1209            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1210            Some(_) => Ok(None),
1211        },
1212        "coalesce" => Ok(vals.into_iter().flatten().next()),
1213        "abs" => match vals.first().cloned().flatten() {
1214            None => Ok(None),
1215            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
1216            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1217            Some(_) => Ok(None),
1218        },
1219        "round" => match vals.first().cloned().flatten() {
1220            None => Ok(None),
1221            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1222            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
1223            Some(_) => Ok(None),
1224        },
1225        "decay" => {
1226            if vals.len() != 3 {
1227                return Err(GraphError::QueryError {
1228                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
1229                });
1230            }
1231            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
1232                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
1233                (Some(b), Some(a), Some(h)) => {
1234                    let numeric = |v: Value| -> Result<f64> {
1235                        match v {
1236                            Value::Int(n) => Ok(n as f64),
1237                            Value::Float(f) => Ok(f),
1238                            other => Err(GraphError::QueryError {
1239                                detail: format!(
1240                                    "decay() requires numeric arguments, got {other:?}"
1241                                ),
1242                            }),
1243                        }
1244                    };
1245                    let b = numeric(b)?;
1246                    let a = numeric(a)?;
1247                    let h = numeric(h)?;
1248                    if h <= 0.0 {
1249                        return Err(GraphError::QueryError {
1250                            detail: "decay() requires halflife > 0".into(),
1251                        });
1252                    }
1253                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1254                }
1255            }
1256        }
1257        _ => Err(GraphError::QueryError {
1258            detail: format!(
1259                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key, id"
1260            ),
1261        }),
1262    }
1263}
1264
1265fn eval_set_return_item<F: Fs>(
1266    db: &GraphDb<F>,
1267    match_rs: &ResultSet,
1268    row: usize,
1269    rel_vars: &[String],
1270    item: &RetItem,
1271    params: &BTreeMap<String, Value>,
1272) -> Result<Option<Value>> {
1273    match &item.value {
1274        RetVal::Var(v) => eval_set_return_operand(
1275            db,
1276            match_rs,
1277            row,
1278            rel_vars,
1279            &Operand::Var(v.clone()),
1280            params,
1281        ),
1282        RetVal::Prop { var, field } => eval_set_return_operand(
1283            db,
1284            match_rs,
1285            row,
1286            rel_vars,
1287            &Operand::Prop {
1288                var: var.clone(),
1289                field: field.clone(),
1290            },
1291            params,
1292        ),
1293        RetVal::FuncCall { name, args } => {
1294            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1295        }
1296        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1297        RetVal::Agg { .. } => Err(GraphError::QueryError {
1298            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1299        }),
1300    }
1301}
1302
1303/// Project user RETURN from original MATCH rows after SET. No rematch.
1304fn project_set_return_rows<F: Fs>(
1305    db: &GraphDb<F>,
1306    rel_vars: &[String],
1307    match_rs: &ResultSet,
1308    returns: &[RetItem],
1309    params: &BTreeMap<String, Value>,
1310) -> Result<ResultSet> {
1311    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1312    let mut out = ResultSet::new(columns);
1313    for row in 0..match_rs.len() {
1314        let mut cells = Vec::with_capacity(returns.len());
1315        for item in returns {
1316            cells.push(eval_set_return_item(
1317                db, match_rs, row, rel_vars, item, params,
1318            )?);
1319        }
1320        out.push_row(cells);
1321    }
1322    Ok(out)
1323}
1324
1325/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1326/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1327/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1328/// Returns `None` for non-list values or lists with non-numeric elements.
1329/// Extra candidates pulled from an approximate index before re-scoring, over and
1330/// above the `k` asked for.
1331///
1332/// The index orders candidates by `f32` distances, which agree with the exact
1333/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1334/// candidates inside a band that narrow — it cannot move a hit past one that is
1335/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1336/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1337/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1338/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1339/// then the members are interchangeable anyway. `min` is applied to the exact
1340/// score, never to the index's, so a hit sitting on the threshold is decided
1341/// exactly.
1342const VECTOR_RESCORE_MARGIN: usize = 16;
1343
1344/// Cosine similarity between an already-unit query and node `id`'s `field`
1345/// vector, read from the **`f64`** properties. `None` when the node has no
1346/// numeric-list vector there, or its norm is zero.
1347///
1348/// The single definition of the score this API reports. Both the brute-force
1349/// scan and the re-scoring step that follows an index lookup go through it, so
1350/// the two paths cannot disagree — which is the property
1351/// `index_and_brute_force_agree_on_scores` pins.
1352fn exact_vector_similarity(
1353    view: &GraphView<'_>,
1354    id: u32,
1355    field: &str,
1356    q_unit: &[f64],
1357) -> Option<f64> {
1358    let v = view.prop(id, field)?;
1359    let xs = value_as_float_list(&v.into_value())?;
1360    let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1361    if v_norm == 0.0 {
1362        return None;
1363    }
1364    Some(
1365        q_unit
1366            .iter()
1367            .zip(xs.iter())
1368            .map(|(a, b)| a * (b / v_norm))
1369            .sum(),
1370    )
1371}
1372
1373fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1374    match v {
1375        Value::List(items) => items
1376            .iter()
1377            .map(|item| match item {
1378                Value::Float(f) => Some(*f),
1379                Value::Int(i) => Some(*i as f64),
1380                _ => None,
1381            })
1382            .collect(),
1383        _ => None,
1384    }
1385}
1386
1387fn make_graph_mut<'a>(
1388    ids: &'a IdMap,
1389    syms: &'a mut Interner,
1390    labels: &'a [u32],
1391    props: core_storage::v8::seam::ColumnsView<'a>,
1392    topo: &'a mut Topology,
1393    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1394    edge_props: &'a mut EdgeProps,
1395) -> GraphMut<'a> {
1396    GraphMut {
1397        ids,
1398        syms,
1399        labels,
1400        props,
1401        topo,
1402        base_topo: base_csr(base),
1403        edge_props,
1404    }
1405}
1406
1407/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1408///
1409/// A store opened from a snapshot keeps its edges in the mapping and its
1410/// overlay empty, so a rule that reads the graph's shape has to see both.
1411fn base_csr(
1412    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1413) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1414    base.as_ref().map(|b| {
1415        b.topology()
1416            .expect("base topology section bounds validated at open")
1417    })
1418}
1419
1420/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1421///
1422/// Takes explicit field references rather than `&self` so the caller can hold
1423/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1424fn build_props_view<'a>(
1425    props: &'a ColumnStore,
1426    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1427) -> core_storage::v8::seam::ColumnsView<'a> {
1428    match base {
1429        None => core_storage::v8::seam::ColumnsView::owned(props),
1430        Some(b) => {
1431            let archived = b
1432                .columns()
1433                .expect("base columns section bounds validated at open");
1434            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1435                .with_shared_strings(base_string_table(b))
1436        }
1437    }
1438}
1439
1440/// The base columns section paired with the string table that resolves its
1441/// string ids — what `ViewStore` needs to read a neighbour's string property
1442/// out of a V9 snapshot.
1443fn base_columns(
1444    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1445) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1446    base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1447        cols: b
1448            .columns()
1449            .expect("base columns section bounds validated at open"),
1450        strings: base_string_table(b),
1451    })
1452}
1453
1454/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1455///
1456/// Every `ColumnsView` built over a base must carry it: without it a V9
1457/// snapshot's string columns, whose own tables are empty, read back as absent.
1458fn base_string_table(
1459    base: &core_storage::v8::MappedBase,
1460) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1461    base.string_table()
1462        .transpose()
1463        .expect("base strings section bounds validated at open")
1464}
1465
1466fn build_topo_view<'a>(
1467    overlay: &'a Topology,
1468    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1469) -> core_storage::v8::seam::TopologyView<'a> {
1470    match base {
1471        None => core_storage::v8::seam::TopologyView::owned(overlay),
1472        Some(b) => {
1473            let archived_csr = b
1474                .topology()
1475                .expect("base topology section bounds validated at open");
1476            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1477        }
1478    }
1479}
1480
1481/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1482///
1483/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1484/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1485/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1486/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1487/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1488#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1489pub enum FsyncPolicy {
1490    /// Every WAL commit calls `fs.sync` (today's behavior).
1491    #[default]
1492    Strict,
1493    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1494    /// this policy is set on the database.
1495    Batched,
1496    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1497    Relaxed,
1498}
1499
1500/// A precondition for a compare-and-set batch write.
1501///
1502/// All preconditions in a [`GraphDb::write_batch_cas`] or
1503/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1504/// any operation in the batch is applied.  If any precondition fails, the
1505/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1506/// is written.
1507///
1508/// # Touch definition
1509///
1510/// A node's last-change commit (`last_changed`) is updated when any of the
1511/// following state-changing WAL records touch it:
1512///
1513/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1514/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1515/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1516///   endpoints (an edge change touches both sides).
1517/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1518///   for deleted keys so the pre-deletion entry is never observed.
1519///
1520/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1521/// state no-ops.  The underlying mutation that triggered rule firing already
1522/// updated the relevant nodes' last-change entries.  Rule-management records
1523/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1524/// do not touch any node's last-change.
1525#[derive(Debug, Clone, PartialEq, Eq)]
1526pub enum Precondition {
1527    /// The node's last-change commit must equal `expected`.
1528    ///
1529    /// Fails with [`GraphError::CasConflict`] when:
1530    /// - The node does not exist (`last_changed` returns `None`), or
1531    /// - The recorded commit seq does not match `expected`.
1532    NodeUnchangedSince { key: String, expected: u64 },
1533    /// The node must not exist (not inserted, or already deleted).
1534    ///
1535    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1536    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1537    NodeAbsent { key: String },
1538}
1539
1540pub struct GraphDb<F: Fs> {
1541    fs: F,
1542    ids: Arc<IdMap>,
1543    syms: Arc<Interner>,
1544    topo: Arc<Topology>,
1545    props: Arc<ColumnStore>,
1546    labels: Arc<Vec<u32>>, // node id -> label symbol
1547    /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1548    /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1549    ///
1550    /// A private table rather than the shared [`Interner`]: interning
1551    /// `"default"` at open would add a symbol to the store's symbol table and
1552    /// change the bytes of the next snapshot of a store that has no namespaces
1553    /// at all.
1554    ns_names: Vec<String>,
1555    /// Namespace index per dense node id, into [`Self::ns_names`];
1556    /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1557    ///
1558    /// Derived: built by one pass over the `ns` column at open (which reads
1559    /// nothing when the column does not exist) and maintained at every node
1560    /// insert. Never written to a snapshot or the WAL, because the property it
1561    /// mirrors already is. A namespace cannot change, so no other record shape
1562    /// can move a node between namespaces.
1563    node_ns: Vec<u32>,
1564    edge_props: Arc<EdgeProps>,
1565    engine: RuleEngine,
1566    view_store: ViewStore,
1567    /// Incremental inverted index for full-text-lite search.
1568    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1569    fulltext: Arc<FulltextIndex>,
1570    /// Opt-in equality index over scalar node properties.
1571    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1572    /// open end (mirrors `fulltext`).
1573    prop_index: PropertyIndex,
1574    /// Whether this store records insert-count multiplicity (§5.13).
1575    ///
1576    /// Declared like `prop_index`'s enabled pairs — a WAL record replayed at
1577    /// open, re-emitted into the baseline by a truncating snapshot — but it
1578    /// gates a *format* step rather than an index: `WalRecord::SetEdgeCount`
1579    /// (discriminant 23) is written only when this is `true`, so a store that
1580    /// never opts in stays readable by a binary that predates the record.
1581    multiplicity: bool,
1582    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1583    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1584    fsync: FsyncPolicy,
1585    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1586    /// call increments this once; all events emitted from that call share the same
1587    /// `commit_seq` value.
1588    commit_seq: u64,
1589    /// Commit → wall-clock map, loaded from the `commit_times.bin` sidecar at
1590    /// open and appended to by `log_then_apply_with` — the one place a commit
1591    /// is born. Replay does **not** stamp: `apply_frames` re-applies commits
1592    /// that already happened, and `SystemTime::now()` there would record replay
1593    /// time as commit time. Empty on a store written before v0.6.11, which
1594    /// makes every date query answer `NoRecordedTime` rather than guess.
1595    commit_times: core_storage::commit_times::CommitTimes,
1596    /// When set, subsequent commits are recorded at this instant instead of the
1597    /// system clock.
1598    ///
1599    /// Sticky on purpose. A backfill replays history that happened over months,
1600    /// and a day's worth of rows genuinely share one instant — a one-shot flag
1601    /// would mean setting it before every row of a bulk load, and forgetting one
1602    /// would stamp that row "now" in the middle of 2026-06. Sticky makes the
1603    /// failure visible instead: forget to move it and every commit carries the
1604    /// same timestamp, which a date query answers oddly and an inspection shows
1605    /// at once.
1606    commit_time_override: Option<i64>,
1607    /// `true` only while the open path is replaying, where `load_from_disk`
1608    /// calls `fulltext.rebuild_all` unconditionally afterwards.
1609    ///
1610    /// Replaying an `EnableFulltext` record backfills its pair with a full
1611    /// `0..ids.len()` scan, and every snapshot re-emits one such record per
1612    /// enabled pair — so on a snapshotted store the open does that scan once per
1613    /// pair and then `rebuild_all` clears every posting and does it all again.
1614    /// The backfill is pure waste *when a rebuild follows*, which is true of the
1615    /// open path and **false** of `refresh()`: refresh applies peer frames and
1616    /// then only folds, so its backfill is the only thing that indexes them.
1617    fulltext_rebuild_follows: bool,
1618    /// `true` when `commit_times.bin` was present but would not decode.
1619    ///
1620    /// Mirrors `roles: None`: a damaged map must not read as "this store
1621    /// records no times", because that is also what an honest pre-v0.6.11 store
1622    /// says. Date queries answer `Corrupt` instead, and nothing is appended to
1623    /// a file already known to be damaged.
1624    commit_times_poisoned: bool,
1625    /// RBAC role definitions loaded from `roles.json` at open.
1626    ///
1627    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1628    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1629    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1630    roles: Option<Vec<RoleDef>>,
1631    /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1632    /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1633    ///
1634    /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1635    /// taken from this handle. Replaced (not cleared) whenever the role
1636    /// definitions change or the store is reloaded, which `commit_seq` does not
1637    /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1638    role_masks: Arc<crate::mask::RoleMaskCache>,
1639    /// Which loaded store this handle is, for memos that outlive it.
1640    ///
1641    /// `role_masks` needs no such thing — the handle owns it and replaces it —
1642    /// but a [`Scope`](crate::mask::Scope) is the caller's, so its resolved key
1643    /// leg is stamped with this alongside `commit_seq`. Minted fresh here and
1644    /// again in [`reset_for_reload`](GraphDb::reset_for_reload), at exactly the
1645    /// two points a fresh `RoleMaskCache` is installed; see
1646    /// [`StoreStamp`](crate::mask::StoreStamp) for the invariant.
1647    store_id: crate::mask::StoreId,
1648    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1649    /// distribute_events call.
1650    subscriptions: Vec<SubEntry>,
1651    /// Live query subscriptions. Re-executed on every commit when non-empty.
1652    /// Dead `Weak` entries are pruned inside `distribute_events`.
1653    query_subscriptions: Vec<QuerySubEntry>,
1654    /// Queue capacity for new subscriptions created by this db.  Default is
1655    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1656    /// to test Lagged behaviour with small queues.
1657    sub_capacity: usize,
1658    /// True for as-of instances opened via [`GraphDb::open_at`].
1659    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1660    /// when this flag is set.
1661    read_only: bool,
1662    /// Total WAL commit count at the time [`open_at`] was called.
1663    /// 0 for normal (non-as-of) instances.
1664    total_wal_commits: u64,
1665    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1666    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1667    /// reads go through a merged `TopologyView`.  `self.props` is always
1668    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1669    base: Option<Arc<core_storage::v8::MappedBase>>,
1670    // ── MVCC epoch reader state ───────────────────────────────────────────────
1671    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1672    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1673    /// `None` only between struct creation and the first fold.
1674    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1675    /// Per-commit deltas accumulated since the last fold.
1676    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1677    /// How many commits have occurred since the last fold.
1678    commits_since_fold: usize,
1679    /// When true, `log_then_apply_with` buffers event notifications instead of
1680    /// firing them immediately.  Used by the group-commit drain thread to defer
1681    /// events until after the group fsync (R2: durability before notification).
1682    /// Cleared to false once the drain thread flushes or discards the buffer.
1683    defer_events: bool,
1684    /// Buffered events accumulated while `defer_events` is true.
1685    deferred_events: Vec<DeferredEvent>,
1686    /// Set to true by the group-commit drain thread when a group fsync fails
1687    /// after WAL truncation.  All subsequent mutation attempts return an IO
1688    /// error until the database is reopened.
1689    degraded: bool,
1690    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1691    /// HNSW, and IVF sections from the mmap base into the engine's retained
1692    /// fields.  `false` on all opens until first use; always `true` for non-V8
1693    /// opens (base is None, fast-path sets flag immediately).
1694    v8_sections_loaded: std::sync::atomic::AtomicBool,
1695    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1696    v8_sections_mutex: std::sync::Mutex<()>,
1697    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1698    /// the node was last modified by commit `seq`.
1699    ///
1700    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1701    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1702    /// nodes return `None` from `last_changed` until they are next mutated.
1703    ///
1704    /// See [`Precondition`] for the full touch definition.
1705    last_change: HashMap<u32, u64>,
1706    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1707    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1708    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1709    wal_archive_retention: Option<u32>,
1710    /// Global frame index of the first commit that is still reachable through
1711    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1712    /// Default 0 = all history reachable.
1713    wal_horizon_floor: u64,
1714    /// True when the surviving archive chain forms a continuous WAL history
1715    /// starting from the store's first commit (the genesis chain).
1716    ///
1717    /// `open_at` may replay archive-resident commits from empty state only when
1718    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1719    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1720    ///     already exist (breaks the chain for subsequent archives), or
1721    ///   - any archive is pruned (floor advances past zero).
1722    ///
1723    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1724    archive_genesis_chain: bool,
1725    /// True when this handle can *prove* the live WAL has never been truncated:
1726    /// there was no `snapshot.bin` when it opened the store, and it has taken no
1727    /// truncating snapshot since.
1728    ///
1729    /// The archive path's genesis check asks "did a snapshot exist before this
1730    /// one?" as a proxy for "was the WAL ever truncated". The proxy is sound
1731    /// across sessions — this binary cannot tell a history-preserving snapshot
1732    /// from a truncating one once the handle that took it is gone — but inside
1733    /// one session it is not, and `enable_multiplicity` made that visible: its
1734    /// forced `keep_wal` snapshot left the WAL entirely intact and yet
1735    /// permanently disqualified the store from ever receiving a genesis marker
1736    /// (defect #23). This flag is what the proxy defers to when the answer is
1737    /// actually known.
1738    snapshot_preserved_history: bool,
1739    /// Transient write-authz context set by `write_batch_authz` /
1740    /// `query_write_authz` for the duration of ONE mutation call.
1741    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1742    pending_write_authz: Option<WriteAuthz>,
1743    /// Slow-query threshold in milliseconds.  0 = disabled.
1744    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1745    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1746    /// — env vars are process-global and race parallel test threads).
1747    slow_query_threshold_ms: u64,
1748    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1749    /// can record entries without requiring `&mut self`).
1750    slow_queries: std::sync::Mutex<SlowQueryLog>,
1751    /// `(field, label, caller)` triples whose exact-versus-approximate
1752    /// ambiguity this handle has already explained once. See
1753    /// [`note_ambiguous_exactness`](GraphDb::note_ambiguous_exactness).
1754    /// The caller shape is part of the key because the two shapes give
1755    /// different advice — silencing one with the other would leave a caller
1756    /// reading advice meant for a signature it does not have.
1757    /// Advice bookkeeping, not graph state: a reload keeps it, as the
1758    /// slow-query log does.
1759    warned_ambiguous_exactness: std::sync::Mutex<HashSet<(String, String, ExactnessCaller)>>,
1760    /// Instant at which the database was opened (used by `/metrics` uptime).
1761    started_at: std::time::Instant,
1762    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1763    /// Byte offset of the WAL prefix already applied to in-memory state.
1764    ///
1765    /// Advanced by exactly the encoded length of every frame this handle
1766    /// appends, and by the decoded byte count of every tail
1767    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1768    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1769    /// drain thread truncates a failed group. Compared against the WAL's
1770    /// on-disk length to decide staleness.
1771    wal_consumed: u64,
1772    /// The **global 0-based frame index the next appended WAL frame will
1773    /// occupy** — `wal_horizon_floor` plus every frame currently reachable
1774    /// through archives and the live WAL.
1775    ///
1776    /// This is the space every history surface addresses: `edges_at`,
1777    /// `was_linked`, both history readouts and `open_at` all index the sequence
1778    /// [`all_frames`](GraphDb::all_frames) returns, and
1779    /// [`wal_total_commits`](GraphDb::wal_total_commits) counts it.
1780    ///
1781    /// It exists because **a commit is not a frame**. `commit_seq` counts
1782    /// commits; a commit whose rules fire appends a *second* frame — the
1783    /// derived-edge history marker — that no counter of commits ever sees. The
1784    /// two diverge by one frame per rule-firing commit, cumulatively, so
1785    /// deriving a frame index from `commit_seq` under-reports by more and more
1786    /// as history grows and resolves every date to an earlier graph. Silently:
1787    /// an older graph is a plausible answer, not an error.
1788    ///
1789    /// Maintained in lockstep with [`wal_consumed`](GraphDb::wal_consumed) —
1790    /// the same appends advance both, one in frames and one in bytes — so the
1791    /// two are seeded and rewound at exactly the same places. Keep it that way.
1792    wal_frames_written: u64,
1793    /// Identity of the snapshot this handle's base state came from, as
1794    /// `(len, mtime_nanos)`. A different value means another process replaced
1795    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1796    snapshot_ident: Option<(u64, u64)>,
1797    /// The options this handle was opened with. Replayed verbatim when
1798    /// `refresh` has to rebuild from disk.
1799    open_opts: OpenOptions,
1800    /// True when this handle holds the cross-process write lock for its whole
1801    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1802    /// no-op on such a handle, and never releases the lock.
1803    holds_lifetime_lock: bool,
1804    /// True between a failed lock acquisition and the end of the write scope
1805    /// that failed. Makes every WAL-appending mutation in that scope return
1806    /// [`GraphError::Busy`] instead of writing.
1807    lock_denied: bool,
1808    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1809    /// pinned to one commit, so it is never stale and never refreshes — later
1810    /// commits by any process are deliberately invisible to it.
1811    pinned: bool,
1812}
1813
1814/// One group of deferred event notifications, held until the group fsync
1815/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1816struct DeferredEvent {
1817    rec: core_storage::WalRecord,
1818    engine_deltas: Vec<EngineEdgeDelta>,
1819    seq: u64,
1820    ingest: Option<(String, usize)>,
1821}
1822
1823/// Options for [`GraphDb::open_with_options`].
1824#[derive(Clone, Copy, Debug)]
1825pub struct OpenOptions {
1826    /// Rewrite an old-format snapshot to the current VERSION after a
1827    /// successful load (default `true`). The old snapshot is kept as
1828    /// `snapshot.bin.bak` until the next clean open at the current version,
1829    /// at which point the `.bak` is deleted.
1830    ///
1831    /// Set to `false` to open a store without touching any on-disk files
1832    /// (useful for read-only inspection of a store at an older format).
1833    pub auto_migrate: bool,
1834
1835    /// Write the valid WAL prefix back over a torn tail on open (default
1836    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1837    ///
1838    /// Set to `false` for an unattended reader. The valid prefix is still
1839    /// decoded and replayed in memory, but nothing is written: a reader that
1840    /// opens while another process is mid-append would otherwise discard a
1841    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1842    /// every prompt, passes `false` for exactly this reason.
1843    pub repair_wal: bool,
1844
1845    /// Open without ever writing to the store (default `false`).
1846    ///
1847    /// A read-only handle:
1848    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1849    ///   `snapshot()`;
1850    /// - performs no disk write at open — no WAL repair write-back and no
1851    ///   auto-migration rewrite, whatever the other two flags say;
1852    /// - never takes the cross-process write lock, so it opens immediately even
1853    ///   while another process is writing, and never makes a writer wait.
1854    ///
1855    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1856    /// normally, so a read-only handle can follow another process's commits.
1857    pub read_only: bool,
1858}
1859
1860impl Default for OpenOptions {
1861    fn default() -> Self {
1862        Self {
1863            auto_migrate: true,
1864            repair_wal: true,
1865            read_only: false,
1866        }
1867    }
1868}
1869
1870/// How long a writer polls for the cross-process write lock before giving up
1871/// with [`GraphError::Busy`].
1872///
1873/// Long enough to ride out another process's commit (a batch apply plus one
1874/// fsync), short enough that a stuck peer surfaces as an error rather than a
1875/// hang.
1876pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1877
1878/// Refusal when a `MERGE` create cannot choose a namespace.
1879///
1880/// A role bound to two or more namespaces cannot have its create arm land in
1881/// `default`, and the statement did not name `ns`. The role must name one.
1882pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1883    "role-bound token: MERGE create requires the role to name one namespace";
1884
1885/// Interval between poll attempts while waiting for the cross-process lock.
1886pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1887
1888/// Why `load_from_disk` is running, which decides whether it may repair.
1889#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1890enum LoadOrigin {
1891    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1892    /// the signature of a crash and truncating it is correct, and archives
1893    /// orphaned by an interrupted prune can be swept.
1894    Open,
1895    /// A reload driven by [`GraphDb::refresh`], because another process
1896    /// replaced the snapshot. Nothing here is crash recovery — the store is
1897    /// live and someone else is writing it — so this origin writes nothing.
1898    Reload,
1899}
1900
1901/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1902///
1903/// `None` at the call site = full authority (today's zero-cost behavior).
1904/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1905/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1906/// record is built.  A denial returns an error with no WAL frame written.
1907///
1908/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1909/// hidden-node existence to callers.
1910#[derive(Clone, Debug)]
1911pub struct WriteAuthz {
1912    pub role: String,
1913    pub scope: WriteScope,
1914    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1915    /// Always `Omit`-mode — never `Stub`.
1916    pub mask: crate::mask::NodeMask,
1917}
1918
1919/// The error every role surface gives when `roles.json` did not parse at open.
1920///
1921/// One text, so `mask_for_role` and [`GraphDb::roles_checked`] cannot drift
1922/// apart on the same cause.
1923fn roles_poisoned() -> GraphError {
1924    GraphError::Corrupt {
1925        detail: "roles.json was corrupt at open; fix the file and re-open to restore role access"
1926            .into(),
1927    }
1928}
1929
1930/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1931///
1932/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1933/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1934/// syncs the directory entry. This is the only correct path for writing the
1935/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1936/// the directory sync.
1937pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1938    use core_storage::fs::{FileId, Fs as _};
1939    RealFs::new(dir)
1940        .map_err(core_storage::GraphError::Io)?
1941        .write_atomic(FileId::SnapshotBak, bytes)
1942        .map_err(core_storage::GraphError::Io)
1943}
1944
1945/// Return the on-disk snapshot format version without decoding the full snapshot.
1946///
1947/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1948/// snapshot file exists (WAL-only store). Returns an error if the header is
1949/// malformed.
1950pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1951    use std::io::Read as _;
1952    let path = dir.join("snapshot.bin");
1953    let mut header = [0u8; 6];
1954    let n = match std::fs::File::open(&path) {
1955        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1956        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1957        Err(e) => return Err(core_storage::GraphError::Io(e)),
1958    };
1959    core_storage::snapshot::peek_version(&header[..n])
1960}
1961
1962/// Options for [`GraphDb::snapshot_with`].
1963#[derive(Debug, Clone, Default)]
1964pub struct SnapshotOptions {
1965    /// When `true`, the WAL is preserved after the snapshot write.
1966    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
1967    /// When `false` (the default), the WAL is truncated to a minimal
1968    /// baseline so cold-start replay stays fast.
1969    pub keep_wal: bool,
1970    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
1971    /// before a fresh WAL baseline is written (history-preserving snapshot).
1972    ///
1973    /// This is the feature opt-in: `false` (the default) leaves the existing
1974    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
1975    /// precedence over `keep_wal` when both are set.
1976    ///
1977    /// Archives can be scanned by [`GraphDb::node_history`],
1978    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
1979    /// [`GraphDb::open_at`], extending the reachable history horizon across
1980    /// snapshot boundaries.
1981    pub archive_wal: bool,
1982}
1983
1984/// Derive the scan-label sym for the commit-skip fast-path.
1985///
1986/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
1987/// or `IndexIntersect`) with a concrete label string, then interns it.
1988///
1989/// Returns `None` in all cases where skipping is unsafe:
1990/// - Any `Expand` op is present (edge traversal; edges change results regardless
1991///   of node labels).
1992/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
1993/// - No recognizable leading scan op is found.
1994///
1995/// This is the conservative v0.4.3 boundary. The caller stores the result in
1996/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
1997fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
1998    // Any Expand → must always re-execute (edges can change join results).
1999    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
2000        return None;
2001    }
2002    for op in ops {
2003        match op {
2004            PlanOp::ScanLabel {
2005                label: Some(label), ..
2006            } => return Some(syms.intern(label)),
2007            PlanOp::IndexScan {
2008                label: Some(label), ..
2009            } => return Some(syms.intern(label)),
2010            PlanOp::IndexIntersect {
2011                label: Some(label), ..
2012            } => return Some(syms.intern(label)),
2013            _ => {}
2014        }
2015    }
2016    None
2017}
2018
2019/// How an as-of read is restricted — the argument to
2020/// [`GraphDb::query_at_scoped`].
2021///
2022/// Every variant is resolved against the graph **as it was at the requested
2023/// commit**, not against the current graph.
2024#[derive(Debug, Clone, Copy)]
2025pub enum AsOfScope<'a> {
2026    /// Everything the named role may see. The role *definition* is the current
2027    /// one — `roles.json` is a sidecar and has no past version — but its
2028    /// `keys` and `labels` are resolved against the as-of graph.
2029    Role(&'a str),
2030    /// An explicit node-key allow-list. Keys that did not exist at that commit
2031    /// resolve to nothing.
2032    Keys(&'a [String]),
2033    /// A role intersected with a client-supplied allow-list. The intersection
2034    /// is the never-widen rule: a client mask can only narrow a role.
2035    RoleAndKeys(&'a str, &'a [String]),
2036    /// Every live node in one namespace, as the graph was at that commit.
2037    ///
2038    /// A namespace cannot change — it is set at insert and immutable — so the
2039    /// answer is simply "the nodes that existed then and are in this
2040    /// namespace". A name no node uses resolves to nothing, never to
2041    /// everything.
2042    Namespace(&'a str),
2043}
2044
2045impl GraphDb<RealFs> {
2046    /// Open the database at `dir` with default options.
2047    ///
2048    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
2049    /// Old-format snapshots (V5, V6) are automatically migrated to the
2050    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
2051    pub fn open(dir: &std::path::Path) -> Result<Self> {
2052        Self::open_with_options(dir, OpenOptions::default())
2053    }
2054
2055    /// Open the database at `dir` with explicit options.
2056    ///
2057    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
2058    /// snapshot is an older format version, this function:
2059    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
2060    ///      + fsynced) before any modification.
2061    ///   2. Rewrites `snapshot.bin` at the current format version via
2062    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
2063    ///
2064    /// If migration fails the error is returned and the original files are
2065    /// intact (the `.bak` was written before the new snapshot was attempted).
2066    ///
2067    /// A clean open that finds the snapshot already at the current version
2068    /// deletes any leftover `.bak` file.
2069    ///
2070    /// WAL-only stores (no snapshot) are never auto-migrated on open.
2071    ///
2072    /// `opts.repair_wal` controls the other write this function can make; see
2073    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
2074    /// no file on disk.
2075    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
2076        Self::open_dir(dir, opts, true)
2077    }
2078
2079    /// Open without taking the cross-process write lock for the handle's
2080    /// lifetime.
2081    ///
2082    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
2083    /// its handle open indefinitely, so it takes the lock per write instead of
2084    /// keeping every other process out of the store for as long as it runs.
2085    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
2086        Self::open_dir(dir, OpenOptions::default(), false)
2087    }
2088
2089    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2090        // Header-only peek — 6 bytes, no full decode.
2091        let snap_version = snapshot_version_at(dir)?;
2092
2093        // Full load: decode snapshot + replay WAL + rebuild indexes.
2094        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
2095
2096        // A read-only handle writes nothing at open, so it never migrates —
2097        // the old-format snapshot is loaded and left exactly as it is.
2098        if opts.auto_migrate && !opts.read_only {
2099            match snap_version {
2100                Some(ver) if ver < core_storage::snapshot::VERSION => {
2101                    let _tm = std::time::Instant::now();
2102                    // Copy the original snapshot to .bak at OS level — no in-memory
2103                    // buffer required for a 2+ GiB file.
2104                    //
2105                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
2106                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
2107                    // A torn .bak on crash is acceptable because the original
2108                    // snapshot.bin is the authoritative source until after the rename.
2109                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
2110                        .map_err(core_storage::GraphError::Io)?;
2111                    trace_migrate!("bak copy done", _tm);
2112                    // Rewrite snapshot at current version; keep WAL intact.
2113                    db.snapshot_with(SnapshotOptions {
2114                        keep_wal: true,
2115                        ..SnapshotOptions::default()
2116                    })?;
2117                    trace_migrate!("snapshot_with done", _tm);
2118                }
2119                Some(_) => {
2120                    // Already current version: remove any leftover .bak.
2121                    let bak = dir.join("snapshot.bin.bak");
2122                    if bak.exists() {
2123                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
2124                    }
2125                }
2126                None => {
2127                    // WAL-only store — nothing to migrate on open.
2128                }
2129            }
2130        }
2131
2132        Ok(db)
2133    }
2134
2135    /// Open a read-only view of the database as it existed after `commit`.
2136    ///
2137    /// Commit indices are 0-based over the current WAL: commit 0 is the state
2138    /// after the first WAL frame, commit N-1 is the state after the N-th (most
2139    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
2140    ///
2141    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
2142    /// so as-of can only reach commits recorded in the current WAL (those
2143    /// written after the most recent snapshot, or all commits if no snapshot
2144    /// was ever taken).  Commit 0 in `open_at` always refers to the first
2145    /// frame in the WAL that exists on disk, not the first ever write to the
2146    /// database.  When the on-disk snapshot recorded that it truncated the
2147    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
2148    /// before frame replay, so the as-of view includes all pre-snapshot data.
2149    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
2150    /// are ignored and replay is WAL-only, as before.
2151    ///
2152    /// **Read-only.** Every mutation method and `snapshot()` on the returned
2153    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
2154    /// `stats()` work normally.
2155    ///
2156    /// # Errors
2157    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
2158    ///   when the WAL is empty after a snapshot).
2159    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
2160        Self::open_at_with(RealFs::new(dir)?, commit)
2161    }
2162
2163    /// Run a **read-only** Cypher query against the graph as it existed at
2164    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
2165    /// of this store's directory at that commit and executes the read there.
2166    ///
2167    /// The current instance is unaffected. Write statements are rejected (the
2168    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
2169    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
2170    /// state. Prefer this over holding many historical instances open.
2171    ///
2172    /// # Errors
2173    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
2174    /// - A query error for a malformed or write query.
2175    pub fn query_at(
2176        &self,
2177        commit: u64,
2178        cypher: &str,
2179        params: &std::collections::BTreeMap<String, Value>,
2180    ) -> Result<ResultSet> {
2181        let temporal = self.open_at_for_read(commit, cypher)?;
2182        temporal.query(cypher, params)
2183    }
2184
2185    /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
2186    ///
2187    /// The **graph** is as of `commit`; the **role definition** is as it is
2188    /// now, because `roles.json` is a sidecar and is never a WAL record — it
2189    /// has no past version to read. A role's `keys` and `labels` are resolved
2190    /// against the commit-`commit` graph, so a role that may see a label sees
2191    /// exactly the nodes that carried it then, and an explicit key that did
2192    /// not exist yet resolves to nothing.
2193    ///
2194    /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
2195    /// only narrow what a role may see, never widen it.
2196    ///
2197    /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
2198    /// them.
2199    ///
2200    /// # Errors
2201    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2202    ///   range; the error carries that range.
2203    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2204    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2205    /// - A query error for a malformed or write query.
2206    pub fn query_at_scoped(
2207        &self,
2208        commit: u64,
2209        cypher: &str,
2210        params: &std::collections::BTreeMap<String, Value>,
2211        scope: AsOfScope<'_>,
2212    ) -> Result<ResultSet> {
2213        let temporal = self.open_at_for_read(commit, cypher)?;
2214        let mask = temporal.mask_at_scope(scope)?;
2215        temporal.query_masked(cypher, params, &mask)
2216    }
2217
2218    /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
2219    /// whatever `scope` resolves to.
2220    ///
2221    /// This is what a surface needs when a caller passes `namespace` beside a
2222    /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
2223    /// restriction, and the namespace is a second one that composes with it
2224    /// rather than replacing it. The intersection is the never-widen rule — a
2225    /// namespace can only narrow what the scope already allows — and both legs
2226    /// are resolved against the graph as it was at `commit`.
2227    ///
2228    /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
2229    pub fn query_at_scoped_in_namespace(
2230        &self,
2231        commit: u64,
2232        cypher: &str,
2233        params: &std::collections::BTreeMap<String, Value>,
2234        scope: AsOfScope<'_>,
2235        namespace: &str,
2236    ) -> Result<ResultSet> {
2237        let temporal = self.open_at_for_read(commit, cypher)?;
2238        let mask = temporal
2239            .mask_at_scope(scope)?
2240            .intersect(&temporal.mask_for_namespace(namespace));
2241        temporal.query_masked(cypher, params, &mask)
2242    }
2243
2244    /// Run a **read-only** Cypher query at `commit`, restricted by a
2245    /// [`Scope`](crate::mask::Scope).
2246    ///
2247    /// [`AsOfScope`] names *one* restriction — a role, a key list, a namespace,
2248    /// or a role-and-keys pair. A `Scope` is the general shape a handle carries,
2249    /// and nesting can give it several role or namespace legs at once, so it
2250    /// cannot be spelled as an `AsOfScope`. This is the entry point a scoped
2251    /// handle uses for time travel; `query_at_scoped` stays the way to ask for
2252    /// one named restriction.
2253    ///
2254    /// Both the graph and the scope's key and namespace legs are resolved
2255    /// against `commit`; a role's *definition* is the current one, because
2256    /// `roles.json` is a sidecar with no past version — the same split
2257    /// [`GraphDb::query_at_scoped`] documents.
2258    ///
2259    /// The scope resolves **cold** here: a temporal handle is its own store, so
2260    /// its ids could never be served to a live read, but filling the scope's
2261    /// one-entry key memo from a handle thrown away at the end of this call
2262    /// would evict the live entry for nothing. See
2263    /// [`Scope::resolve_uncached`](crate::mask::Scope::resolve_uncached).
2264    ///
2265    /// # Errors
2266    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2267    ///   range; the error carries that range.
2268    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2269    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2270    /// - A query error for a malformed or write query.
2271    pub fn query_at_with_scope(
2272        &self,
2273        commit: u64,
2274        cypher: &str,
2275        params: &std::collections::BTreeMap<String, Value>,
2276        scope: &crate::mask::Scope,
2277    ) -> Result<ResultSet> {
2278        let temporal = self.open_at_for_read(commit, cypher)?;
2279        let mask = scope.resolve_uncached(&temporal)?;
2280        temporal.query_masked(cypher, params, &mask)
2281    }
2282
2283    /// Open the temporal view for a time-travel read and refuse write Cypher.
2284    ///
2285    /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
2286    /// resolve the commit and reject writes identically.
2287    fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
2288        let dir = self.fs.dir().to_path_buf();
2289        let temporal = Self::open_at(&dir, commit)?;
2290        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
2291            detail: format!("lex: {e}"),
2292        })?) {
2293            return Err(GraphError::QueryError {
2294                detail: "query_at is read-only: write statements are not permitted in a \
2295                         time-travel query"
2296                    .into(),
2297            });
2298        }
2299        Ok(temporal)
2300    }
2301}
2302
2303impl<F: Fs> GraphDb<F> {
2304    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
2305    pub fn open_with(fs: F) -> Result<Self> {
2306        Self::open_with_repair(fs, true)
2307    }
2308
2309    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
2310    /// prefix without writing the truncation back. See
2311    /// [`OpenOptions::repair_wal`].
2312    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
2313        Self::open_generic(
2314            fs,
2315            OpenOptions {
2316                repair_wal,
2317                ..OpenOptions::default()
2318            },
2319            true,
2320        )
2321    }
2322
2323    /// Shared open path.
2324    ///
2325    /// `hold_lock` requests the cross-process write lock for the whole handle
2326    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
2327    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
2328    /// `false` and takes the lock per write instead, so that a long-lived
2329    /// server does not keep every other process out of the store.
2330    ///
2331    /// A read-only open never takes the lock regardless of `hold_lock`.
2332    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2333        let mut db = Self::new_empty(fs, opts);
2334        db.read_only = opts.read_only;
2335        if hold_lock && !opts.read_only {
2336            if !db.poll_lock(WRITE_LOCK_WAIT)? {
2337                return Err(GraphError::Busy { holder: None });
2338            }
2339            db.holds_lifetime_lock = true;
2340        }
2341        db.load_from_disk(LoadOrigin::Open)?;
2342        Ok(db)
2343    }
2344
2345    /// A handle with no state loaded: every field at its empty value, the
2346    /// filesystem and options in place. Only [`load_from_disk`] makes it
2347    /// usable.
2348    fn new_empty(fs: F, opts: OpenOptions) -> Self {
2349        Self {
2350            fs,
2351            ids: Arc::new(IdMap::new()),
2352            syms: Arc::new(Interner::new()),
2353            topo: Arc::new(Topology::new()),
2354            props: Arc::new(ColumnStore::new()),
2355            labels: Arc::new(Vec::new()),
2356            ns_names: vec![NS_DEFAULT.to_string()],
2357            node_ns: Vec::new(),
2358            edge_props: Arc::new(EdgeProps::new()),
2359            engine: RuleEngine::new(),
2360            view_store: ViewStore::new(),
2361            fulltext: Arc::new(FulltextIndex::new()),
2362            prop_index: PropertyIndex::new(),
2363            multiplicity: false,
2364            event_sink: None,
2365            fsync: FsyncPolicy::Strict,
2366            commit_seq: 0,
2367            commit_times: core_storage::commit_times::CommitTimes::default(),
2368            commit_times_poisoned: false,
2369            fulltext_rebuild_follows: false,
2370            commit_time_override: None,
2371            roles: Some(vec![]),
2372            role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
2373            store_id: crate::mask::StoreId::next(),
2374            subscriptions: Vec::new(),
2375            query_subscriptions: Vec::new(),
2376            sub_capacity: DEFAULT_SUB_CAPACITY,
2377            read_only: false,
2378            total_wal_commits: 0,
2379            base: None,
2380            fold_overlay: None,
2381            delta_tail: Vec::new(),
2382            commits_since_fold: 0,
2383            defer_events: false,
2384            deferred_events: Vec::new(),
2385            degraded: false,
2386            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2387            v8_sections_mutex: std::sync::Mutex::new(()),
2388            last_change: HashMap::new(),
2389            wal_archive_retention: None,
2390            wal_horizon_floor: 0,
2391            archive_genesis_chain: false,
2392            // Nothing is proven until `load_from_disk` has looked at the store.
2393            snapshot_preserved_history: false,
2394            pending_write_authz: None,
2395            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2396                .ok()
2397                .and_then(|v| v.parse().ok())
2398                .unwrap_or(100),
2399            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2400                entries: std::collections::VecDeque::new(),
2401                total: 0,
2402            }),
2403            warned_ambiguous_exactness: std::sync::Mutex::new(HashSet::new()),
2404            started_at: std::time::Instant::now(),
2405            wal_consumed: 0,
2406            wal_frames_written: 0,
2407            snapshot_ident: None,
2408            open_opts: opts,
2409            holds_lifetime_lock: false,
2410            lock_denied: false,
2411            pinned: false,
2412        }
2413    }
2414
2415    /// Return every field describing stored graph state to its empty value,
2416    /// leaving this handle's own identity alone.
2417    ///
2418    /// Preserved on purpose: the filesystem, open options, lock ownership, the
2419    /// event sink and subscriptions, fsync policy, degraded flag, and the
2420    /// slow-query configuration and log. A caller that registered a sink or a
2421    /// subscription keeps it across a reload.
2422    fn reset_for_reload(&mut self) {
2423        self.ids = Arc::new(IdMap::new());
2424        self.syms = Arc::new(Interner::new());
2425        self.topo = Arc::new(Topology::new());
2426        self.props = Arc::new(ColumnStore::new());
2427        self.labels = Arc::new(Vec::new());
2428        self.ns_names = vec![NS_DEFAULT.to_string()];
2429        self.node_ns = Vec::new();
2430        self.edge_props = Arc::new(EdgeProps::new());
2431        self.engine = RuleEngine::new();
2432        self.view_store = ViewStore::new();
2433        self.fulltext = Arc::new(FulltextIndex::new());
2434        self.prop_index = PropertyIndex::new();
2435        // Cleared like every other declaration: a reload replays the store's own
2436        // WAL, and the opt-in comes back from it or not at all.
2437        self.multiplicity = false;
2438        self.commit_seq = 0;
2439        self.commit_times = core_storage::commit_times::CommitTimes::default();
2440        self.commit_times_poisoned = false;
2441        self.fulltext_rebuild_follows = false;
2442        self.commit_time_override = None;
2443        self.roles = Some(vec![]);
2444        // A fresh cache, not a cleared one: any reader snapshot still holding
2445        // the old `Arc` keeps it to itself, so nothing it memoised against the
2446        // pre-reload store can be read back through this handle.
2447        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2448        // The same move for memos this handle does not own. `commit_seq` is
2449        // zeroed just above and reseeded from `max(last_change)`, which a
2450        // delete-only commit leaves where it was — so a reload can land back on
2451        // a sequence a caller's `Scope` already cached a mask at. A new id is
2452        // what makes that entry stop matching.
2453        self.store_id = crate::mask::StoreId::next();
2454        self.total_wal_commits = 0;
2455        self.base = None;
2456        self.fold_overlay = None;
2457        self.delta_tail = Vec::new();
2458        self.commits_since_fold = 0;
2459        self.deferred_events = Vec::new();
2460        self.v8_sections_loaded
2461            .store(false, std::sync::atomic::Ordering::Release);
2462        self.last_change = HashMap::new();
2463        self.wal_horizon_floor = 0;
2464        self.archive_genesis_chain = false;
2465        // Re-derived by `load_from_disk` from the store it is about to read.
2466        self.snapshot_preserved_history = false;
2467        self.pending_write_authz = None;
2468        self.wal_consumed = 0;
2469        self.wal_frames_written = 0;
2470        self.snapshot_ident = None;
2471    }
2472
2473    /// Load the snapshot base and replay the WAL into an empty handle — the
2474    /// whole of what opening a store does after the struct exists.
2475    ///
2476    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2477    /// rebuild a handle in place, without ownership of `F`, when another
2478    /// process replaces the snapshot underneath it.
2479    ///
2480    /// `origin` decides whether the two repair writes this function can make
2481    /// are appropriate; see [`LoadOrigin`].
2482    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2483        // Both writes below are crash recovery, and only an open is entitled to
2484        // perform them. A read-only handle promises to touch nothing, and a
2485        // reload driven by `refresh` is looking at a store another process is
2486        // actively writing: what looks like a torn tail there is a peer
2487        // mid-append, and what looks like an orphaned archive may be one that
2488        // peer is about to reference.
2489        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2490        let repair_wal = self.open_opts.repair_wal && may_repair;
2491        let db = self;
2492        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2493        db.archive_genesis_chain = db.fs.has_genesis_marker();
2494        // Opening cleanup: remove orphaned archives — archives whose frames all
2495        // fall below the horizon floor.  Orphans arise when a crash interrupted
2496        // the retention-prune sequence after the floor was written but before
2497        // all surplus archives were deleted.  Safe to delete: floor already
2498        // accounts for their frames.
2499        if may_repair {
2500            db.cleanup_orphaned_archives()?;
2501        }
2502        let _t0 = std::time::Instant::now();
2503        // Peek 6 bytes to determine snapshot version without reading the full
2504        // file. For RealFs this is a true partial read (O(1)); for SimFs the
2505        // default impl reads all bytes and truncates (still correct).
2506        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2507        // V8, V9 and V10 share the mmap-able container; V9 only adds section 12
2508        // and V10 adds nothing but its version stamp. A version outside that set
2509        // falls through to the full-read path below, where `snapshot::decode`
2510        // either handles it (V5–V7) or refuses it by name — which is what stops
2511        // an older binary before it reaches the WAL.
2512        let is_v8 = snap_header.len() >= 6
2513            && &snap_header[0..4] == b"GDB1"
2514            && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
2515                snap_header[4],
2516                snap_header[5],
2517            ]));
2518        // The version this store is stamped with, or `None` when it has never
2519        // been snapshotted. Read from the same six bytes, with no second read.
2520        let snapshot_version = if snap_header.len() >= 6 && &snap_header[0..4] == b"GDB1" {
2521            Some(u16::from_le_bytes([snap_header[4], snap_header[5]]))
2522        } else {
2523            None
2524        };
2525        // No snapshot means no snapshot has ever truncated the WAL, so this
2526        // handle can prove the history is whole. Once a snapshot exists that
2527        // this handle did not take, it cannot: see `snapshot_preserved_history`.
2528        db.snapshot_preserved_history = snap_header.is_empty();
2529        if is_v8 {
2530            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2531            // No 2.4GB heap Vec is allocated on RealFs.
2532            let mapped = Arc::new(
2533                if let Some(snap_path) = db.fs.snapshot_path() {
2534                    core_storage::v8::MappedBase::map(&snap_path)
2535                } else {
2536                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2537                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
2538                }
2539                .map_err(|e| GraphError::Corrupt {
2540                    detail: format!("v8: mmap open: {e:?}"),
2541                })?,
2542            );
2543            db.restore_v8_base(Arc::clone(&mapped))?;
2544            trace_open!("restore_v8_base", _t0);
2545            db.base = Some(mapped);
2546            trace_open!("base assigned", _t0);
2547            // Only a snapshot from a writer that could leave a deleted node's
2548            // edges behind is searched for them.
2549            if !db.base.as_ref().is_some_and(|base| base.edges_swept()) {
2550                db.drop_edges_of_retired_nodes();
2551            }
2552        } else if !snap_header.is_empty() {
2553            // Legacy V5-V7: full read required for decode.
2554            let snap_bytes = db.fs.read(FileId::Snapshot)?;
2555            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2556                db.restore_snapshot_state(state)?;
2557            }
2558        }
2559        // else: snap_header is empty = no snapshot file, fresh store.
2560        //
2561        // Seed commit_seq from the highest seq persisted in last_change so that
2562        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2563        // already stored in the snapshot.  Without this, a db with one snapshot
2564        // commit would save last_change["a"]=1, then on reopen the first WAL
2565        // frame would replay at seq=1 again — colliding and making WAL-tail
2566        // mutations indistinguishable from the snapshot baseline.
2567        //
2568        // Safety invariant (seq-recycling):
2569        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
2570        //   last_change because they belonged to a previous db lifetime — a new
2571        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
2572        //   CAS precondition can carry a recycled seq as its `expected` value
2573        //   and accidentally match a live node's last_change entry.
2574        //
2575        // `expected:0` on a deleted-then-reinserted node:
2576        //   After deletion, last_changed() returns None; callers that call
2577        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2578        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
2579        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
2580        //   a CasConflict would be a caller that invented expected=0 without ever
2581        //   calling last_changed() — unreachable via the documented API contract.
2582        if let Some(&max_seq) = db.last_change.values().max() {
2583            db.commit_seq = db.commit_seq.max(max_seq);
2584        }
2585        let bytes = db.fs.read(FileId::Wal)?;
2586        let (records, valid_len) = decode_all(&bytes);
2587        // The valid prefix is replayed either way; `repair_wal` only decides
2588        // whether the truncation is written back. A reader that races a live
2589        // appender must not persist a truncation the writer never asked for.
2590        if valid_len < bytes.len() && repair_wal {
2591            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2592        }
2593        // WAL-present path: build indexes eagerly BEFORE replay so that the
2594        // first replayed record does not trigger the lazy-init guard (which
2595        // would call reindex_all_load_state on an empty graph, defeating the
2596        // point of restoring IVF/HNSW blobs from the snapshot).
2597        if !records.is_empty() {
2598            db.ensure_v8_base_sections_loaded();
2599            trace_open!("lazy sections loaded (WAL path)", _t0);
2600        }
2601        // Scoped to this call: `refresh()` also replays frames and is *not*
2602        // followed by a rebuild, so its backfill must still run.
2603        db.fulltext_rebuild_follows = true;
2604        // Every decoded frame occupies an index, replayed or not: markers
2605        // are state no-ops but they are not index no-ops.
2606        let decoded_frames = records.len() as u64;
2607        // A snapshot that kept its WAL says how much of it it already holds.
2608        // Those frames are counted, and not applied a second time.
2609        let held = db
2610            .base
2611            .as_ref()
2612            .and_then(|base| base.wal_mark())
2613            .filter(|mark| mark.matches(&bytes))
2614            .map(|mark| (mark.len as usize, decode_all(&bytes[..mark.len as usize])))
2615            // The mark was taken at the end of a whole WAL, so it ends on a
2616            // frame boundary; one that does not is not this WAL's.
2617            .filter(|(len, (_, consumed))| consumed == len)
2618            .map_or(0, |(_, (held, _))| held.len());
2619        let replayed = db.apply_frames_over(records, held);
2620        db.fulltext_rebuild_follows = false;
2621        let replayed = replayed?;
2622        // ── The multiplicity declaration, recovered from the stamp ───────────
2623        //
2624        // The opt-in is re-emitted into every baseline WAL a snapshot writes, so
2625        // ordinarily the replay above has already found it. But
2626        // `snapshot_with(archive_wal)` renames the live WAL away and writes its
2627        // replacement afterwards, and between those two points the store holds
2628        // no live declaration at all. A crash there — or a single `Err` from any
2629        // call in between — used to opt the store back out on the next open
2630        // (defect #22): it would stop counting and write a **V9** snapshot while
2631        // the archives still carried discriminant 23, which is the exact state
2632        // the V10 stamp exists to prevent.
2633        //
2634        // The V10 stamp is what carries the conclusion. The archive clause is a
2635        // scope restriction, not a second proof — an earlier version of this
2636        // comment, and defect #22, claimed otherwise, and defect #33 corrects
2637        // it. Taking the two in order:
2638        //
2639        // **The stamp.** `snapshot_with` stamps the snapshot from
2640        // `self.multiplicity` *before* it touches the WAL, and nothing rewrites
2641        // a V10 snapshot at V9 while the store believes it is opted in. So a
2642        // V10 stamp says this store reached `enable_multiplicity` far enough to
2643        // write the snapshot — and, decisively, that every older binary already
2644        // refuses this store by name. Opting in here can cost such a reader
2645        // nothing it was not already being told.
2646        //
2647        // **What the archive clause does not prove.** It is *not* evidence that
2648        // the archive was taken while the store was opted in. A store can
2649        // archive at V9 and opt in afterwards, leaving a V10 snapshot standing
2650        // beside an archive whose WAL carries no declaration at all — see
2651        // `a_failed_opt_in_beside_an_archive_comes_back_opted_in`. The inference
2652        // held in the success case by coincidence, not by construction.
2653        //
2654        // **What it does buy: scope.** Without it the recovery would also fire
2655        // on a store that reached the V10 snapshot write and then failed with no
2656        // archive in sight. That store must stay opted out, and can: no WAL was
2657        // renamed away, nothing carries discriminant 23, and its next snapshot
2658        // rewrites at V9, which puts it back within reach of every older reader.
2659        // An archive is the marker for the one state that is not recoverable
2660        // that way — a WAL renamed away that may hold the only copy of the
2661        // declaration. `no_crash_leaves_discriminant_23_unguarded` pins that
2662        // line: it sweeps a workload with no archives at all and refuses a
2663        // V10-implies-enabled rule.
2664        //
2665        // **The invariant, whichever way the clause goes:** the recovery never
2666        // opts in a store whose snapshot is not V10. A V9 store has made no
2667        // promise to an older reader, so opting it in would start writing
2668        // discriminant 23 behind a stamp that does not guard it. Pinned by
2669        // `the_recovery_never_opts_in_a_store_whose_snapshot_is_not_v10` and
2670        // `the_recovery_does_not_opt_a_store_in_by_itself`.
2671        //
2672        // What this recovery cannot do is make the opt-in atomic; it is not,
2673        // and `enable_multiplicity` says so. See defects #32-#34.
2674        if !db.multiplicity
2675            && snapshot_version == Some(core_storage::snapshot::VERSION_10)
2676            && !db.fs.list_archives()?.is_empty()
2677        {
2678            db.multiplicity = true;
2679        }
2680        // The cursor sits at the end of the valid prefix, not the end of the
2681        // file: a torn or still-being-written tail is unconsumed by definition
2682        // and stays visible to `is_stale` until it decodes.
2683        db.wal_consumed = valid_len as u64;
2684        // The frame cursor counts the same sequence `all_frames` returns:
2685        // surviving archives first, then the live WAL, offset by the floor.
2686        // Counting the archives separately rather than calling
2687        // `wal_total_commits` keeps the live WAL from being decoded twice on
2688        // every open, and costs nothing on a store that has never archived.
2689        db.wal_frames_written = db.wal_horizon_floor + db.archive_frame_count()? + decoded_frames;
2690        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2691        trace_open!("wal replay done", _t0);
2692        // Rebuild view values after WAL replay only when there is no V8 base.
2693        // With a V8 base, view values are correct in the snapshot and are updated
2694        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2695        // A full rebuild would read overlay-only props (empty after restore_v8_base)
2696        // and overwrite correct base values with wrong results (e.g. NeighborAgg
2697        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2698        // base value).
2699        if db.base.is_none() {
2700            let topo_view = TopologyView::owned(&db.topo);
2701            db.view_store.rebuild_all(
2702                Arc::make_mut(&mut db.props),
2703                &topo_view,
2704                &db.ids,
2705                &db.syms,
2706                &db.labels,
2707            );
2708        }
2709        // Rebuild full-text index after WAL replay.  Corrects drift from
2710        // per-record incremental apply during replay.
2711        Arc::make_mut(&mut db.fulltext).rebuild_all(
2712            &db.ids,
2713            &db.labels,
2714            &db.syms,
2715            build_props_view(&db.props, &db.base),
2716        );
2717        db.prop_index.rebuild_all(
2718            &db.ids,
2719            &db.labels,
2720            &db.syms,
2721            build_props_view(&db.props, &db.base),
2722        );
2723        // Namespaces: one pass over the `ns` column, after the snapshot is
2724        // restored and the WAL replayed. Replay maintains `node_ns` record by
2725        // record as well; this pass is what makes a snapshot-only open right,
2726        // and it reads nothing on a store with no `ns` column.
2727        db.rebuild_node_ns();
2728        // A mid-build snapshot's HNSW blob carries `complete == false`.
2729        // Register it so `serve`'s ticker sees work without waiting for a write.
2730        db.register_outstanding_index_builds();
2731        // Load roles sidecar. Missing file = no roles (Some(vec![])).
2732        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2733        db.roles = Self::load_roles_from_fs(&db.fs)?;
2734        // The time sidecar. Absent is the normal case for any store written
2735        // before v0.6.11 and is not an error; unreadable is recorded so date
2736        // queries can say "damaged" rather than "none recorded".
2737        db.load_commit_times_from_fs();
2738        // Capture the initial MVCC fold so reader() is ready immediately.
2739        db.fold_now();
2740        trace_open!("open_with complete", _t0);
2741        Ok(replayed)
2742    }
2743
2744    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2745    /// replay does — same `apply` calls, same per-frame delta drain, same
2746    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2747    /// edges appear identically whether a frame arrives at open, from a local
2748    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2749    ///
2750    /// Returns the number of frames applied.
2751    ///
2752    /// Deltas are drained and discarded per frame: replayed frames are already
2753    /// reflected on disk, so they are not news to a subscriber, and draining
2754    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2755    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2756        self.apply_frames_over(records, 0)
2757    }
2758
2759    /// [`Self::apply_frames`] over a base that already holds the first `held`
2760    /// records: the state a snapshot that kept its WAL was taken at. Those
2761    /// are numbered like any other frame and not applied, because applying a
2762    /// write to the state it produced is not always a no-op — an insert
2763    /// under a key since renamed or deleted makes a second node. A
2764    /// declaration among them is applied all the same: the pairs a store has
2765    /// declared are known from the WAL and nowhere else.
2766    fn apply_frames_over(&mut self, records: Vec<WalRecord>, held: usize) -> Result<usize> {
2767        if records.is_empty() {
2768            return Ok(0);
2769        }
2770        // Materialize any state retained in the mmap base before the first
2771        // frame lands, so a replayed record cannot trip the lazy-init guard and
2772        // rebuild indexes from an empty graph. Both calls are idempotent.
2773        self.ensure_v8_base_sections_loaded();
2774        self.engine.consume_retained_state_eager(
2775            &self.ids,
2776            &self.syms,
2777            &self.labels,
2778            build_props_view(&self.props, &self.base),
2779        );
2780        let applied = records.len();
2781        // A live commit turns on the engine's report of the edges its rules
2782        // add and retract, which is what keeps a view over a derived edge
2783        // type current. Replay has to do the same, or a store with a snapshot
2784        // — where nothing recomputes the views afterwards — reopens with the
2785        // counts the snapshot had.
2786        let emitted_before = self.engine.emit_deltas();
2787        for (i, rec) in records.into_iter().enumerate() {
2788            if !self.view_store.is_empty() && !self.engine.emit_deltas() {
2789                self.engine.set_emit_deltas(true);
2790            }
2791            if i >= held || is_declaration(&rec) {
2792                self.apply(&rec)?;
2793            }
2794            let _ = self.engine.drain_deltas();
2795            // Track commit_seq during replay so last_change entries are
2796            // consistent with the seqs assigned by log_then_apply_with on
2797            // subsequent live commits.  After N replayed frames, commit_seq=N;
2798            // live commits begin at N+1.
2799            self.commit_seq += 1;
2800            let replay_seq = self.commit_seq;
2801            self.update_last_change_from_rec(&rec, replay_seq);
2802        }
2803        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2804        // this assert catches the regression in debug builds immediately.
2805        debug_assert_eq!(
2806            self.engine.pending_delta_count(),
2807            0,
2808            "pending_deltas non-empty after replay — \
2809             per-frame drain must run inside the loop to keep memory O(1)"
2810        );
2811        // T2 note: the per-frame drain IS the suppression seam for replay.
2812        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2813        // replaying subscribers; the mechanism is already in place.
2814        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2815        self.engine.set_emit_deltas(emitted_before);
2816        Ok(applied)
2817    }
2818
2819    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2820    //
2821    // mushroomdb is many-readers / one-writer across processes. Writers take an
2822    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2823    // Every handle tracks how much of the WAL it has consumed, so it can pick
2824    // up another process's commits by decoding only the new tail rather than
2825    // reopening. See `docs/site/concurrency.md`.
2826
2827    /// Whether the store on disk has moved ahead of (or out from under) this
2828    /// handle's in-memory state.
2829    ///
2830    /// True when the WAL's length differs from this handle's cursor — another
2831    /// process committed, or is mid-append — or when the snapshot file's
2832    /// identity changed. Costs two metadata lookups and reads no file contents,
2833    /// so it is cheap enough for a read path to call.
2834    ///
2835    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2836    /// pinned to one commit and later commits are deliberately invisible to it.
2837    pub fn is_stale(&self) -> Result<bool> {
2838        if self.pinned {
2839            return Ok(false);
2840        }
2841        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2842            return Ok(true);
2843        }
2844        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2845    }
2846
2847    /// Bring this handle up to date with every commit other processes have made,
2848    /// and return how many frames were applied.
2849    ///
2850    /// The WAL tail is decoded from this handle's cursor and applied through the
2851    /// same path the open replay uses, so rules fire and derived edges appear
2852    /// exactly as they would on a fresh open. Interners, id maps and indexes
2853    /// stay valid for the same reason.
2854    ///
2855    /// A frame another process is still writing is left alone: a trailing
2856    /// partial frame is a wait, not a corruption, and the handle stays stale
2857    /// until that frame is complete. Nothing is written to disk, so a read-only
2858    /// handle can refresh freely.
2859    ///
2860    /// When the snapshot file's identity changed, or the WAL is shorter than
2861    /// this handle's cursor, the WAL no longer continues our state — another
2862    /// process snapshotted or archived. The handle is then rebuilt from disk
2863    /// with the options it was opened with, and the return value is the number
2864    /// of frames in the new WAL.
2865    ///
2866    /// Returns 0 for an as-of view, which never follows later commits.
2867    ///
2868    /// # Errors
2869    ///
2870    /// An error here leaves the handle **degraded**: it got partway through
2871    /// applying the tail, or partway through a reload, so its in-memory state
2872    /// no longer matches any point on disk. Further mutations are refused and
2873    /// the handle must be reopened. Nothing on disk was damaged — the store
2874    /// itself is fine, and a fresh open recovers it.
2875    pub fn refresh(&mut self) -> Result<u64> {
2876        if self.pinned {
2877            return Ok(0);
2878        }
2879        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
2880        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
2881        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
2882            // The WAL no longer continues our state: rebuild from disk. State
2883            // is cleared first, so a failed load leaves an empty handle — mark
2884            // it degraded rather than let a caller read an empty graph as if
2885            // it were the store's contents.
2886            self.reset_for_reload();
2887            return match self.load_from_disk(LoadOrigin::Reload) {
2888                Ok(frames) => Ok(frames as u64),
2889                Err(e) => {
2890                    self.degraded = true;
2891                    Err(e)
2892                }
2893            };
2894        }
2895        if wal_len == self.wal_consumed {
2896            return Ok(0);
2897        }
2898        let tail = self
2899            .fs
2900            .read_range(FileId::Wal, self.wal_consumed)
2901            .map_err(GraphError::Io)?;
2902        let (records, valid_len) = decode_all(&tail);
2903        let decoded_frames = records.len() as u64;
2904        let applied = match self.apply_frames(records) {
2905            Ok(n) => n,
2906            Err(e) => {
2907                // Some frames landed and some did not, and the cursor cannot
2908                // say how many. Advancing it would skip the rest; leaving it
2909                // would replay what already applied. Neither is recoverable in
2910                // place, so refuse further writes and require a reopen.
2911                self.degraded = true;
2912                return Err(e);
2913            }
2914        };
2915        // Advance by the bytes actually decoded, never by the file length: an
2916        // incomplete trailing frame stays unconsumed for the next refresh.
2917        self.wal_consumed += valid_len as u64;
2918        self.wal_frames_written += decoded_frames;
2919        // The peer that wrote those frames also stamped them. Absorbing the
2920        // frames without the stamps leaves this handle resolving dates from a
2921        // prefix of the store's history, and — while our own map is still
2922        // empty — one commit away from rewriting the peer's file out of
2923        // existence (`first` below decides on the map, and the map is what we
2924        // just brought up to date).
2925        self.load_commit_times_from_fs();
2926        if applied > 0 {
2927            // Peer commits must reach `reader()` snapshots taken from here on.
2928            // A full fold is what open does; refresh does not build per-commit
2929            // deltas, so there is nothing cheaper that stays correct.
2930            self.fold_now();
2931        }
2932        Ok(applied as u64)
2933    }
2934
2935    /// Byte offset of the WAL prefix this handle has applied.
2936    ///
2937    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
2938    #[doc(hidden)]
2939    pub fn wal_consumed(&self) -> u64 {
2940        self.wal_consumed
2941    }
2942
2943    /// Rewind the WAL cursor after the group-commit drain thread truncated a
2944    /// failed group off the tail, so the cursor still describes the file.
2945    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
2946        self.wal_consumed = len;
2947    }
2948
2949    /// One non-blocking attempt at the cross-process write lock.
2950    ///
2951    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
2952    /// in-process write guard. That ordering is what keeps a busy peer in
2953    /// another process from stalling this process's readers.
2954    ///
2955    /// A handle that owns the lock for its lifetime always succeeds.
2956    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
2957        if self.holds_lifetime_lock {
2958            return Ok(true);
2959        }
2960        self.fs.try_lock_exclusive().map_err(GraphError::Io)
2961    }
2962
2963    /// Poll for the cross-process write lock until `wait` elapses.
2964    ///
2965    /// One attempt is always made, so a zero wait is a single try. Returns
2966    /// `false` when the lock is still held elsewhere at the deadline; nothing
2967    /// has been written and retrying later is safe.
2968    ///
2969    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
2970    /// handle outright. [`SharedDb`](crate::SharedDb) polls
2971    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
2972    /// that it holds no in-process guard while it waits.
2973    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
2974        let deadline = std::time::Instant::now() + wait;
2975        loop {
2976            if self.try_cross_process_lock()? {
2977                return Ok(true);
2978            }
2979            let now = std::time::Instant::now();
2980            if now >= deadline {
2981                return Ok(false);
2982            }
2983            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
2984        }
2985    }
2986
2987    /// Open a cross-process write scope, given the outcome of an already-made
2988    /// lock attempt.
2989    ///
2990    /// The caller polls for the lock first — outside any in-process guard — and
2991    /// passes what it got. On success this refreshes, so the writes about to
2992    /// happen land on top of every other process's commits. On failure the
2993    /// handle refuses WAL-appending mutations and `snapshot()` with
2994    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
2995    /// closes the scope, so a caller holding a guard cannot write behind
2996    /// another process's back.
2997    ///
2998    /// A handle that already owns the lock for its lifetime skips the refresh:
2999    /// no other process can have written, so there is nothing to pick up.
3000    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
3001        self.lock_denied = !acquired;
3002        if !acquired || self.holds_lifetime_lock {
3003            return Ok(());
3004        }
3005        if let Err(e) = self.refresh() {
3006            // Do not hold a lock we cannot use: release it and let the caller
3007            // see the underlying failure.
3008            let _ = self.fs.unlock();
3009            self.lock_denied = true;
3010            return Err(e);
3011        }
3012        Ok(())
3013    }
3014
3015    /// Close a cross-process write scope opened by
3016    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
3017    /// clear the Busy latch. Safe to call when the lock was never taken.
3018    pub(crate) fn end_write_lock(&mut self) {
3019        self.lock_denied = false;
3020        if !self.holds_lifetime_lock {
3021            // Releasing a lock we do not hold is a no-op; a failure to release
3022            // is reported by the OS closing the descriptor at handle drop.
3023            let _ = self.fs.unlock();
3024        }
3025    }
3026
3027    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
3028    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
3029    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
3030    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
3031    /// Restore all persisted state from a decoded snapshot. Shared by
3032    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
3033    fn restore_snapshot_state(
3034        &mut self,
3035        state: core_storage::snapshot::SnapshotState,
3036    ) -> Result<()> {
3037        self.ids = Arc::new(state.ids);
3038        self.syms = Arc::new(state.syms);
3039        self.topo = Arc::new(state.topo);
3040        self.props = Arc::new(state.props);
3041        self.labels = Arc::new(state.labels);
3042        self.edge_props = Arc::new(state.edge_props);
3043        // Cross-section label integrity for V5/V7 snapshots: same invariants as
3044        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
3045        // out-of-range sym ids, or a sentinel label on a live node would otherwise
3046        // open successfully and panic later in `NodeRef::label()` or
3047        // `neighborhood_masked()`.  Catching it here turns those into typed
3048        // `GraphError::Corrupt` at open time.
3049        {
3050            let ids_len = self.ids.len();
3051            if self.labels.len() != ids_len {
3052                return Err(GraphError::Corrupt {
3053                    detail: format!(
3054                        "snapshot: labels vec has {} entries but id table has {} total slots",
3055                        self.labels.len(),
3056                        ids_len,
3057                    ),
3058                });
3059            }
3060            let syms_len = self.syms.len() as u32;
3061            for (i, &sym) in self.labels.iter().enumerate() {
3062                let is_tombstoned = self.ids.is_tombstoned(i as u32);
3063                if sym == u32::MAX {
3064                    if !is_tombstoned {
3065                        return Err(GraphError::Corrupt {
3066                            detail: format!(
3067                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
3068                            ),
3069                        });
3070                    }
3071                } else if sym >= syms_len {
3072                    return Err(GraphError::Corrupt {
3073                        detail: format!(
3074                            "snapshot: label at id slot {i} references sym {sym} \
3075                             which is out of interner range ({syms_len})"
3076                        ),
3077                    });
3078                }
3079            }
3080        }
3081        let defs: Vec<RuleDef> = state
3082            .rule_defs
3083            .iter()
3084            .map(|b| {
3085                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3086                    detail: format!("snapshot rule_def deserialize: {e}"),
3087                })
3088            })
3089            .collect::<Result<Vec<_>>>()?;
3090        self.engine =
3091            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
3092        // Candidate indexes are rebuilt lazily on the first mutation (see
3093        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
3094        // snapshot are retained without deserializing so that:
3095        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
3096        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
3097        //     lazy-init guard which calls reindex_all_load_state (the scan
3098        //     skips the HNSW build for every side the blob supplies).
3099        //   - WAL-present: open_with calls consume_retained_state_eager before
3100        //     replay so HNSW/IVF are live before any record fires the hooks.
3101        let ivf_bytes = if state.ivf_state.is_empty() {
3102            Vec::new()
3103        } else {
3104            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
3105        };
3106        // Store blobs without eagerly deserializing them.
3107        // `self.ids` is the snapshot's id table at this point — WAL replay has
3108        // not run — so its length is the line an interrupted build is detected
3109        // against.
3110        let snapshot_ids = self.ids.len() as u32;
3111        self.engine
3112            .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
3113        // Restore view defs from snapshot (V5).
3114        // The ColumnStore already contains view values from the snapshot;
3115        // use restore_view (no collision check, no backfill) so the store
3116        // is aware of the definitions.  rebuild_all runs after WAL replay.
3117        for def_bytes in &state.view_defs {
3118            let def: ViewDef =
3119                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3120                    detail: format!("snapshot view_def deserialize: {e}"),
3121                })?;
3122            self.view_store
3123                .restore_view(def)
3124                .map_err(|e| GraphError::Corrupt {
3125                    detail: format!("snapshot view restore: {e}"),
3126                })?;
3127        }
3128        Ok(())
3129    }
3130
3131    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
3132    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
3133    ///
3134    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
3135    /// deserialization and view rebuild have access to all column data.
3136    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
3137        self.ids = Arc::new(archived_to_idmap(mapped.ids().map_err(|e| {
3138            GraphError::Corrupt {
3139                detail: format!("v8: ids section: {e:?}"),
3140            }
3141        })?));
3142        self.syms = Arc::new(archived_to_interner(mapped.syms().map_err(|e| {
3143            GraphError::Corrupt {
3144                detail: format!("v8: syms section: {e:?}"),
3145            }
3146        })?));
3147
3148        // C1: self.props is left as an empty overlay. Column reads go through
3149        // props_view() (ColumnsView::with_base), which consults the archived base
3150        // section zero-copy. This avoids the O(columns) heap copy at every open.
3151
3152        // self.topo deliberately left as Topology::new() — overlay path.
3153
3154        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
3155            detail: format!("v8: meta section: {e:?}"),
3156        })?)
3157        .map_err(|e| GraphError::Corrupt {
3158            detail: format!("v8: meta decode: {e:?}"),
3159        })?;
3160        self.labels = Arc::new(meta.labels);
3161        // Cross-section label integrity: labels must cover every id slot (live
3162        // and tombstoned), every non-sentinel sym must be within the interner's
3163        // bound, and no live (non-tombstoned) node may carry the u32::MAX
3164        // sentinel label.  Without this check, a crafted snapshot where the META
3165        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
3166        // sym ids, or a sentinel label on a live node, would open successfully
3167        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
3168        // related read paths.  Catching the inconsistency here converts those
3169        // panics into typed `GraphError::Corrupt` at open time.
3170        {
3171            let ids_len = self.ids.len();
3172            if self.labels.len() != ids_len {
3173                return Err(GraphError::Corrupt {
3174                    detail: format!(
3175                        "v8: labels section has {} entries but id table has {} total slots",
3176                        self.labels.len(),
3177                        ids_len,
3178                    ),
3179                });
3180            }
3181            let syms_len = self.syms.len() as u32;
3182            for (i, &sym) in self.labels.iter().enumerate() {
3183                let is_tombstoned = self.ids.is_tombstoned(i as u32);
3184                if sym == u32::MAX {
3185                    // Sentinel is only valid for tombstoned slots.
3186                    if !is_tombstoned {
3187                        return Err(GraphError::Corrupt {
3188                            detail: format!(
3189                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
3190                            ),
3191                        });
3192                    }
3193                } else if sym >= syms_len {
3194                    return Err(GraphError::Corrupt {
3195                        detail: format!(
3196                            "v8: label at id slot {i} references sym {sym} \
3197                             which is out of interner range ({syms_len})"
3198                        ),
3199                    });
3200                }
3201            }
3202        }
3203        // C3: self.edge_props stays as an empty overlay.  Reads go through
3204        // edge_props_view() which consults the mmap'd base section zero-copy
3205        // via EdgePropsView::with_base.  No heap decode at open time.
3206
3207        // Restore rule engine.
3208        let (rule_def_bytes, rule_tripped, rule_fires) =
3209            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
3210                GraphError::Corrupt {
3211                    detail: format!("v8: rules_meta section: {e:?}"),
3212                }
3213            })?);
3214        let defs: Vec<RuleDef> = rule_def_bytes
3215            .iter()
3216            .map(|b| {
3217                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3218                    detail: format!("v8: rule_def deserialize: {e}"),
3219                })
3220            })
3221            .collect::<Result<Vec<_>>>()?;
3222        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
3223        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
3224        // `ensure_v8_base_sections_loaded` reads them on first use from
3225        // `self.base` (set by the caller immediately after this returns).
3226        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
3227
3228        // Restore view definitions.
3229        let view_defs =
3230            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
3231                detail: format!("v8: views section: {e:?}"),
3232            })?);
3233        for def_bytes in &view_defs {
3234            let def: ViewDef =
3235                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3236                    detail: format!("v8: view_def deserialize: {e}"),
3237                })?;
3238            self.view_store
3239                .restore_view(def)
3240                .map_err(|e| GraphError::Corrupt {
3241                    detail: format!("v8: view restore: {e}"),
3242                })?;
3243        }
3244        // Load the last-change map from section 11 (small section; load eagerly).
3245        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
3246        // in that case and `decode_last_change_bytes` returns an empty map.
3247        let last_change_raw = mapped
3248            .last_change_bytes()
3249            .map_err(|e| GraphError::Corrupt {
3250                detail: format!("v8: last_change section: {e:?}"),
3251            })?;
3252        self.last_change = decode_last_change_bytes(last_change_raw);
3253
3254        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
3255        // the file.  Pure bounds check — no bytes read, no page faults triggered.
3256        // Catches truncated snapshots at open time before the lazy deferred reads.
3257        mapped.validate_section_bounds().map_err(|e| match e {
3258            GraphError::Corrupt { detail } => GraphError::Corrupt {
3259                detail: format!("v8: section bounds: {detail}"),
3260            },
3261            other => other,
3262        })?;
3263        Ok(())
3264    }
3265
3266    /// Read provenance, HNSW, and IVF sections from the mmap base into the
3267    /// engine's retained fields on first call.  Subsequent calls are a no-op
3268    /// (AtomicBool fast-path).
3269    ///
3270    /// Must be called before any code path that reads or mutates engine
3271    /// provenance, HNSW, or IVF state:
3272    /// - WAL replay (before `consume_retained_state_eager`)
3273    /// - First mutation (`log_then_apply_with`)
3274    /// - Read-only paths (`stats`, `explain`, `node_edges`)
3275    /// - Snapshot (`snapshot_with`)
3276    ///
3277    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
3278    fn ensure_v8_base_sections_loaded(&self) {
3279        use std::sync::atomic::Ordering;
3280        if self.v8_sections_loaded.load(Ordering::Acquire) {
3281            return;
3282        }
3283        let _guard = self
3284            .v8_sections_mutex
3285            .lock()
3286            .expect("v8 sections mutex poisoned");
3287        if self.v8_sections_loaded.load(Ordering::Acquire) {
3288            return; // another caller populated while we waited
3289        }
3290        let _t = std::time::Instant::now();
3291        if let Some(base) = &self.base {
3292            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
3293            // Bounds are already validated at open time (restore_v8_base →
3294            // validate_section_bounds) — unreachable post-validate_section_bounds;
3295            // unwrap_or_default is a safety belt against impossible errors.
3296            let prov_bytes = base
3297                .provenance_raw_bytes()
3298                .map(|b| b.to_vec())
3299                .unwrap_or_default();
3300            self.engine.store_provenance_bytes(prov_bytes);
3301            // HNSW: decode rkyv blobs into owned map.
3302            let hnsw_state = base
3303                .hnsw_section()
3304                .map(archived_hnsw_to_owned)
3305                .unwrap_or_default();
3306            // IVF: raw bincode bytes; deserialized on first mutation/query.
3307            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
3308            // Called before WAL replay on a WAL-present open (`open_with`) and
3309            // before any write on a clean one, so this is the snapshot's count.
3310            let snapshot_ids = self.ids.len() as u32;
3311            self.engine
3312                .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
3313        }
3314        self.v8_sections_loaded.store(true, Ordering::Release);
3315        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
3316            eprintln!(
3317                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
3318                _t.elapsed()
3319            );
3320        }
3321    }
3322
3323    /// Return a `TopologyView` that merges the mmap'd base (when present) with
3324    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
3325    /// the full merged topology without going through `self.view()`.
3326    fn topo_view(&self) -> TopologyView<'_> {
3327        match self.base {
3328            None => TopologyView::owned(&self.topo),
3329            Some(ref base) => {
3330                // SAFETY: base lives as long as self; section bounds validated at open.
3331                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
3332                let archived = base
3333                    .topology()
3334                    .expect("base topology section bounds validated at open");
3335                TopologyView::with_base(&self.topo, archived)
3336            }
3337        }
3338    }
3339
3340    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
3341    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
3342    /// overlay first, then fall through to the archived base section zero-copy.
3343    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
3344        match self.base {
3345            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
3346            Some(ref base) => {
3347                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
3348                let archived = base
3349                    .columns()
3350                    .expect("base columns section bounds validated at open");
3351                core_storage::v8::seam::ColumnsView::with_base_cached(
3352                    &self.props,
3353                    archived,
3354                    base.mixed_cache(),
3355                )
3356                .with_shared_strings(base_string_table(base))
3357            }
3358        }
3359    }
3360
3361    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
3362    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
3363    ///
3364    /// Reads consult the overlay first (for post-snapshot mutations), then fall
3365    /// through to the archived base section zero-copy.  Tombstones in the
3366    /// overlay mask deleted-from-base entries.
3367    fn edge_props_view(&self) -> EdgePropsView<'_> {
3368        match self.base {
3369            None => EdgePropsView::owned(&self.edge_props),
3370            Some(ref base) => {
3371                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
3372                let archived = base
3373                    .edge_props_section()
3374                    .expect("base edge_props section bounds validated at open");
3375                EdgePropsView::with_base(&self.edge_props, archived)
3376            }
3377        }
3378    }
3379
3380    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
3381        // An as-of view never writes and is pinned to one commit: it takes no
3382        // cross-process lock and does not follow later commits.
3383        let mut db = Self::new_empty(
3384            fs,
3385            OpenOptions {
3386                repair_wal: false,
3387                auto_migrate: false,
3388                read_only: true,
3389            },
3390        );
3391        db.pinned = true; // read_only is set after replay, but pinning is immediate
3392        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
3393        db.archive_genesis_chain = db.fs.has_genesis_marker();
3394        // Same orphaned-archive cleanup as open_with: floor was written first
3395        // during pruning, so a crash may have left stale archives below floor.
3396        db.cleanup_orphaned_archives()?;
3397        // Collect archive frames (oldest-first) and live WAL frames.
3398        // Archives represent pre-snapshot history; the snapshot captures the
3399        // cumulative state at the time of archiving.  Crash-window guarantee:
3400        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
3401        //   B: crash after rename, before new WAL → archive present, WAL
3402        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
3403        //   C: crash after new baseline WAL written → normal post-archive.
3404        let archive_ns = db.fs.list_archives()?;
3405        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
3406        for n in &archive_ns {
3407            let arc_bytes = db.fs.read_archive(*n)?;
3408            let (arc_frames, _) = decode_all(&arc_bytes);
3409            archive_frames_all.extend(arc_frames);
3410        }
3411        let total_archive_frames = archive_frames_all.len() as u64;
3412
3413        let live_bytes = db.fs.read(FileId::Wal)?;
3414        let (live_records, _valid_len) = decode_all(&live_bytes);
3415        let total_surviving = total_archive_frames + live_records.len() as u64;
3416        // Global total including any pruned history below the horizon floor.
3417        let total = db.wal_horizon_floor + total_surviving;
3418
3419        // Horizon and range check.
3420        if commit < db.wal_horizon_floor {
3421            return Err(GraphError::CommitOutOfRange {
3422                commit,
3423                total,
3424                floor: db.wal_horizon_floor,
3425            });
3426        }
3427        if commit >= total {
3428            return Err(GraphError::CommitOutOfRange {
3429                commit,
3430                total,
3431                floor: db.wal_horizon_floor,
3432            });
3433        }
3434
3435        // Local index into surviving frames (0 = first frame of oldest archive).
3436        let local = commit - db.wal_horizon_floor;
3437
3438        if local < total_archive_frames {
3439            // Target commit is in an archive.  Correct replay from empty state
3440            // is only possible when the archive chain is an uninterrupted
3441            // genesis chain (first archive taken from a fresh store, no prior
3442            // WAL truncation) and no archives have been pruned (floor == 0).
3443            //
3444            // If either condition is violated the prefix needed to reconstruct
3445            // the requested state is gone; refuse rather than return wrong data.
3446            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
3447                return Err(GraphError::CommitOutOfRange {
3448                    commit,
3449                    total,
3450                    floor: db.wal_horizon_floor,
3451                });
3452            }
3453            // Replay all archive frames up to and including the target commit
3454            // from an empty database state.  Archives must be replayed in order
3455            // so that dense-id intern tables are built up correctly.
3456            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
3457                db.apply(&rec)?;
3458                let _ = db.engine.drain_deltas();
3459            }
3460        } else {
3461            // Target commit is in the live WAL: load snapshot as base, then
3462            // replay the needed live WAL prefix.
3463            //
3464            // Base state: a truncating snapshot (wal_truncated=true) compacts
3465            // all pre-truncation / pre-archive commits.  Dense-id records in
3466            // the live WAL reference ids/interns that the snapshot provides.
3467            //
3468            // A `keep_wal` snapshot records `wal_truncated=false`: it is the
3469            // state at the head, not at the start of the WAL, so it is no base
3470            // for a prefix. That is right when the live WAL reaches the store's
3471            // first commit and can be replayed from nothing. It is wrong when
3472            // the WAL starts above that — the floor has moved, or archives sit
3473            // in front of it — because the snapshot that *was* its base has
3474            // been overwritten. Replaying the prefix over nothing then answers
3475            // with a graph missing everything older than the WAL. The base is
3476            // gone, so the read is refused (ledger row 82).
3477            let base_is_gone = db.wal_horizon_floor > 0 || total_archive_frames > 0;
3478            // Peek 6 bytes (same pattern as open_with).
3479            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
3480            let is_v8 = snap_header.len() >= 6
3481                && &snap_header[0..4] == b"GDB1"
3482                && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
3483                    snap_header[4],
3484                    snap_header[5],
3485                ]));
3486            if is_v8 {
3487                let state = if let Some(snap_path) = db.fs.snapshot_path() {
3488                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
3489                        GraphError::Corrupt {
3490                            detail: format!("v8: open_at mmap: {e:?}"),
3491                        }
3492                    })?;
3493                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
3494                } else {
3495                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
3496                    core_storage::snapshot::decode(&snap_bytes)?
3497                };
3498                if let Some(state) = state {
3499                    if state.wal_truncated {
3500                        db.restore_snapshot_state(state)?;
3501                        db.drop_edges_of_retired_nodes();
3502                    } else if base_is_gone {
3503                        return Err(GraphError::CommitOutOfRange {
3504                            commit,
3505                            total,
3506                            floor: db.wal_horizon_floor,
3507                        });
3508                    }
3509                }
3510            } else if !snap_header.is_empty() {
3511                let snap_bytes = db.fs.read(FileId::Snapshot)?;
3512                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
3513                    if state.wal_truncated {
3514                        db.restore_snapshot_state(state)?;
3515                        db.drop_edges_of_retired_nodes();
3516                    } else if base_is_gone {
3517                        return Err(GraphError::CommitOutOfRange {
3518                            commit,
3519                            total,
3520                            floor: db.wal_horizon_floor,
3521                        });
3522                    }
3523                }
3524            }
3525            // else: snap_header empty = no snapshot file.
3526            let live_local = local - total_archive_frames;
3527            for rec in live_records.into_iter().take((live_local + 1) as usize) {
3528                db.apply(&rec)?;
3529                let _ = db.engine.drain_deltas();
3530            }
3531        }
3532        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
3533        // post-loop assert in open_with.
3534        debug_assert_eq!(
3535            db.engine.pending_delta_count(),
3536            0,
3537            "pending_deltas non-empty after open_at replay — \
3538             per-frame drain must run inside the loop to keep memory O(1)"
3539        );
3540        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
3541                                          // Rebuild view values after WAL replay so derived-edge-driven views
3542                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
3543                                          // base), so topo_view is always owned.
3544        {
3545            let topo_view = TopologyView::owned(&db.topo);
3546            db.view_store.rebuild_all(
3547                Arc::make_mut(&mut db.props),
3548                &topo_view,
3549                &db.ids,
3550                &db.syms,
3551                &db.labels,
3552            );
3553        }
3554        // Rebuild full-text index for as-of view (mirrors open_with pattern).
3555        Arc::make_mut(&mut db.fulltext).rebuild_all(
3556            &db.ids,
3557            &db.labels,
3558            &db.syms,
3559            build_props_view(&db.props, &db.base),
3560        );
3561        db.prop_index.rebuild_all(
3562            &db.ids,
3563            &db.labels,
3564            &db.syms,
3565            build_props_view(&db.props, &db.base),
3566        );
3567        // Namespaces on the temporal handle, built by the same pass the live
3568        // open uses, so an as-of mask narrows by the namespaces of that commit.
3569        db.rebuild_node_ns();
3570        // Load roles sidecar (current roles, not point-in-time).
3571        db.roles = Self::load_roles_from_fs(&db.fs)?;
3572        db.read_only = true;
3573        db.total_wal_commits = total;
3574        // Capture initial fold so reader() is immediately usable.
3575        db.fold_now();
3576        Ok(db)
3577    }
3578
3579    /// Whether this instance is a read-only as-of view.
3580    pub fn is_read_only(&self) -> bool {
3581        self.read_only
3582    }
3583
3584    // ── MVCC epoch reader ─────────────────────────────────────────────────────
3585
3586    /// Clone the current overlay state into a new `FrozenOverlay` and reset
3587    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
3588    /// the end of `open_with` / `open_at_with` to prime the reader.
3589    fn fold_now(&mut self) {
3590        // Eight `Arc::clone`s — refcount bumps, O(1). This used to deep-copy the
3591        // whole overlay: `IdMap` alone is a `HashMap<String, u32>` plus a
3592        // `Vec<String>`, so every node key was copied twice, on every open,
3593        // after every snapshot, every FOLD_EVERY_K commits on the write path,
3594        // and — with no commit threshold — on every `refresh()` that applied a
3595        // peer commit. A refreshing reader now pays nothing for a fold.
3596        //
3597        // `props` and `topo` were already cheap for a different reason: on a
3598        // snapshotted store `restore_v8_base` leaves them as empty overlays over
3599        // the zero-copy mmap. `ids`, `syms` and `fulltext` were not, and that
3600        // inconsistency was the defect.
3601        let frozen = crate::reader::FrozenOverlay {
3602            ids: std::sync::Arc::clone(&self.ids),
3603            syms: std::sync::Arc::clone(&self.syms),
3604            topo: std::sync::Arc::clone(&self.topo),
3605            props: std::sync::Arc::clone(&self.props),
3606            labels: std::sync::Arc::clone(&self.labels),
3607            edge_props: std::sync::Arc::clone(&self.edge_props),
3608            roles: self.roles.clone().map(std::sync::Arc::new),
3609            fulltext: std::sync::Arc::clone(&self.fulltext),
3610        };
3611        self.fold_overlay = Some(Arc::new(frozen));
3612        self.delta_tail.clear();
3613        self.commits_since_fold = 0;
3614    }
3615
3616    /// Capture a lock-free reader snapshot of the current db state.
3617    ///
3618    /// The read lock is held only for the duration of this call (to clone a
3619    /// handful of `Arc` handles). Subsequent query operations run without any
3620    /// lock.
3621    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3622        crate::reader::ReaderSnapshot::new(
3623            self.fold_overlay
3624                .clone()
3625                .expect("fold_overlay is always Some after open_with; call reader() after open"),
3626            self.base.clone(),
3627            self.delta_tail.clone(),
3628            // The snapshot's effective state is exactly this handle's state at
3629            // this commit, so it shares the memo and its version key.
3630            self.commit_seq,
3631            Arc::clone(&self.role_masks),
3632        )
3633    }
3634
3635    /// Append a delta the reader cannot apply, so that a corrupt overlay is
3636    /// reachable from a test.
3637    ///
3638    /// Compiled only under `test-hooks`, which the server's dev-dependency on
3639    /// this crate turns on. One call permanently corrupts every
3640    /// [`ReaderSnapshot`](crate::reader::ReaderSnapshot) taken from the handle,
3641    /// so it must not be in the published surface: `#[doc(hidden)]` hides it
3642    /// from rustdoc and from nothing else. The feature gate — not
3643    /// `#[cfg(test)]` — because its only callers are in `crates/server/tests`,
3644    /// a different crate, exactly as `core_rules`'s index counters are.
3645    ///
3646    /// [`ReaderSnapshot::effective`](crate::reader::ReaderSnapshot) folds the
3647    /// delta tail into a clone of the frozen overlay and answers
3648    /// [`GraphError::Corrupt`] when a record will not apply. Nothing a caller
3649    /// can do produces that state — `apply_one`'s failures are disagreements
3650    /// between the tail and the fold it is applied to, which the write path
3651    /// cannot create — so the `Corrupt` arm of every scoped reader method was
3652    /// reachable only by inspection until this hook existed. An `Intern` record
3653    /// claiming an id the frozen interner will not hand back is the smallest
3654    /// such disagreement.
3655    ///
3656    /// Only the tail is touched. This handle's own state is untouched and
3657    /// `commit_seq` does not move, so a role mask already memoised at this
3658    /// version stays memoised — which is exactly the state in which the HTTP
3659    /// role branches reach a scoped read with a corrupt overlay under them.
3660    #[cfg(any(test, feature = "test-hooks"))]
3661    #[doc(hidden)]
3662    pub fn push_unapplyable_delta_for_test(&mut self) {
3663        self.delta_tail.push(Arc::new(crate::reader::CommitDelta {
3664            records: vec![WalRecord::Intern {
3665                id: u32::MAX,
3666                text: "delta-tail-corruption".into(),
3667            }],
3668            derived_inserts: Vec::new(),
3669            derived_deletes: Vec::new(),
3670        }));
3671    }
3672
3673    /// Total number of WAL commits at the time [`open_at`] was called.
3674    /// Returns 0 for normal (non-as-of) instances.
3675    pub fn total_wal_commits(&self) -> u64 {
3676        self.total_wal_commits
3677    }
3678
3679    /// Apply a record to in-memory state. Used by both live writes and replay,
3680    /// so replay is definitionally identical to the original execution.
3681    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3682        // Before the record mutates anything: a store restored from a snapshot
3683        // defers building its candidate indexes until the first write, and that
3684        // build is a full node scan. Left where it used to fire — inside the
3685        // engine hook, after `props.set` and the label assignment — the scan
3686        // read the half-applied record and took the in-flight node's vector for
3687        // one the snapshot should have carried, which read as an interrupted
3688        // vector-index build and cost a full `RebuildRule` on the first
3689        // embedded write after every reopen. Hoisted here the scan sees exactly
3690        // the persisted state; the record's own hook then files its vector
3691        // through the ordinary insert path a line later.
3692        self.populate_indexes_before_write();
3693        match rec {
3694            WalRecord::InsertNode { label, key, props } => {
3695                let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
3696                let sym = Arc::make_mut(&mut self.syms).intern(label);
3697                if self.labels.len() <= id as usize {
3698                    // gap slots are sentinels, never valid label symbols
3699                    Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
3700                }
3701                Arc::make_mut(&mut self.labels)[id as usize] = sym;
3702                let mut ns_name = NS_DEFAULT.to_string();
3703                for (field, value) in props {
3704                    if field == NS_PROP {
3705                        ns_name = namespace_of_value(Some(value)).to_string();
3706                    }
3707                    Arc::make_mut(&mut self.props).set(id, field, value.clone());
3708                }
3709                self.set_node_ns(id, &ns_name);
3710                // Initialize view values for the new node before the engine runs so
3711                // delta-based increments start from a known zero baseline.
3712                self.view_store.init_node_views(
3713                    id,
3714                    Arc::make_mut(&mut self.props),
3715                    &self.syms,
3716                    &self.labels,
3717                );
3718                // Fire rules for the newly inserted node.
3719                let cursor = self.engine.pending_delta_count();
3720                let mut eng = std::mem::take(&mut self.engine);
3721                {
3722                    let mut gm = make_graph_mut(
3723                        &self.ids,
3724                        Arc::make_mut(&mut self.syms),
3725                        &self.labels,
3726                        build_props_view(&self.props, &self.base),
3727                        Arc::make_mut(&mut self.topo),
3728                        &self.base,
3729                        Arc::make_mut(&mut self.edge_props),
3730                    );
3731                    eng.on_node_changed(id, None, &mut gm);
3732                }
3733                self.engine = eng;
3734                // Process derived-edge deltas for view maintenance.
3735                // Fast path: skip the O(delta_count) allocation when no views exist.
3736                if !self.view_store.is_empty() {
3737                    #[cfg(test)]
3738                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3739                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3740                    for d in &new_deltas {
3741                        self.view_store.on_edge_changed(
3742                            d.etype_sym,
3743                            d.src_id,
3744                            d.dst_id,
3745                            d.fired,
3746                            Arc::make_mut(&mut self.props),
3747                            &build_topo_view(&self.topo, &self.base),
3748                            &self.ids,
3749                            &self.syms,
3750                            &self.labels,
3751                            base_columns(&self.base),
3752                        );
3753                    }
3754                }
3755                // Full-text index maintenance: index enabled fields for this label.
3756                if self.fulltext.has_label(label) {
3757                    for (field, value) in props {
3758                        if self.fulltext.is_enabled(label, field) {
3759                            Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
3760                        }
3761                    }
3762                }
3763                // Property (equality) index maintenance.
3764                if self.prop_index.has_label(label) {
3765                    for (field, value) in props {
3766                        self.prop_index.set(label, field, id, value);
3767                    }
3768                }
3769            }
3770            WalRecord::InsertEdge {
3771                edge_type,
3772                src_key,
3773                dst_key,
3774            } => {
3775                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3776                    detail: format!("wal replay references unknown key {src_key}"),
3777                })?;
3778                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3779                    detail: format!("wal replay references unknown key {dst_key}"),
3780                })?;
3781                let etype = Arc::make_mut(&mut self.syms).intern(edge_type);
3782                // Skip if the edge is already visible in the merged base+overlay
3783                // view.  This keeps WAL replay idempotent when the WAL contains
3784                // pre-snapshot records that are already encoded in a V8 base
3785                // (keep_wal=true opens and crash-before-truncation scenarios).
3786                if self.base.is_some()
3787                    && self
3788                        .topo_view()
3789                        .neighbors(etype, Direction::Out, src)
3790                        .contains(&dst)
3791                {
3792                    return Ok(());
3793                }
3794                Arc::make_mut(&mut self.topo).add_edge(etype, src, dst);
3795                // View maintenance for manual edge insert.
3796                self.view_store.on_edge_changed(
3797                    etype,
3798                    src,
3799                    dst,
3800                    true,
3801                    Arc::make_mut(&mut self.props),
3802                    &build_topo_view(&self.topo, &self.base),
3803                    &self.ids,
3804                    &self.syms,
3805                    &self.labels,
3806                    base_columns(&self.base),
3807                );
3808                // Rule engine: via-hop rules must update when user edges change.
3809                let cursor = self.engine.pending_delta_count();
3810                let mut eng = std::mem::take(&mut self.engine);
3811                {
3812                    let mut gm = make_graph_mut(
3813                        &self.ids,
3814                        Arc::make_mut(&mut self.syms),
3815                        &self.labels,
3816                        build_props_view(&self.props, &self.base),
3817                        Arc::make_mut(&mut self.topo),
3818                        &self.base,
3819                        Arc::make_mut(&mut self.edge_props),
3820                    );
3821                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
3822                }
3823                self.engine = eng;
3824                if !self.view_store.is_empty() {
3825                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3826                    for d in &new_deltas {
3827                        self.view_store.on_edge_changed(
3828                            d.etype_sym,
3829                            d.src_id,
3830                            d.dst_id,
3831                            d.fired,
3832                            Arc::make_mut(&mut self.props),
3833                            &build_topo_view(&self.topo, &self.base),
3834                            &self.ids,
3835                            &self.syms,
3836                            &self.labels,
3837                            base_columns(&self.base),
3838                        );
3839                    }
3840                }
3841            }
3842            WalRecord::SetProp { key, field, value } => {
3843                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
3844                    detail: format!("wal replay references unknown key {key}"),
3845                })?;
3846                let old_value = build_props_view(&self.props, &self.base)
3847                    .get(id, field)
3848                    .map(|vr| vr.into_value());
3849                Arc::make_mut(&mut self.props).set(id, field, value.clone());
3850                // Fire rules for the changed field.
3851                let cursor = self.engine.pending_delta_count();
3852                let mut eng = std::mem::take(&mut self.engine);
3853                {
3854                    let mut gm = make_graph_mut(
3855                        &self.ids,
3856                        Arc::make_mut(&mut self.syms),
3857                        &self.labels,
3858                        build_props_view(&self.props, &self.base),
3859                        Arc::make_mut(&mut self.topo),
3860                        &self.base,
3861                        Arc::make_mut(&mut self.edge_props),
3862                    );
3863                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
3864                }
3865                self.engine = eng;
3866                // Derived-edge deltas → view updates.
3867                if !self.view_store.is_empty() {
3868                    #[cfg(test)]
3869                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3870                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3871                    for d in &new_deltas {
3872                        self.view_store.on_edge_changed(
3873                            d.etype_sym,
3874                            d.src_id,
3875                            d.dst_id,
3876                            d.fired,
3877                            Arc::make_mut(&mut self.props),
3878                            &build_topo_view(&self.topo, &self.base),
3879                            &self.ids,
3880                            &self.syms,
3881                            &self.labels,
3882                            base_columns(&self.base),
3883                        );
3884                    }
3885                }
3886                // Neighbor-aggregate views that read `field` must also update.
3887                self.view_store.on_prop_changed(
3888                    id,
3889                    field,
3890                    Arc::make_mut(&mut self.props),
3891                    &build_topo_view(&self.topo, &self.base),
3892                    &self.ids,
3893                    &self.syms,
3894                    &self.labels,
3895                    base_columns(&self.base),
3896                );
3897                // Full-text index maintenance: update tokens for this field if indexed.
3898                if self.fulltext.field_indexed(field) {
3899                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3900                        if sym == u32::MAX {
3901                            None
3902                        } else {
3903                            self.syms.resolve(sym)
3904                        }
3905                    });
3906                    if let Some(label) = label_opt {
3907                        if self.fulltext.is_enabled(label, field) {
3908                            Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
3909                            Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
3910                        }
3911                    }
3912                }
3913                // Property (equality) index maintenance: re-key this node's value.
3914                if self.prop_index.field_indexed(field) {
3915                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
3916                        if sym == u32::MAX {
3917                            None
3918                        } else {
3919                            self.syms.resolve(sym)
3920                        }
3921                    });
3922                    if let Some(label) = label_opt {
3923                        self.prop_index.set(label, field, id, value);
3924                    }
3925                }
3926            }
3927            WalRecord::Intern { id, text } => {
3928                if let Some(existing) = self.syms.get(text) {
3929                    if existing != *id {
3930                        return Err(GraphError::Corrupt {
3931                            detail: format!(
3932                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
3933                            ),
3934                        });
3935                    }
3936                } else {
3937                    let got = Arc::make_mut(&mut self.syms).intern(text);
3938                    if got != *id {
3939                        return Err(GraphError::Corrupt {
3940                            detail: format!(
3941                                "wal intern assigned {got} for {text:?}, record wanted {id}"
3942                            ),
3943                        });
3944                    }
3945                }
3946            }
3947            WalRecord::InsertNodeId { label, key, props } => {
3948                let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
3949                if self.labels.len() <= id as usize {
3950                    Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
3951                }
3952                Arc::make_mut(&mut self.labels)[id as usize] = *label;
3953                let label_str = self
3954                    .syms
3955                    .resolve(*label)
3956                    .ok_or_else(|| GraphError::Corrupt {
3957                        detail: format!("wal InsertNodeId unknown label intern {label}"),
3958                    })?
3959                    .to_string();
3960                let mut ns_name = NS_DEFAULT.to_string();
3961                for (field_sym, value) in props {
3962                    let field =
3963                        self.syms
3964                            .resolve(*field_sym)
3965                            .ok_or_else(|| GraphError::Corrupt {
3966                                detail: format!(
3967                                    "wal InsertNodeId unknown field intern {field_sym}"
3968                                ),
3969                            })?;
3970                    if field == NS_PROP {
3971                        ns_name = namespace_of_value(Some(value)).to_string();
3972                    }
3973                    Arc::make_mut(&mut self.props).set(id, field, value.clone());
3974                }
3975                self.set_node_ns(id, &ns_name);
3976                self.view_store.init_node_views(
3977                    id,
3978                    Arc::make_mut(&mut self.props),
3979                    &self.syms,
3980                    &self.labels,
3981                );
3982                let cursor = self.engine.pending_delta_count();
3983                let mut eng = std::mem::take(&mut self.engine);
3984                {
3985                    let mut gm = make_graph_mut(
3986                        &self.ids,
3987                        Arc::make_mut(&mut self.syms),
3988                        &self.labels,
3989                        build_props_view(&self.props, &self.base),
3990                        Arc::make_mut(&mut self.topo),
3991                        &self.base,
3992                        Arc::make_mut(&mut self.edge_props),
3993                    );
3994                    eng.on_node_changed(id, None, &mut gm);
3995                }
3996                self.engine = eng;
3997                if !self.view_store.is_empty() {
3998                    #[cfg(test)]
3999                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4000                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4001                    for d in &new_deltas {
4002                        self.view_store.on_edge_changed(
4003                            d.etype_sym,
4004                            d.src_id,
4005                            d.dst_id,
4006                            d.fired,
4007                            Arc::make_mut(&mut self.props),
4008                            &build_topo_view(&self.topo, &self.base),
4009                            &self.ids,
4010                            &self.syms,
4011                            &self.labels,
4012                            base_columns(&self.base),
4013                        );
4014                    }
4015                }
4016                if self.fulltext.has_label(&label_str) {
4017                    for (field_sym, value) in props {
4018                        let Some(field) = self.syms.resolve(*field_sym) else {
4019                            continue;
4020                        };
4021                        if self.fulltext.is_enabled(&label_str, field) {
4022                            Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4023                        }
4024                    }
4025                }
4026                if self.prop_index.has_label(&label_str) {
4027                    for (field_sym, value) in props {
4028                        let Some(field) = self.syms.resolve(*field_sym) else {
4029                            continue;
4030                        };
4031                        self.prop_index.set(&label_str, field, id, value);
4032                    }
4033                }
4034            }
4035            WalRecord::InsertEdgeId { etype, src, dst } => {
4036                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
4037                // already be tombstoned. Skip rather than attaching edges to
4038                // dead ids (DeleteNode keys the live re-insert, not the old id).
4039                if self.ids.is_tombstoned(*src)
4040                    || self.ids.is_tombstoned(*dst)
4041                    || self.ids.key_of(*src).is_none()
4042                    || self.ids.key_of(*dst).is_none()
4043                {
4044                    return Ok(());
4045                }
4046                // Skip if already visible in the merged view (same idempotency
4047                // guard as InsertEdge above: prevents double-counting when
4048                // pre-snapshot WAL records are replayed over a V8 base).
4049                if self.base.is_some()
4050                    && self
4051                        .topo_view()
4052                        .neighbors(*etype, Direction::Out, *src)
4053                        .contains(dst)
4054                {
4055                    return Ok(());
4056                }
4057                Arc::make_mut(&mut self.topo).add_edge(*etype, *src, *dst);
4058                self.view_store.on_edge_changed(
4059                    *etype,
4060                    *src,
4061                    *dst,
4062                    true,
4063                    Arc::make_mut(&mut self.props),
4064                    &build_topo_view(&self.topo, &self.base),
4065                    &self.ids,
4066                    &self.syms,
4067                    &self.labels,
4068                    base_columns(&self.base),
4069                );
4070                // Rule engine: via-hop rules fire when user via-edges are inserted.
4071                // Resolve etype back to string so on_edge_changed can match rules by name.
4072                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
4073                    let cursor = self.engine.pending_delta_count();
4074                    let mut eng = std::mem::take(&mut self.engine);
4075                    {
4076                        let mut gm = make_graph_mut(
4077                            &self.ids,
4078                            Arc::make_mut(&mut self.syms),
4079                            &self.labels,
4080                            build_props_view(&self.props, &self.base),
4081                            Arc::make_mut(&mut self.topo),
4082                            &self.base,
4083                            Arc::make_mut(&mut self.edge_props),
4084                        );
4085                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
4086                    }
4087                    self.engine = eng;
4088                    if !self.view_store.is_empty() {
4089                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4090                        for d in &new_deltas {
4091                            self.view_store.on_edge_changed(
4092                                d.etype_sym,
4093                                d.src_id,
4094                                d.dst_id,
4095                                d.fired,
4096                                Arc::make_mut(&mut self.props),
4097                                &build_topo_view(&self.topo, &self.base),
4098                                &self.ids,
4099                                &self.syms,
4100                                &self.labels,
4101                                base_columns(&self.base),
4102                            );
4103                        }
4104                    }
4105                }
4106            }
4107            WalRecord::SetPropId { id, field, value } => {
4108                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
4109                    return Ok(());
4110                }
4111                let field_str = self
4112                    .syms
4113                    .resolve(*field)
4114                    .ok_or_else(|| GraphError::Corrupt {
4115                        detail: format!("wal SetPropId unknown field intern {field}"),
4116                    })?
4117                    .to_string();
4118                let old_value = build_props_view(&self.props, &self.base)
4119                    .get(*id, &field_str)
4120                    .map(|vr| vr.into_value());
4121                Arc::make_mut(&mut self.props).set(*id, &field_str, value.clone());
4122                let cursor = self.engine.pending_delta_count();
4123                let mut eng = std::mem::take(&mut self.engine);
4124                {
4125                    let mut gm = make_graph_mut(
4126                        &self.ids,
4127                        Arc::make_mut(&mut self.syms),
4128                        &self.labels,
4129                        build_props_view(&self.props, &self.base),
4130                        Arc::make_mut(&mut self.topo),
4131                        &self.base,
4132                        Arc::make_mut(&mut self.edge_props),
4133                    );
4134                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
4135                }
4136                self.engine = eng;
4137                if !self.view_store.is_empty() {
4138                    #[cfg(test)]
4139                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4140                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4141                    for d in &new_deltas {
4142                        self.view_store.on_edge_changed(
4143                            d.etype_sym,
4144                            d.src_id,
4145                            d.dst_id,
4146                            d.fired,
4147                            Arc::make_mut(&mut self.props),
4148                            &build_topo_view(&self.topo, &self.base),
4149                            &self.ids,
4150                            &self.syms,
4151                            &self.labels,
4152                            base_columns(&self.base),
4153                        );
4154                    }
4155                }
4156                self.view_store.on_prop_changed(
4157                    *id,
4158                    &field_str,
4159                    Arc::make_mut(&mut self.props),
4160                    &build_topo_view(&self.topo, &self.base),
4161                    &self.ids,
4162                    &self.syms,
4163                    &self.labels,
4164                    base_columns(&self.base),
4165                );
4166                if self.fulltext.field_indexed(&field_str) {
4167                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4168                        if sym == u32::MAX {
4169                            None
4170                        } else {
4171                            self.syms.resolve(sym)
4172                        }
4173                    });
4174                    if let Some(label) = label_opt {
4175                        if self.fulltext.is_enabled(label, &field_str) {
4176                            Arc::make_mut(&mut self.fulltext).remove_node_field(*id, &field_str);
4177                            Arc::make_mut(&mut self.fulltext).add_tokens(*id, &field_str, value);
4178                        }
4179                    }
4180                }
4181                if self.prop_index.field_indexed(&field_str) {
4182                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4183                        if sym == u32::MAX {
4184                            None
4185                        } else {
4186                            self.syms.resolve(sym)
4187                        }
4188                    });
4189                    if let Some(label) = label_opt {
4190                        self.prop_index.set(label, &field_str, *id, value);
4191                    }
4192                }
4193            }
4194            WalRecord::CreateRule { def_bytes } => {
4195                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4196                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4197                })?;
4198                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
4199                // so the engine already has it; silently skip to avoid a spurious
4200                // RuleInvalid error in the crash window between snapshot write and WAL
4201                // truncation.
4202                if self.engine.rules().any(|r| r.name == def.name) {
4203                    return Ok(());
4204                }
4205                let cursor = self.engine.pending_delta_count();
4206                let mut eng = std::mem::take(&mut self.engine);
4207                let result = {
4208                    let mut gm = make_graph_mut(
4209                        &self.ids,
4210                        Arc::make_mut(&mut self.syms),
4211                        &self.labels,
4212                        build_props_view(&self.props, &self.base),
4213                        Arc::make_mut(&mut self.topo),
4214                        &self.base,
4215                        Arc::make_mut(&mut self.edge_props),
4216                    );
4217                    eng.create_rule(def, &mut gm)
4218                };
4219                self.engine = eng;
4220                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
4221                // Derived-edge fires from backfill → view updates.
4222                // Fast path: skip O(edge_count) allocation when no views exist.
4223                if !self.view_store.is_empty() {
4224                    #[cfg(test)]
4225                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4226                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4227                    for d in &new_deltas {
4228                        self.view_store.on_edge_changed(
4229                            d.etype_sym,
4230                            d.src_id,
4231                            d.dst_id,
4232                            d.fired,
4233                            Arc::make_mut(&mut self.props),
4234                            &build_topo_view(&self.topo, &self.base),
4235                            &self.ids,
4236                            &self.syms,
4237                            &self.labels,
4238                            base_columns(&self.base),
4239                        );
4240                    }
4241                }
4242            }
4243            WalRecord::DeleteRule { name } => {
4244                // Replay-over-snapshot idempotency: the snapshot already captured the
4245                // post-delete state so the rule is absent; silently skip to avoid a
4246                // spurious RuleNotFound error in the crash window between snapshot write
4247                // and WAL truncation.
4248                if !self.engine.rules().any(|r| r.name == *name) {
4249                    return Ok(());
4250                }
4251                let cursor = self.engine.pending_delta_count();
4252                let mut eng = std::mem::take(&mut self.engine);
4253                let result = {
4254                    let mut gm = make_graph_mut(
4255                        &self.ids,
4256                        Arc::make_mut(&mut self.syms),
4257                        &self.labels,
4258                        build_props_view(&self.props, &self.base),
4259                        Arc::make_mut(&mut self.topo),
4260                        &self.base,
4261                        Arc::make_mut(&mut self.edge_props),
4262                    );
4263                    eng.delete_rule(name, &mut gm)
4264                };
4265                self.engine = eng;
4266                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4267                // Derived-edge retractions → view updates.
4268                if !self.view_store.is_empty() {
4269                    #[cfg(test)]
4270                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4271                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4272                    for d in &new_deltas {
4273                        self.view_store.on_edge_changed(
4274                            d.etype_sym,
4275                            d.src_id,
4276                            d.dst_id,
4277                            d.fired,
4278                            Arc::make_mut(&mut self.props),
4279                            &build_topo_view(&self.topo, &self.base),
4280                            &self.ids,
4281                            &self.syms,
4282                            &self.labels,
4283                            base_columns(&self.base),
4284                        );
4285                    }
4286                }
4287            }
4288            WalRecord::RemoveProp { key, field } => {
4289                // Recovery-safe: unknown key or already-absent field is a
4290                // clean no-op. Crash-window replay over a snapshot that
4291                // already applied this record must not Err.
4292                let Some(id) = self.ids.get(key) else {
4293                    return Ok(());
4294                };
4295                // Read old value through the seam for rule retraction.
4296                let old = build_props_view(&self.props, &self.base)
4297                    .get(id, field)
4298                    .map(|vr| vr.into_value());
4299                Arc::make_mut(&mut self.props).remove(id, field);
4300                // If the base still supplies the value after the overlay removal,
4301                // record a tombstone so ColumnsView::get does not resurrect it.
4302                // This covers both the base-only case AND the both-resident case:
4303                //   base-only (in_overlay=false): old prop was only in base, remove
4304                //     is a no-op on overlay, base still visible → tombstone needed.
4305                //   both-resident (in_overlay=true): overlay had v2, base has v1;
4306                //     removing overlay uncovers v1 → tombstone needed.
4307                // Idempotent on double-replay: second pass sees the tombstone →
4308                // get() returns None → condition is false → no duplicate tombstone.
4309                if build_props_view(&self.props, &self.base)
4310                    .get(id, field)
4311                    .is_some()
4312                {
4313                    Arc::make_mut(&mut self.props).record_prop_tombstone(id, field);
4314                }
4315                let cursor = self.engine.pending_delta_count();
4316                let mut eng = std::mem::take(&mut self.engine);
4317                {
4318                    let mut gm = make_graph_mut(
4319                        &self.ids,
4320                        Arc::make_mut(&mut self.syms),
4321                        &self.labels,
4322                        build_props_view(&self.props, &self.base),
4323                        Arc::make_mut(&mut self.topo),
4324                        &self.base,
4325                        Arc::make_mut(&mut self.edge_props),
4326                    );
4327                    eng.on_node_changed(id, Some((field, old)), &mut gm);
4328                }
4329                self.engine = eng;
4330                // Derived-edge deltas → view updates.
4331                if !self.view_store.is_empty() {
4332                    #[cfg(test)]
4333                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4334                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4335                    for d in &new_deltas {
4336                        self.view_store.on_edge_changed(
4337                            d.etype_sym,
4338                            d.src_id,
4339                            d.dst_id,
4340                            d.fired,
4341                            Arc::make_mut(&mut self.props),
4342                            &build_topo_view(&self.topo, &self.base),
4343                            &self.ids,
4344                            &self.syms,
4345                            &self.labels,
4346                            base_columns(&self.base),
4347                        );
4348                    }
4349                }
4350                // Neighbor-aggregate views that read `field` must also update.
4351                self.view_store.on_prop_changed(
4352                    id,
4353                    field,
4354                    Arc::make_mut(&mut self.props),
4355                    &build_topo_view(&self.topo, &self.base),
4356                    &self.ids,
4357                    &self.syms,
4358                    &self.labels,
4359                    base_columns(&self.base),
4360                );
4361                // Full-text index maintenance: remove tokens for this field.
4362                if self.fulltext.field_indexed(field) {
4363                    Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4364                }
4365                // Property (equality) index maintenance: drop this node's entry.
4366                if self.prop_index.field_indexed(field) {
4367                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
4368                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
4369                    }) {
4370                        self.prop_index.remove_node(label, field, id);
4371                    }
4372                }
4373            }
4374            WalRecord::DeleteEdge {
4375                edge_type,
4376                src_key,
4377                dst_key,
4378            } => {
4379                // Recovery-safe: unknown keys, unknown etype, or already-
4380                // absent edge is a clean no-op (remove_edge returns false).
4381                let Some(src) = self.ids.get(src_key) else {
4382                    return Ok(());
4383                };
4384                let Some(dst) = self.ids.get(dst_key) else {
4385                    return Ok(());
4386                };
4387                let Some(etype) = self.syms.get(edge_type) else {
4388                    return Ok(());
4389                };
4390                // I3: phantom-tombstone guard.  When a V8 base is present, a
4391                // DeleteEdge WAL record for an edge that was already absorbed into
4392                // the new base (i.e. neither in overlay nor in base) must be skipped.
4393                // Without this guard, remove_edge records a tombstone for an edge
4394                // that no longer exists, incorrectly understating edge_count.
4395                if self.base.is_some()
4396                    && !self
4397                        .topo_view()
4398                        .neighbors(etype, core_storage::topology::Direction::Out, src)
4399                        .contains(&dst)
4400                {
4401                    return Ok(());
4402                }
4403                Arc::make_mut(&mut self.topo).remove_edge(etype, src, dst);
4404                Arc::make_mut(&mut self.edge_props).remove_edge(etype, src, dst);
4405                // View maintenance for manual edge delete (topo already updated above).
4406                self.view_store.on_edge_changed(
4407                    etype,
4408                    src,
4409                    dst,
4410                    false,
4411                    Arc::make_mut(&mut self.props),
4412                    &build_topo_view(&self.topo, &self.base),
4413                    &self.ids,
4414                    &self.syms,
4415                    &self.labels,
4416                    base_columns(&self.base),
4417                );
4418                // Rule engine: via-hop rules must retract when user via-edges are deleted.
4419                let cursor = self.engine.pending_delta_count();
4420                let mut eng = std::mem::take(&mut self.engine);
4421                {
4422                    let mut gm = make_graph_mut(
4423                        &self.ids,
4424                        Arc::make_mut(&mut self.syms),
4425                        &self.labels,
4426                        build_props_view(&self.props, &self.base),
4427                        Arc::make_mut(&mut self.topo),
4428                        &self.base,
4429                        Arc::make_mut(&mut self.edge_props),
4430                    );
4431                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
4432                }
4433                self.engine = eng;
4434                if !self.view_store.is_empty() {
4435                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4436                    for d in &new_deltas {
4437                        self.view_store.on_edge_changed(
4438                            d.etype_sym,
4439                            d.src_id,
4440                            d.dst_id,
4441                            d.fired,
4442                            Arc::make_mut(&mut self.props),
4443                            &build_topo_view(&self.topo, &self.base),
4444                            &self.ids,
4445                            &self.syms,
4446                            &self.labels,
4447                            base_columns(&self.base),
4448                        );
4449                    }
4450                }
4451            }
4452            WalRecord::DeleteNode { key } => {
4453                // Recovery-safe: already-tombstoned / unknown key is a clean
4454                // no-op. Crash-window replay over a snapshot that already
4455                // applied this record cannot recover the retired id from the
4456                // key (`IdMap::get` is None), so every subsequent step is
4457                // skipped. Each step is independently idempotent if invoked
4458                // twice on a still-live id: retraction is a no-op on empty
4459                // provenance, `remove_edge` returns false, `remove_all` is a
4460                // no-op, `ids.delete` returns None, label sentinel is sticky.
4461                let Some(n) = self.ids.get(key) else {
4462                    return Ok(());
4463                };
4464
4465                // (1) Retract derived edges + de-index while props/labels live.
4466                let cursor = self.engine.pending_delta_count();
4467                let mut eng = std::mem::take(&mut self.engine);
4468                {
4469                    let mut gm = make_graph_mut(
4470                        &self.ids,
4471                        Arc::make_mut(&mut self.syms),
4472                        &self.labels,
4473                        build_props_view(&self.props, &self.base),
4474                        Arc::make_mut(&mut self.topo),
4475                        &self.base,
4476                        Arc::make_mut(&mut self.edge_props),
4477                    );
4478                    eng.on_node_removed(n, &mut gm);
4479                }
4480                self.engine = eng;
4481                // Derived-edge retractions → view updates for neighbors.
4482                if !self.view_store.is_empty() {
4483                    #[cfg(test)]
4484                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4485                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4486                    for d in &new_deltas {
4487                        self.view_store.on_edge_changed(
4488                            d.etype_sym,
4489                            d.src_id,
4490                            d.dst_id,
4491                            d.fired,
4492                            Arc::make_mut(&mut self.props),
4493                            &build_topo_view(&self.topo, &self.base),
4494                            &self.ids,
4495                            &self.syms,
4496                            &self.labels,
4497                            base_columns(&self.base),
4498                        );
4499                    }
4500                }
4501
4502                // (2) Sweep ALL remaining edges incident to n, both directions,
4503                // every etype. This cascade is intentionally mask-independent:
4504                // topology integrity requires removing every edge touching the
4505                // deleted node regardless of the caller's visibility scope.
4506                // (The mask limits which nodes a role's read phase can return;
4507                // the WAL delete always executes with full storage authority.)
4508                // Collect then remove so neighbor slices stay valid during
4509                // iteration. Remove from topo first, then call view maintenance
4510                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
4511                //
4512                // Through the view of the snapshot and what was written since,
4513                // not `self.topo` alone: that is only what was written since,
4514                // and an edge the snapshot holds was left behind, pointing at
4515                // a node that was gone (ledger row 95).
4516                let mut doomed = Vec::new();
4517                {
4518                    let tv = self.topo_view();
4519                    let etypes: Vec<u32> = tv.etypes().collect();
4520                    for et in &etypes {
4521                        for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
4522                            doomed.push((*et, n, dst));
4523                        }
4524                        for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
4525                            // A self-loop is already listed from its Out side.
4526                            if src != n {
4527                                doomed.push((*et, src, n));
4528                            }
4529                        }
4530                    }
4531                }
4532                for (et, s, d) in doomed {
4533                    Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
4534                    Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
4535                    // View maintenance: n's own view values will be cleared by
4536                    // remove_all below; only update surviving neighbors.
4537                    self.view_store.on_edge_changed_except(
4538                        et,
4539                        s,
4540                        d,
4541                        false,
4542                        Arc::make_mut(&mut self.props),
4543                        &build_topo_view(&self.topo, &self.base),
4544                        &self.ids,
4545                        &self.syms,
4546                        &self.labels,
4547                        base_columns(&self.base),
4548                        Some(n),
4549                    );
4550                }
4551
4552                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
4553                Arc::make_mut(&mut self.props).remove_all(n);
4554                // Full-text index maintenance: remove all tokens for this node.
4555                Arc::make_mut(&mut self.fulltext).remove_node(n);
4556                // Property (equality) index maintenance: drop all entries for n.
4557                self.prop_index.remove_node_all(n);
4558
4559                // (4) Retire the dense id and stamp the label sentinel.
4560                Arc::make_mut(&mut self.ids).delete(key);
4561                if let Some(slot) = Arc::make_mut(&mut self.labels).get_mut(n as usize) {
4562                    *slot = u32::MAX;
4563                }
4564            }
4565            WalRecord::Batch(inner) => {
4566                // Apply each inner record in order through the same apply path.
4567                // Inner records are validated free of nested Batch by encode_record.
4568                for rec in inner {
4569                    self.apply(rec)?;
4570                }
4571            }
4572            WalRecord::RebuildRule { name } => {
4573                // Replay-over-snapshot idempotency: the snapshot may already
4574                // reflect a later delete_rule, so the rule is absent; skip.
4575                if !self.engine.rules().any(|r| r.name == *name) {
4576                    return Ok(());
4577                }
4578                let cursor = self.engine.pending_delta_count();
4579                let mut eng = std::mem::take(&mut self.engine);
4580                let result = {
4581                    let mut gm = make_graph_mut(
4582                        &self.ids,
4583                        Arc::make_mut(&mut self.syms),
4584                        &self.labels,
4585                        build_props_view(&self.props, &self.base),
4586                        Arc::make_mut(&mut self.topo),
4587                        &self.base,
4588                        Arc::make_mut(&mut self.edge_props),
4589                    );
4590                    eng.rebuild(name, &mut gm)
4591                };
4592                self.engine = eng;
4593                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4594                // Derived-edge delta changes → view updates.
4595                if !self.view_store.is_empty() {
4596                    #[cfg(test)]
4597                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4598                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4599                    for d in &new_deltas {
4600                        self.view_store.on_edge_changed(
4601                            d.etype_sym,
4602                            d.src_id,
4603                            d.dst_id,
4604                            d.fired,
4605                            Arc::make_mut(&mut self.props),
4606                            &build_topo_view(&self.topo, &self.base),
4607                            &self.ids,
4608                            &self.syms,
4609                            &self.labels,
4610                            base_columns(&self.base),
4611                        );
4612                    }
4613                }
4614            }
4615            WalRecord::CreateView { def_bytes } => {
4616                let def: ViewDef =
4617                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
4618                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
4619                    })?;
4620                // Replay-over-snapshot idempotency: view already present → skip.
4621                if self.view_store.has_view(&def.name) {
4622                    return Ok(());
4623                }
4624                self.view_store
4625                    .create_view_over(
4626                        def,
4627                        Arc::make_mut(&mut self.props),
4628                        &build_topo_view(&self.topo, &self.base),
4629                        &self.ids,
4630                        &self.syms,
4631                        &self.labels,
4632                        base_columns(&self.base),
4633                    )
4634                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4635            }
4636            WalRecord::DeleteView { name } => {
4637                // Replay-over-snapshot idempotency: view already absent → skip.
4638                if !self.view_store.has_view(name) {
4639                    return Ok(());
4640                }
4641                let doomed_prop = self
4642                    .view_store
4643                    .views()
4644                    .find(|v| v.name == *name)
4645                    .map(|v| (v.label.clone(), v.view_prop.clone()));
4646                self.view_store
4647                    .delete_view(
4648                        name,
4649                        Arc::make_mut(&mut self.props),
4650                        &self.ids,
4651                        &self.labels,
4652                        &self.syms,
4653                    )
4654                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4655                // `delete_view` clears the view's property from what was
4656                // written since the snapshot. A value the snapshot holds is
4657                // hidden by a tombstone, or it stays readable on every node
4658                // of the label after the view that owned it is gone.
4659                if let (Some((label, prop)), true) = (doomed_prop, self.base.is_some()) {
4660                    if let Some(label_sym) = self.syms.get(&label) {
4661                        for id in 0..self.ids.len() as u32 {
4662                            if self.labels.get(id as usize).copied() == Some(label_sym)
4663                                && build_props_view(&self.props, &self.base)
4664                                    .get(id, &prop)
4665                                    .is_some()
4666                            {
4667                                Arc::make_mut(&mut self.props).record_prop_tombstone(id, &prop);
4668                            }
4669                        }
4670                    }
4671                }
4672            }
4673            WalRecord::EnableFulltext { label, field } => {
4674                // Replay-over-snapshot idempotency: already enabled → skip.
4675                if self.fulltext.is_enabled(label, field) {
4676                    return Ok(());
4677                }
4678                Arc::make_mut(&mut self.fulltext).enable(label, field);
4679                if self.fulltext_rebuild_follows {
4680                    // The open path rebuilds the whole index after replay, which
4681                    // clears every posting this scan would write. Doing it twice
4682                    // costs a full tokenise-and-stem pass over the corpus per
4683                    // enabled pair: measured at 456 ms against 3.8 ms for the
4684                    // same 30,000-entity store with no pair enabled.
4685                    return Ok(());
4686                }
4687                // Backfill: index all live nodes of this label that have the field.
4688                let n = self.ids.len() as u32;
4689                for id in 0..n {
4690                    let Some(&sym) = self.labels.get(id as usize) else {
4691                        continue;
4692                    };
4693                    if sym == u32::MAX {
4694                        continue; // tombstoned
4695                    }
4696                    let Some(lbl) = self.syms.resolve(sym) else {
4697                        continue;
4698                    };
4699                    if lbl != label {
4700                        continue;
4701                    }
4702                    if let Some(value) = build_props_view(&self.props, &self.base)
4703                        .get(id, field)
4704                        .map(|vr| vr.into_value())
4705                    {
4706                        Arc::make_mut(&mut self.fulltext).add_tokens(id, field, &value);
4707                    }
4708                }
4709            }
4710            WalRecord::DisableFulltext { label, field } => {
4711                // Replay-over-snapshot idempotency: already disabled → skip.
4712                if !self.fulltext.is_enabled(label, field) {
4713                    return Ok(());
4714                }
4715                // If another label still indexes this field, the postings column
4716                // is kept — but it must not contain node_ids from the now-disabled
4717                // label.  Remove them before calling disable() so the field_indexed
4718                // guard inside disable() sees the correct post-removal state.
4719                if self.fulltext.field_indexed_by_other(label, field) {
4720                    if let Some(label_sym) = self.syms.get(label) {
4721                        for (node_id, &lsym) in self.labels.iter().enumerate() {
4722                            if lsym == label_sym {
4723                                Arc::make_mut(&mut self.fulltext)
4724                                    .remove_node_field(node_id as u32, field);
4725                            }
4726                        }
4727                    }
4728                }
4729                Arc::make_mut(&mut self.fulltext).disable(label, field);
4730            }
4731            WalRecord::EnableIndex { label, field } => {
4732                // Replay-over-snapshot idempotency: already enabled → skip.
4733                if self.prop_index.is_enabled(label, field) {
4734                    return Ok(());
4735                }
4736                self.prop_index.enable(label, field);
4737                // Backfill: index all live nodes of this label that have the field.
4738                let n = self.ids.len() as u32;
4739                for id in 0..n {
4740                    let Some(&sym) = self.labels.get(id as usize) else {
4741                        continue;
4742                    };
4743                    if sym == u32::MAX {
4744                        continue; // tombstoned
4745                    }
4746                    let Some(lbl) = self.syms.resolve(sym) else {
4747                        continue;
4748                    };
4749                    if lbl != label {
4750                        continue;
4751                    }
4752                    if let Some(value) = build_props_view(&self.props, &self.base)
4753                        .get(id, field)
4754                        .map(|vr| vr.into_value())
4755                    {
4756                        self.prop_index.set(label, field, id, &value);
4757                    }
4758                }
4759            }
4760            WalRecord::DisableIndex { label, field } => {
4761                self.prop_index.disable(label, field);
4762            }
4763            // ── insert-count multiplicity (§5.13) ────────────────────────────
4764            //
4765            // Two shapes, told apart by `count`: the opt-in declaration, and an
4766            // absolute count for one triple. Absolute is what makes this
4767            // idempotent over a snapshot base — a pre-snapshot frame replayed
4768            // over a base that already folded it in lands on the same number
4769            // rather than adding to it, which is the failure a delta (or a count
4770            // derived from `InsertEdgeId` records) would have.
4771            WalRecord::SetEdgeCount {
4772                etype,
4773                src,
4774                dst,
4775                count,
4776            } => {
4777                if rec.is_multiplicity_decl() {
4778                    self.multiplicity = true;
4779                } else {
4780                    Arc::make_mut(&mut self.edge_props).set(
4781                        *etype,
4782                        *src,
4783                        *dst,
4784                        EDGE_COUNT_PROP,
4785                        Value::Int(*count as i64),
4786                    );
4787                }
4788            }
4789            // History markers carry no replay state — rules re-derive edges
4790            // deterministically on open/replay. Skip unconditionally.
4791            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
4792            // ── rename_node ──────────────────────────────────────────────────
4793            WalRecord::RenameNode { old_key, new_key } => {
4794                // Recovery-safe: if old_key is already gone (key was renamed
4795                // by a snapshot or a prior replay frame), skip cleanly.
4796                if self.ids.get(old_key).is_none() {
4797                    return Ok(());
4798                }
4799                // The rename only updates the key-table; the dense id, all
4800                // topo edges, props, labels, and rule state are id-indexed and
4801                // require no change.
4802                Arc::make_mut(&mut self.ids)
4803                    .rename(old_key, new_key)
4804                    .map_err(|e| GraphError::Corrupt {
4805                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
4806                    })?;
4807            }
4808        }
4809        Ok(())
4810    }
4811
4812    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
4813    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
4814    /// idempotent when the string is already bound. Always emit: after
4815    /// `snapshot()` the WAL is truncated and live intern is not on disk.
4816    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
4817        let id = if let Some(id) = self.syms.get(s) {
4818            id
4819        } else {
4820            Arc::make_mut(&mut self.syms).intern(s)
4821        };
4822        (
4823            id,
4824            WalRecord::Intern {
4825                id,
4826                text: s.to_string(),
4827            },
4828        )
4829    }
4830
4831    /// Rewrite user-facing records into dense-id records. On `Err`, no live
4832    /// state is left mutated: speculative interns made while building the
4833    /// output are rolled back, so a later successful mutation cannot log an
4834    /// `Intern` record whose id replay would never reproduce.
4835    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
4836        self.rewrite_wal_dense_planned(recs.into_iter().map(PlannedRec::Rec).collect())
4837    }
4838
4839    /// [`rewrite_wal_dense`](Self::rewrite_wal_dense) for a frame that still
4840    /// carries [`PlannedRec::DuplicateCount`] entries — the shape a batch
4841    /// produces, where a duplicate's count can only be named once this pass has
4842    /// assigned the frame's own ids.
4843    fn rewrite_wal_dense_planned(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
4844        let syms_checkpoint = self.syms.len();
4845        let result = self.rewrite_wal_dense_inner(recs);
4846        if result.is_err() {
4847            Arc::make_mut(&mut self.syms).truncate(syms_checkpoint);
4848        }
4849        result
4850    }
4851
4852    fn rewrite_wal_dense_inner(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
4853        let mut out = Vec::with_capacity(recs.len());
4854        // Node ids allocated by later apply(InsertNodeId) in this same batch.
4855        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
4856        // Namespace of each node inserted earlier in this same frame, so a SET
4857        // on a node this frame created is measured against the namespace it was
4858        // created in rather than against the store, where it does not exist yet.
4859        let mut pending_ns: std::collections::HashMap<String, String> =
4860            std::collections::HashMap::new();
4861        let mut interned = std::collections::HashSet::<u32>::new();
4862        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
4863            detail: "id space exhausted".into(),
4864        })?;
4865        // Insert counts this frame has already raised. `edge_insert_count`
4866        // reads committed state, which cannot see a count queued earlier in
4867        // this same frame, so N duplicates of one pair would otherwise all
4868        // compute `committed + 1` and the last would win.
4869        let mut pending_counts: HashMap<(u32, u32, u32), u64> = HashMap::new();
4870        // Keys this frame has taken off the node the store holds them on: the
4871        // old key of a rename, and a deleted node's. Until the frame inserts
4872        // under such a key again it names nothing, and after that it names the
4873        // new node — never the stored one, which is what the store alone
4874        // would still answer while the frame is being rewritten.
4875        let mut freed: std::collections::HashSet<String> = std::collections::HashSet::new();
4876        let lookup = |ids: &IdMap,
4877                      pending: &std::collections::HashMap<String, u32>,
4878                      freed: &std::collections::HashSet<String>,
4879                      key: &str|
4880         -> Option<u32> {
4881            match pending.get(key) {
4882                Some(&id) => Some(id),
4883                None if freed.contains(key) => None,
4884                None => ids.get(key),
4885            }
4886        };
4887        for rec in recs {
4888            // A duplicate insert's count, resolved here and nowhere else.
4889            //
4890            // This is the only pass that knows the frame's own ids: a node
4891            // created earlier in the same frame has no dense id until the
4892            // `InsertNodeId` above allocates one, and an edge type first used in
4893            // this frame is not in `syms` until `intern_wal` puts it there.
4894            // Resolving the count in the batch's validate pass instead — where
4895            // it used to live — meant that a duplicate whose endpoints or type
4896            // were created in the same frame silently produced no count at all,
4897            // which is exactly the shape a mirror rebuild writes (defect #24).
4898            let rec = match rec {
4899                PlannedRec::Rec(rec) => rec,
4900                PlannedRec::DuplicateCount {
4901                    edge_type,
4902                    src_key,
4903                    dst_key,
4904                } => {
4905                    let (etype, intern) = self.intern_wal(&edge_type);
4906                    if interned.insert(etype) {
4907                        out.push(intern);
4908                    }
4909                    let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
4910                        GraphError::Corrupt {
4911                            detail: format!("dense WAL rewrite missing src {src_key}"),
4912                        }
4913                    })?;
4914                    let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
4915                        GraphError::Corrupt {
4916                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
4917                        }
4918                    })?;
4919                    let count = pending_counts
4920                        .get(&(etype, src, dst))
4921                        .copied()
4922                        .unwrap_or_else(|| self.edge_insert_count(etype, src, dst))
4923                        .saturating_add(1);
4924                    pending_counts.insert((etype, src, dst), count);
4925                    out.push(WalRecord::SetEdgeCount {
4926                        etype,
4927                        src,
4928                        dst,
4929                        count,
4930                    });
4931                    continue;
4932                }
4933            };
4934            match rec {
4935                WalRecord::InsertNode { label, key, props } => {
4936                    // Namespace validation and normalisation, on the one seam
4937                    // every user-visible node insert passes through: insert_node,
4938                    // a batch, ingest, Cypher CREATE and MERGE all arrive here
4939                    // before the WAL append, and replay never does.
4940                    let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
4941                    pending_ns.insert(key.clone(), ns_name);
4942                    let (label_id, intern) = self.intern_wal(&label);
4943                    if interned.insert(label_id) {
4944                        out.push(intern);
4945                    }
4946                    let mut props_id = Vec::with_capacity(props.len());
4947                    for (field, value) in props {
4948                        let (field_id, intern) = self.intern_wal(&field);
4949                        if interned.insert(field_id) {
4950                            out.push(intern);
4951                        }
4952                        props_id.push((field_id, value));
4953                    }
4954                    if lookup(&self.ids, &pending, &freed, &key).is_none() {
4955                        pending.insert(key.clone(), next);
4956                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
4957                            detail: "id space exhausted".into(),
4958                        })?;
4959                    }
4960                    out.push(WalRecord::InsertNodeId {
4961                        label: label_id,
4962                        key,
4963                        props: props_id,
4964                    });
4965                }
4966                WalRecord::SetProp { key, field, value } => {
4967                    // A namespace is set at insert and fixed after: the write is
4968                    // refused when it would move the node, and dropped when it
4969                    // names the namespace the node is already in. Checked here
4970                    // so set_prop, a batch, Cypher SET/MERGE and every upsert
4971                    // that merges props get the same answer.
4972                    if field == NS_PROP {
4973                        let Value::Str(ref to) = value else {
4974                            return Err(GraphError::RuleInvalid {
4975                                detail: format!(
4976                                    "node {key}: {NS_PROP} must be a string naming a namespace, \
4977                                     got {value:?}"
4978                                ),
4979                            });
4980                        };
4981                        let from = pending_ns
4982                            .get(&key)
4983                            .cloned()
4984                            .or_else(|| self.namespace_of(&key))
4985                            .unwrap_or_else(|| NS_DEFAULT.to_string());
4986                        let to = to.clone();
4987                        if to != from {
4988                            return Err(GraphError::NamespaceImmutable {
4989                                key: key.clone(),
4990                                from,
4991                                to,
4992                            });
4993                        }
4994                        continue;
4995                    }
4996                    let id = lookup(&self.ids, &pending, &freed, &key).ok_or_else(|| {
4997                        GraphError::Corrupt {
4998                            detail: format!("dense WAL rewrite missing key {key}"),
4999                        }
5000                    })?;
5001                    let (field_id, intern) = self.intern_wal(&field);
5002                    if interned.insert(field_id) {
5003                        out.push(intern);
5004                    }
5005                    out.push(WalRecord::SetPropId {
5006                        id,
5007                        field: field_id,
5008                        value,
5009                    });
5010                }
5011                WalRecord::InsertEdge {
5012                    edge_type,
5013                    src_key,
5014                    dst_key,
5015                } => {
5016                    let (etype, intern) = self.intern_wal(&edge_type);
5017                    if interned.insert(etype) {
5018                        out.push(intern);
5019                    }
5020                    let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5021                        GraphError::Corrupt {
5022                            detail: format!("dense WAL rewrite missing src {src_key}"),
5023                        }
5024                    })?;
5025                    let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5026                        GraphError::Corrupt {
5027                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
5028                        }
5029                    })?;
5030                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
5031                }
5032                WalRecord::RenameNode {
5033                    ref old_key,
5034                    ref new_key,
5035                } => {
5036                    // Track the rename in `pending` so subsequent InsertEdge /
5037                    // SetProp records in this batch can resolve the new key.
5038                    let id = lookup(&self.ids, &pending, &freed, old_key).ok_or_else(|| {
5039                        GraphError::Corrupt {
5040                            detail: format!(
5041                                "dense WAL rewrite: RenameNode old key {old_key} not found"
5042                            ),
5043                        }
5044                    })?;
5045                    pending.remove(old_key.as_str());
5046                    freed.insert(old_key.clone());
5047                    pending.insert(new_key.clone(), id);
5048                    out.push(rec);
5049                }
5050                // # Symbol-order invariant (load-bearing)
5051                //
5052                // Write-time and replay-time symbol assignment must agree: every
5053                // symbol in a `Batch` frame has to receive the same dense id when
5054                // the frame's records are replayed in order as it received when
5055                // the frame was written.
5056                //
5057                // A rule's backfill interns its `edge_type` lazily
5058                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
5059                // site), and that backfill runs from `apply` — during the
5060                // `CreateRule` record itself, and again from any later
5061                // `InsertNodeId` in the same frame that makes the rule fire. At
5062                // write time the whole batch is rewritten before any of it is
5063                // applied, so a later `InsertEdge` in the same batch would win the
5064                // lower id for its edge type; on replay the rule's lazy intern
5065                // gets there first and steals it, and the `Intern` record fails at
5066                // the `wal intern assigned …` check in `apply`.
5067                //
5068                // Pre-interning the rule's `edge_type` here, and emitting its
5069                // `Intern` record ahead of the `CreateRule` record, makes both
5070                // orders identical. `weight_prop` needs no pre-intern:
5071                // `EdgeProps::set` keys props by `String`, never through the
5072                // interner. `via_edge` needs none either: via-hop rules resolve it
5073                // with `syms.get` and skip when it is absent.
5074                //
5075                // `RebuildRule` and `DeleteRule` need no such handling here:
5076                // `RebuildRule` has no `BatchOp` variant, so it never appears
5077                // inside a `Batch` today — it is only ever issued as its own
5078                // standalone commit (`rebuild_rule`, or the auto-rebuild path
5079                // that logs it as a second commit after the triggering op).
5080                // `DeleteRule` does have a `BatchOp` variant and can appear
5081                // inside a `Batch`, but it carries only a rule `name` — no
5082                // `edge_type` or other symbol that needs pre-interning — so
5083                // only `CreateRule` needs this arm.
5084                WalRecord::CreateRule { ref def_bytes } => {
5085                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
5086                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
5087                    })?;
5088                    let (etype, intern) = self.intern_wal(&def.edge_type);
5089                    if interned.insert(etype) {
5090                        out.push(intern);
5091                    }
5092                    out.push(rec);
5093                }
5094                WalRecord::DeleteNode { ref key } => {
5095                    pending.remove(key.as_str());
5096                    freed.insert(key.clone());
5097                    out.push(rec);
5098                }
5099                other => out.push(other),
5100            }
5101        }
5102        Ok(out)
5103    }
5104
5105    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
5106        let recs = self.rewrite_wal_dense(recs)?;
5107        match recs.len() {
5108            0 => Ok(()),
5109            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
5110            _ => self.log_then_apply(WalRecord::Batch(recs)),
5111        }
5112    }
5113
5114    /// Durable write, then notify the event sink. Replay (`apply` during
5115    /// `open`) never enters this function, so it is the replay-silent seam.
5116    /// Record that the commit occupying `frame_index` happened now, and append
5117    /// those 16 bytes to the sidecar.
5118    ///
5119    /// `frame_index` is the **global 0-based WAL frame index** of the commit's
5120    /// own record — the space every history surface addresses — taken from
5121    /// [`wal_frames_written`](GraphDb::wal_frames_written) before the append
5122    /// that puts the record there.
5123    ///
5124    /// **It is deliberately not derived from `commit_seq`.** A commit is not a
5125    /// frame: one whose rules fire appends a second frame for the derived-edge
5126    /// history marker, so `commit_seq - 1` falls one frame further behind per
5127    /// rule-firing commit and every date resolves to an ever-earlier graph.
5128    /// That was the shipped behaviour through v0.6.11 and it failed silently,
5129    /// because an older graph is a plausible answer rather than an error.
5130    ///
5131    /// Called from exactly one place — `log_then_apply_with`, immediately after
5132    /// `commit_seq` is incremented. Every write path in the engine funnels
5133    /// through that function, and replay deliberately does not: `apply_frames`
5134    /// re-applies commits that already happened, so stamping there would record
5135    /// replay time as commit time.
5136    ///
5137    /// **This is the engine's only wall-clock read.** Everything else uses
5138    /// `Instant`, which is monotonic and not a date.
5139    ///
5140    /// Failure is swallowed on purpose. The sidecar is not part of the WAL or
5141    /// the snapshot, so a failed append must not fail a commit that is already
5142    /// durable — it costs a date, not data. The map is marked poisoned so the
5143    /// gap is reported rather than resolved across.
5144    fn stamp_commit_time(&mut self, frame_index: u64) {
5145        if self.commit_times_poisoned {
5146            return;
5147        }
5148        let unix_ms = match self.commit_time_override {
5149            Some(ms) => ms,
5150            None => {
5151                let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
5152                else {
5153                    // A clock before 1970. Refuse to invent a timestamp.
5154                    self.commit_times_poisoned = true;
5155                    return;
5156                };
5157                now.as_millis() as i64
5158            }
5159        };
5160        let first = self.commit_times.is_empty();
5161        self.commit_times.push(frame_index, unix_ms);
5162
5163        let wrote = if first {
5164            self.fs.write_atomic(
5165                FileId::CommitTimes,
5166                &core_storage::commit_times::encode(&self.commit_times),
5167            )
5168        } else {
5169            self.fs.append(
5170                FileId::CommitTimes,
5171                &core_storage::commit_times::encode_entry(frame_index, unix_ms),
5172            )
5173        };
5174        if wrote.is_err() {
5175            self.commit_times_poisoned = true;
5176        }
5177    }
5178
5179    /// Read the time sidecar from disk into this handle.
5180    ///
5181    /// Absent is the normal case for any store written before v0.6.11 and is
5182    /// not an error; unreadable is recorded so date queries can say "damaged"
5183    /// rather than "none recorded".
5184    ///
5185    /// Called at open **and** by `refresh` when a peer's frames are absorbed.
5186    /// Both, because the map is a file another process appends to: a handle
5187    /// that raises its frame cursor to include a peer's commits while holding a
5188    /// stale map would answer dates from a prefix of the truth — and, if its
5189    /// own map were still empty, would rewrite the whole file with one entry
5190    /// and destroy the peer's.
5191    fn load_commit_times_from_fs(&mut self) {
5192        match self.fs.read(FileId::CommitTimes) {
5193            Ok(bytes) if bytes.is_empty() => {}
5194            Ok(bytes) => {
5195                // A map from an older format version is discarded, not
5196                // reported as damage and not reinterpreted. Its entries were
5197                // written correctly against a different meaning of the number
5198                // — see `COMMIT_TIMES_VERSION` — and reading them in this
5199                // build's space would resolve dates onto unrelated commits.
5200                // Leaving the map empty makes the store answer
5201                // `NoRecordedTime`, which is the truth: it records no times
5202                // this build can use, and the next commit starts a usable map.
5203                if core_storage::commit_times::superseded_version(&bytes).is_some() {
5204                    return;
5205                }
5206                match core_storage::commit_times::decode(&bytes) {
5207                    Ok(t) => self.commit_times = t,
5208                    Err(_) => self.commit_times_poisoned = true,
5209                }
5210            }
5211            Err(_) => {}
5212        }
5213    }
5214
5215    /// Rewrite the sidecar from memory. Used after truncation, which is the one
5216    /// operation that cannot be expressed as an append.
5217    fn rewrite_commit_times(&mut self) {
5218        if self.commit_times_poisoned {
5219            return;
5220        }
5221        if self
5222            .fs
5223            .write_atomic(
5224                FileId::CommitTimes,
5225                &core_storage::commit_times::encode(&self.commit_times),
5226            )
5227            .is_err()
5228        {
5229            self.commit_times_poisoned = true;
5230        }
5231    }
5232
5233    /// The greatest commit whose recorded time is at or before `unix_ms`.
5234    ///
5235    /// Errors name what they can answer instead of guessing a commit:
5236    /// `Corrupt` when the sidecar would not decode, `NoRecordedTime` when the
5237    /// store records none, `TimeBeforeFloor` when the instant predates the
5238    /// oldest entry, and `CommitOutOfRange` when the answer falls below the WAL
5239    /// horizon and so cannot be replayed.
5240    ///
5241    /// The answer is a **0-based frame index**, ready to hand to `edges_at` or
5242    /// `was_linked` without adjustment.
5243    pub fn resolve_instant(&self, unix_ms: i64) -> Result<u64> {
5244        if self.commit_times_poisoned {
5245            return Err(GraphError::Corrupt {
5246                detail: "commit_times.bin will not decode; date queries are \
5247                         unavailable on this store"
5248                    .into(),
5249            });
5250        }
5251        let at = self
5252            .commit_times
5253            .resolve_instant(unix_ms, self.wal_horizon_floor)?;
5254        // The map outlives the history it describes. A truncating snapshot folds
5255        // the WAL and discards it, so entries can name commits the engine can no
5256        // longer replay — the floor check above catches pruning, and this catches
5257        // discarding. Returning an index the caller's next call will reject is a
5258        // two-step error where one will do, and `resolve_date` is public: it
5259        // either hands back a usable index or refuses.
5260        let total = self.wal_total_commits()?;
5261        if at >= total {
5262            return Err(GraphError::CommitOutOfRange {
5263                commit: at,
5264                total,
5265                floor: self.wal_horizon_floor,
5266            });
5267        }
5268        Ok(at)
5269    }
5270
5271    /// Record subsequent commits as having happened at `unix_ms`, or pass
5272    /// `None` to go back to the system clock.
5273    ///
5274    /// For **backfilled history**: a mirror importing rows that already carry
5275    /// their own timestamps, or a replay of events that happened months ago.
5276    /// Without this every such commit is stamped "now", so a store holding a
5277    /// year of imported history answers every date question with
5278    /// `TimeBeforeFloor` — the data is there and no date reaches it.
5279    ///
5280    /// Sticky until changed or cleared, because a day of backfilled rows
5281    /// genuinely shares one instant.
5282    ///
5283    /// **Import in chronological order.** A supplied instant earlier than
5284    /// anything already recorded is refused with
5285    /// [`GraphError::CommitTimeNotMonotonic`], because resolution walks commit
5286    /// order: a later commit carrying an earlier time would silently widen every
5287    /// answer after it. Equal is allowed — that is what a shared day means. The
5288    /// live clock is never held to this, so an NTP step backwards still commits.
5289    ///
5290    /// Deliberately **not** exposed over HTTP or MCP: asserting when a commit
5291    /// happened rewrites the store's apparent history, which is not something a
5292    /// role token models. It is an embedding-caller's operation.
5293    pub fn record_commits_at(&mut self, unix_ms: Option<i64>) -> Result<()> {
5294        if self.read_only {
5295            return Err(GraphError::ReadOnly);
5296        }
5297        if let Some(ms) = unix_ms {
5298            if self.commit_times_poisoned {
5299                return Err(GraphError::Corrupt {
5300                    detail: "commit_times.bin will not decode; this store cannot \
5301                             record an asserted commit time"
5302                        .into(),
5303                });
5304            }
5305            if let Some(newest) = self.commit_times.max_ms() {
5306                if ms < newest {
5307                    return Err(GraphError::CommitTimeNotMonotonic {
5308                        supplied_ms: ms,
5309                        newest_ms: newest,
5310                    });
5311                }
5312            }
5313        }
5314        self.commit_time_override = unix_ms;
5315        Ok(())
5316    }
5317
5318    /// The instant subsequent commits are being recorded at, when one is set.
5319    pub fn commit_time_override(&self) -> Option<i64> {
5320        self.commit_time_override
5321    }
5322
5323    /// [`Self::edges_at`] addressed by an instant rather than a commit index.
5324    ///
5325    /// Resolves through [`Self::resolve_instant`] — the last commit at or
5326    /// before the instant — then answers exactly as the commit-indexed call
5327    /// does. A store that records no times refuses by name; it never guesses.
5328    pub fn edges_at_instant(&self, key: &str, unix_ms: i64) -> Result<Vec<EdgeAt>> {
5329        let commit = self.resolve_instant(unix_ms)?;
5330        self.edges_at(key, commit)
5331    }
5332
5333    /// [`Self::was_linked`] addressed by an instant rather than a commit index.
5334    pub fn was_linked_at_instant(
5335        &self,
5336        a: &str,
5337        b: &str,
5338        edge_type: &str,
5339        unix_ms: i64,
5340    ) -> Result<bool> {
5341        let commit = self.resolve_instant(unix_ms)?;
5342        self.was_linked(a, b, edge_type, commit)
5343    }
5344
5345    /// Parse an RFC 3339 instant (or a bare `YYYY-MM-DD`) and resolve it.
5346    ///
5347    /// The one place every caller-facing surface converts a date string, so
5348    /// HTTP, MCP, Python and the CLI cannot drift in what they accept.
5349    pub fn resolve_date(&self, s: &str) -> Result<u64> {
5350        // The **end** of what the string denotes. A bare date is a day, so it
5351        // resolves to the last commit at or before that day's end — resolving to
5352        // the midnight that starts it would exclude everything that happened on
5353        // the date the caller asked about.
5354        let ms = core_storage::commit_times::parse_rfc3339_end_ms(s).ok_or_else(|| {
5355            GraphError::QueryError {
5356                detail: format!(
5357                    "could not parse {s:?} as a date; expected RFC 3339 \
5358                     (2026-06-19, or 2026-06-19T12:00:00Z)"
5359                ),
5360            }
5361        })?;
5362        self.resolve_instant(ms)
5363    }
5364
5365    /// The recorded wall-clock time of `commit`, when the sidecar holds one.
5366    ///
5367    /// `commit` is a **0-based frame index** — the space `edges_at`,
5368    /// `was_linked` and the history events use, not the 1-based `commit_seq`.
5369    pub fn commit_time_ms(&self, commit: u64) -> Option<i64> {
5370        if self.commit_times_poisoned {
5371            return None;
5372        }
5373        self.commit_times.time_of(commit)
5374    }
5375
5376    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
5377        self.log_then_apply_with(rec, None, self.fsync)
5378    }
5379
5380    /// Whether this frame must fsync under `policy`.
5381    ///
5382    /// Batched contract: user-visible batches (>1 mutation) fsync; single
5383    /// mutations do not. The dense rewrite wraps a single mutation in a
5384    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
5385    /// from the count — removing that filter would make every single-op write
5386    /// fsync under Batched (or, if the threshold were raised instead, skip a
5387    /// needed fsync for real two-op batches).
5388    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
5389        match policy {
5390            FsyncPolicy::Relaxed => false,
5391            FsyncPolicy::Strict => true,
5392            FsyncPolicy::Batched => match rec {
5393                // Intern + one mutation is the single-op rewrite, not a user batch.
5394                WalRecord::Batch(inner) => {
5395                    inner
5396                        .iter()
5397                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
5398                        .count()
5399                        > 1
5400                }
5401                _ => false,
5402            },
5403        }
5404    }
5405
5406    /// # Apply-infallibility invariant (load-bearing)
5407    ///
5408    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
5409    /// for a `Batch` frame after a successful WAL write, the WAL would contain
5410    /// the full frame while in-memory state would reflect only the ops before
5411    /// the failure. On reopen, WAL replay would then apply the entire batch —
5412    /// diverging permanently from what the pre-crash process had in memory.
5413    ///
5414    /// For `Batch` frames this situation cannot arise because:
5415    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
5416    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
5417    ///   use, with no concurrent mutation between validation exit and apply entry.
5418    /// - Every `apply` arm for a validated op is either infallible by construction
5419    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
5420    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
5421    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
5422    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
5423    ///
5424    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
5425    /// `Err` for a `Batch` frame, making any future regression immediately visible
5426    /// in tests rather than silently diverging crash-recovery behaviour.
5427    fn log_then_apply_with(
5428        &mut self,
5429        rec: WalRecord,
5430        ingest: Option<(String, usize)>,
5431        policy: FsyncPolicy,
5432    ) -> Result<()> {
5433        // Read-only guard: as-of instances must never write the WAL.
5434        if self.read_only {
5435            return Err(GraphError::ReadOnly);
5436        }
5437        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
5438        // partway; in-memory state is ahead of (or out of step with) the
5439        // on-disk WAL, so further mutations would deepen the divergence.
5440        // Reopen the database to recover.  Checked before the lock guard: this
5441        // is the more serious condition and the more useful error.
5442        if self.degraded {
5443            return Err(GraphError::Io(std::io::Error::other(
5444                "database degraded after group-commit fsync failure; reopen required",
5445            )));
5446        }
5447        // Cross-process guard: this write scope asked for the store's write
5448        // lock and did not get it. Writing anyway would append frames on top of
5449        // a WAL another process is extending, so refuse instead.
5450        if self.lock_denied {
5451            return Err(GraphError::Busy { holder: None });
5452        }
5453        // Ensure retained provenance bytes are decoded into the live mutable
5454        // fields before any mutation touches self.engine.provenance.  This is a
5455        // no-op if provenance was never stored (fresh store) or has already been
5456        // consumed (subsequent mutations).  WAL replay calls apply() directly
5457        // and is covered by consume_retained_state_eager before replay.
5458        self.ensure_v8_base_sections_loaded();
5459        self.engine.ensure_provenance_loaded_mut();
5460        // Invariant (I-1): no stale deltas may enter from a previous apply.
5461        // If any engine method ever accumulates deltas before erroring, they would
5462        // contaminate the *next* commit's event stream. This assert fires in debug
5463        // builds, making any future regression visible at the earliest point.
5464        debug_assert_eq!(
5465            self.engine.pending_delta_count(),
5466            0,
5467            "stale engine deltas at log_then_apply_with entry — \
5468             a previous apply arm may have accumulated deltas before erroring; \
5469             the caller must drain_deltas() on any error path before returning"
5470        );
5471        let frame = encode_record(&rec);
5472        self.fs.append(FileId::Wal, &frame)?;
5473        // The cursor advances by exactly the bytes appended: these frames are
5474        // ours and already applied, so a later refresh must not replay them.
5475        self.wal_consumed += frame.len() as u64;
5476        self.wal_frames_written += 1;
5477        if Self::wal_needs_sync(policy, &rec) {
5478            self.fs.sync(FileId::Wal)?;
5479        }
5480        // Marker writing always needs the engine deltas, but the engine only
5481        // accumulates them when emit_deltas is true (normally gated on subscribers
5482        // or views being present).  Enable emission for this apply if it is
5483        // currently off, then restore the original state unconditionally via an
5484        // RAII guard — this prevents a panic in apply() from leaking the flag.
5485        // The same guard resets the engine's transient chaining state. A panic
5486        // unwinding out of a rule hook would otherwise leave `chain_depth`
5487        // non-zero, which makes every later `begin_chain` decide chaining is
5488        // already running and silently switch it off for good.
5489        struct RestoreEmitDeltas(*mut RuleEngine, bool);
5490        impl Drop for RestoreEmitDeltas {
5491            fn drop(&mut self) {
5492                // SAFETY: pointer into self (GraphDb); guard is dropped within
5493                // this frame before log_then_apply_with returns.
5494                unsafe {
5495                    (*self.0).set_emit_deltas(self.1);
5496                    (*self.0).reset_chain_state();
5497                }
5498            }
5499        }
5500        let original_emit = self.engine.emit_deltas();
5501        if !original_emit {
5502            self.engine.set_emit_deltas(true);
5503        }
5504        // SAFETY: raw pointer into self; guard dropped within this frame.
5505        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
5506
5507        let apply_result = self.apply(&rec);
5508        // For Batch frames, post-validation apply must be infallible (see above).
5509        // A debug_assert here catches any future change that makes apply fallible
5510        // before the caller notices via silent WAL/memory divergence.
5511        if matches!(&rec, WalRecord::Batch(_)) {
5512            debug_assert!(
5513                apply_result.is_ok(),
5514                "Batch apply returned Err after successful WAL write — \
5515                 the validate-then-apply invariant has been violated; \
5516                 see log_then_apply_with invariant doc"
5517            );
5518        }
5519        if apply_result.is_err() {
5520            // Discard any partial deltas accumulated by the failed apply.
5521            // They must not ride the next commit's event stream (I-1).
5522            // _emit_guard restores emit_deltas on drop automatically.
5523            let _ = self.engine.drain_deltas();
5524            let _ = self.engine.take_rebuild_needed();
5525            apply_result?;
5526        }
5527        self.commit_seq += 1;
5528        let seq = self.commit_seq;
5529        // Update per-node last-change map for the committed record.
5530        // Must happen after commit_seq is incremented so the seq is correct.
5531        self.update_last_change_from_rec(&rec, seq);
5532        // Drain engine deltas and distribute to subscribers before the existing
5533        // MutationEvent sink fires — both happen post-fsync, post-apply.
5534        // _emit_guard restores emit_deltas after this line when it drops.
5535        let engine_deltas = self.engine.drain_deltas();
5536
5537        // Append history-marker WAL records for any derived-edge changes so
5538        // that `edge_history` and `was_linked` can surface rule-attributed
5539        // events. Markers are STATE NO-OPS during replay; they are written
5540        // without an additional fsync (the triggering commit's sync already
5541        // happened; the next commit's sync covers these lazily).
5542        if !engine_deltas.is_empty() {
5543            let markers: Vec<WalRecord> = engine_deltas
5544                .iter()
5545                .map(|d| {
5546                    if d.fired {
5547                        WalRecord::DerivedEdgeAdded {
5548                            rule: d.rule.clone(),
5549                            edge_type: d.edge_type.clone(),
5550                            src_key: d.src_key.clone(),
5551                            dst_key: d.dst_key.clone(),
5552                        }
5553                    } else {
5554                        WalRecord::DerivedEdgeRetracted {
5555                            rule: d.rule.clone(),
5556                            edge_type: d.edge_type.clone(),
5557                            src_key: d.src_key.clone(),
5558                            dst_key: d.dst_key.clone(),
5559                        }
5560                    }
5561                })
5562                .collect();
5563            let marker_frame = if markers.len() == 1 {
5564                markers.into_iter().next().unwrap()
5565            } else {
5566                WalRecord::Batch(markers)
5567            };
5568            // Ignore append errors: markers are best-effort history
5569            // annotations. Losing them does not affect state correctness.
5570            // The cursor only advances when the bytes actually landed.
5571            let marker_bytes = encode_record(&marker_frame);
5572            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
5573                self.wal_consumed += marker_bytes.len() as u64;
5574                // A marker is a state no-op during replay but it is not an
5575                // index no-op: it occupies a frame that every history surface
5576                // counts. Missing this increment is the whole of defect 1.
5577                self.wal_frames_written += 1;
5578            }
5579        }
5580
5581        // Stamp the commit against the **last** frame it wrote.
5582        //
5583        // `edges_at` and `was_linked` reconstruct derived edges by reading the
5584        // history markers out of the WAL, not by re-running rules over a
5585        // prefix. A commit's complete state — its record *and* the edges its
5586        // rules derived — is therefore only reached at its marker frame, so
5587        // that is the frame a date naming this commit must resolve to. Stamping
5588        // the record's own frame would answer every date with the graph as it
5589        // was one derivation short.
5590        //
5591        // This runs after the marker append for that reason, and it is still
5592        // the single stamping site: one commit, one entry.
5593        self.stamp_commit_time(self.wal_frames_written - 1);
5594
5595        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
5596        // stored as-is (including any nested Batch / Intern records); the
5597        // ReaderSnapshot's apply_one function handles all variants.
5598        {
5599            let derived_inserts = engine_deltas
5600                .iter()
5601                .filter(|d| d.fired)
5602                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5603                .collect();
5604            let derived_deletes = engine_deltas
5605                .iter()
5606                .filter(|d| !d.fired)
5607                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5608                .collect();
5609            let delta = Arc::new(crate::reader::CommitDelta {
5610                records: vec![rec.clone()],
5611                derived_inserts,
5612                derived_deletes,
5613            });
5614            self.delta_tail.push(delta);
5615            self.commits_since_fold += 1;
5616            if self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
5617                self.fold_now();
5618            }
5619        }
5620
5621        if self.defer_events {
5622            // Group-commit drain thread: hold events until after the group
5623            // fsync so subscribers only observe durable data (R2).
5624            self.deferred_events.push(DeferredEvent {
5625                rec: rec.clone(),
5626                engine_deltas,
5627                seq,
5628                ingest,
5629            });
5630        } else {
5631            self.distribute_events(&rec, &engine_deltas, seq);
5632            self.emit_committed(&rec, ingest);
5633        }
5634        // Drift is only known after apply, so auto-rebuild cannot join the
5635        // triggering op's WAL frame. Issue RebuildRule as a second commit.
5636        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
5637        // retrigger loop is impossible if the fit succeeded, but we still
5638        // drain the flag so a leftover cannot re-enter.
5639        // One slice of any outstanding vector-index build rides here too, so a
5640        // store that is being written to finishes its build without anyone
5641        // calling `pump_index_build`. A rule that becomes whole joins the same
5642        // RebuildRule loop below.
5643        let mut rebuilds = self.engine.take_rebuild_needed();
5644        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
5645            // Not after `CreateRule`: that record's own apply already did the
5646            // rule's first slice, and pumping again here would make one
5647            // `create_rule` call do two slices' work under one lock.
5648            // Nothing pending is the overwhelmingly common case and must cost
5649            // a map lookup, not an engine swap: a store being written to has
5650            // long since populated its indexes, so the `pump_index_build`
5651            // entry point owns the not-yet-populated case on its own.
5652            if !is_create_rule_frame(&rec) && !self.engine.builds_in_progress().is_empty() {
5653                rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
5654            }
5655            let mut failed = Vec::new();
5656            for name in rebuilds {
5657                if self.engine.rules().any(|r| r.name == name) {
5658                    // User op is already durable. A failed second commit must
5659                    // not surface as the caller's error.
5660                    if let Err(e) =
5661                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
5662                    {
5663                        eprintln!(
5664                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
5665                        );
5666                        failed.push(name);
5667                    }
5668                }
5669            }
5670            for name in failed {
5671                self.engine.queue_rebuild_needed(name);
5672            }
5673        }
5674        Ok(())
5675    }
5676
5677    /// Install a post-commit hook. Replaces any previous sink.
5678    ///
5679    /// The sink runs inside `log_then_apply` after a successful
5680    /// durable commit, while the caller still holds `&mut self`. When this
5681    /// database is behind a [`crate::SharedDb`], that means the **write
5682    /// guard is held**. The sink must never call `read` / `write` (or any
5683    /// other method) on the same `SharedDb` — the `RwLock` is not
5684    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
5685    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
5686    /// Intended examples: `std::sync::mpsc::SyncSender`,
5687    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
5688    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
5689    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
5690        self.event_sink = Some(sink);
5691    }
5692
5693    /// Whether a post-commit event sink is currently installed.
5694    pub fn has_event_sink(&self) -> bool {
5695        self.event_sink.is_some()
5696    }
5697
5698    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
5699    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
5700        self.fsync = p;
5701    }
5702
5703    /// Return the current WAL fsync cadence.
5704    pub fn fsync_policy(&self) -> FsyncPolicy {
5705        self.fsync
5706    }
5707
5708    // ── Group-commit event deferral ───────────────────────────────────────────
5709
5710    /// Enable or disable deferred event mode.
5711    ///
5712    /// When `true`, event notifications (subscription `DbEvent`s and legacy
5713    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
5714    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
5715    /// or [`discard_deferred_events`] if the fsync failed and the group must
5716    /// be treated as lost.
5717    pub fn set_deferred_events_mode(&mut self, defer: bool) {
5718        self.defer_events = defer;
5719    }
5720
5721    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
5722    /// was set to true.  Clears the buffer.
5723    ///
5724    /// Called by the drain thread AFTER a successful group fsync, so
5725    /// subscribers observe only data that is durably on disk.
5726    pub fn flush_deferred_events(&mut self) {
5727        let events = std::mem::take(&mut self.deferred_events);
5728        for de in events {
5729            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
5730            self.emit_committed(&de.rec, de.ingest);
5731        }
5732    }
5733
5734    /// Discard all buffered events without firing them.
5735    ///
5736    /// Called by the drain thread when a group fsync fails: the WAL has been
5737    /// truncated back to the pre-group offset, so the committed-but-unsynced
5738    /// ops must not be observable to subscribers.
5739    pub fn discard_deferred_events(&mut self) {
5740        self.deferred_events.clear();
5741    }
5742
5743    // ── Degraded state ────────────────────────────────────────────────────────
5744
5745    /// Mark this database as degraded.
5746    ///
5747    /// Called by the group-commit drain thread after a group fsync failure and
5748    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
5749    /// further mutations would deepen the divergence.  All subsequent calls to
5750    /// [`log_then_apply_with`] return `Err` until the database is reopened.
5751    pub fn set_degraded(&mut self) {
5752        self.degraded = true;
5753    }
5754
5755    fn emit(&self, ev: MutationEvent) {
5756        if let Some(sink) = &self.event_sink {
5757            sink(ev);
5758        }
5759    }
5760
5761    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
5762        match rec {
5763            WalRecord::Batch(inner) => {
5764                for r in inner {
5765                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
5766                        self.emit(ev);
5767                    }
5768                }
5769                match ingest {
5770                    Some((label, inserted)) => {
5771                        self.emit(MutationEvent::Ingested { label, inserted })
5772                    }
5773                    None => {
5774                        let ops = inner
5775                            .iter()
5776                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
5777                            .count();
5778                        if ops > 1 {
5779                            self.emit(MutationEvent::BatchApplied { ops });
5780                        }
5781                    }
5782                }
5783            }
5784            other => {
5785                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
5786                    self.emit(ev);
5787                }
5788            }
5789        }
5790    }
5791
5792    // -----------------------------------------------------------------------
5793    // Subscription API
5794    // -----------------------------------------------------------------------
5795
5796    /// Distribute post-commit events to all live subscribers.
5797    ///
5798    /// Build a row-key → row-data map from a [`ResultSet`].
5799    ///
5800    /// Each row is serialized to JSON to form its key; a debug fallback is used
5801    /// if serialization fails. Used by both the initial-seed path in
5802    /// [`Self::subscribe_query`] and the per-commit diff path in
5803    /// [`Self::distribute_events`] to keep the two in sync.
5804    fn result_to_row_map(
5805        result: &core_query::ResultSet,
5806    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
5807        (0..result.len())
5808            .map(|i| {
5809                let row = result.row(i).to_vec();
5810                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
5811                (key, row)
5812            })
5813            .collect()
5814    }
5815
5816    /// Collect the set of label syms touched by a WAL record.
5817    ///
5818    /// Returns `Some(set)` when every record in this commit can be attributed to
5819    /// a known label sym. Returns `None` when the commit must not be skipped:
5820    /// edge records, unresolvable key→label lookups, or any record type not in
5821    /// the explicit handled set.
5822    ///
5823    /// Handled record types and their actions:
5824    /// - `InsertNode`   → look up label in interner (fails → None)
5825    /// - `InsertNodeId` → label sym is carried directly
5826    /// - `SetProp`      → resolve key→id→label (fails → None)
5827    /// - `DeleteNode`   → resolve key→id→label (fails → None)
5828    /// - `Batch`        → recurse into every inner record
5829    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
5830    /// - everything else → None (conservative)
5831    fn commit_touched_labels(
5832        rec: &WalRecord,
5833        syms: &Interner,
5834        ids: &IdMap,
5835        labels: &[u32],
5836    ) -> Option<BTreeSet<u32>> {
5837        let mut out = BTreeSet::new();
5838        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
5839            Some(out)
5840        } else {
5841            None
5842        }
5843    }
5844
5845    fn collect_touched_labels(
5846        rec: &WalRecord,
5847        syms: &Interner,
5848        ids: &IdMap,
5849        labels: &[u32],
5850        out: &mut BTreeSet<u32>,
5851    ) -> bool {
5852        match rec {
5853            // String-key insert: the dense rewrite converts this to
5854            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
5855            // records written before the dense path was added.
5856            WalRecord::InsertNode { label, .. } => {
5857                if let Some(sym) = syms.get(label) {
5858                    out.insert(sym);
5859                    true
5860                } else {
5861                    false
5862                }
5863            }
5864            // Dense-id insert (produced by rewrite_wal_dense for every
5865            // insert_node call in the current codebase).
5866            WalRecord::InsertNodeId { label, .. } => {
5867                out.insert(*label);
5868                true
5869            }
5870            // String-key prop set: dense path converts to [Intern, SetPropId].
5871            WalRecord::SetProp { key, .. } => {
5872                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
5873                    out.insert(sym);
5874                    true
5875                } else {
5876                    false
5877                }
5878            }
5879            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
5880            WalRecord::SetPropId { id, .. } => {
5881                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
5882                    out.insert(sym);
5883                    true
5884                } else {
5885                    false
5886                }
5887            }
5888            WalRecord::DeleteNode { key } => {
5889                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
5890                    out.insert(sym);
5891                    true
5892                } else {
5893                    false
5894                }
5895            }
5896            WalRecord::Batch(inner) => inner
5897                .iter()
5898                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
5899            // Intern is a pure metadata record — it does not touch any node's
5900            // label and is safe to skip for the label-skip predicate.
5901            WalRecord::Intern { .. } => true,
5902            // Edge records: always re-execute (edges can change join results).
5903            WalRecord::InsertEdge { .. }
5904            | WalRecord::DeleteEdge { .. }
5905            | WalRecord::InsertEdgeId { .. } => false,
5906            _ => false,
5907        }
5908    }
5909
5910    /// Resolve a node key to its label sym via the dense id table.
5911    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
5912    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
5913        let id = ids.get(key)?;
5914        let sym = labels.get(id as usize).copied()?;
5915        (sym != u32::MAX).then_some(sym)
5916    }
5917
5918    /// Distribute post-commit events to all live subscribers.
5919    ///
5920    /// Called from `log_then_apply_with` after apply + fsync, before the
5921    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
5922    ///
5923    /// Query subscriptions (subscribe_query) re-execute their plan on every
5924    /// call and diff the result against the previous run. Zero overhead when
5925    /// no query subscriptions are active.
5926    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
5927        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
5928            return;
5929        }
5930
5931        if !self.subscriptions.is_empty() {
5932            // Build write events from the WAL record.
5933            let write_events: Vec<DbEvent> =
5934                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
5935
5936            // Build edge events from engine deltas.  Weight is looked up from
5937            // edge_props at distribution time (after apply), so it's always fresh.
5938            let edge_events: Vec<DbEvent> = engine_deltas
5939                .iter()
5940                .map(|d| {
5941                    if d.fired {
5942                        // The score lives under the rule's declared weight_prop,
5943                        // which is not always the literal "weight".
5944                        let prop = self
5945                            .engine
5946                            .rules()
5947                            .find(|r| r.name == d.rule)
5948                            .and_then(|r| r.weight_prop.as_deref());
5949                        let weight = prop.and_then(|p| {
5950                            self.edge_props
5951                                .get(d.etype_sym, d.src_id, d.dst_id, p)
5952                                .and_then(|v| {
5953                                    if let core_storage::Value::Float(f) = v {
5954                                        Some(*f)
5955                                    } else {
5956                                        None
5957                                    }
5958                                })
5959                        });
5960                        DbEvent::EdgeFired {
5961                            rule: d.rule.clone(),
5962                            src_key: d.src_key.clone(),
5963                            dst_key: d.dst_key.clone(),
5964                            edge_type: d.edge_type.clone(),
5965                            weight,
5966                            commit_seq: seq,
5967                        }
5968                    } else {
5969                        DbEvent::EdgeRetracted {
5970                            rule: d.rule.clone(),
5971                            src_key: d.src_key.clone(),
5972                            dst_key: d.dst_key.clone(),
5973                            edge_type: d.edge_type.clone(),
5974                            commit_seq: seq,
5975                        }
5976                    }
5977                })
5978                .collect();
5979
5980            // Prune dead entries; push matching events to live ones.
5981            self.subscriptions.retain(|entry| {
5982                let Some(inner) = entry.inner.upgrade() else {
5983                    return false;
5984                };
5985                for ev in &write_events {
5986                    if event_matches(ev, &entry.filter) {
5987                        inner.push(ev.clone());
5988                    }
5989                }
5990                for ev in &edge_events {
5991                    if event_matches(ev, &entry.filter) {
5992                        inner.push(ev.clone());
5993                    }
5994                }
5995                true
5996            });
5997
5998            // Turn off delta accumulation if all subscribers dropped and no views remain.
5999            if self.subscriptions.is_empty() && self.view_store.is_empty() {
6000                self.engine.set_emit_deltas(false);
6001            }
6002        }
6003
6004        // Query subscriptions: full re-run per commit, then diff rows.
6005        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
6006        // Differential evaluation is roadmap / Phase 5.
6007        if !self.query_subscriptions.is_empty() {
6008            // Take the list out so we can call self.view() without borrow conflict.
6009            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
6010            let empty_params = BTreeMap::new();
6011            query_subs.retain_mut(|entry| {
6012                let Some(inner) = entry.inner.upgrade() else {
6013                    return false; // subscriber dropped — prune
6014                };
6015                // Label-skip: if the plan has a known scan label and this commit
6016                // can be proven to touch only different labels (and no rule-derived
6017                // edge deltas fired), the result set cannot have changed — skip.
6018                if let Some(scan_sym) = entry.scan_label {
6019                    if engine_deltas.is_empty() {
6020                        let touched =
6021                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
6022                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
6023                            return true; // safe to skip — result set unchanged
6024                        }
6025                    }
6026                }
6027                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
6028                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
6029                    Ok(r) => r,
6030                    Err(e) => {
6031                        // Keep the subscription alive; skip the diff for this commit.
6032                        // Re-run errors are transient (e.g., planner change) and
6033                        // self-heal when the next commit succeeds.
6034                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
6035                        return true;
6036                    }
6037                };
6038                // Build new row map: serialized-key → row data.
6039                let new_row_map = Self::result_to_row_map(&result);
6040                // Removed rows: in prev but not in new.
6041                for (key, row) in &entry.prev_row_map {
6042                    if !new_row_map.contains_key(key) {
6043                        inner.push(DbEvent::QueryRowRemoved {
6044                            columns: entry.columns.clone(),
6045                            row: row.clone(),
6046                        });
6047                    }
6048                }
6049                // Added rows: in new but not in prev.
6050                for (key, row) in &new_row_map {
6051                    if !entry.prev_row_map.contains_key(key) {
6052                        inner.push(DbEvent::QueryRowAdded {
6053                            columns: entry.columns.clone(),
6054                            row: row.clone(),
6055                        });
6056                    }
6057                }
6058                entry.prev_row_map = new_row_map;
6059                true
6060            });
6061            self.query_subscriptions = query_subs;
6062        }
6063    }
6064
6065    /// Returns `true` if any live subscriber or view definition requires delta
6066    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
6067    fn needs_emit_deltas(&self) -> bool {
6068        !self.view_store.is_empty()
6069            || self
6070                .subscriptions
6071                .iter()
6072                .any(|e| e.inner.upgrade().is_some())
6073    }
6074
6075    /// Convert a WAL record into `DbEvent` write events with the given seq.
6076    fn write_events_from_record(
6077        rec: &WalRecord,
6078        seq: u64,
6079        intern: &Interner,
6080        ids: &IdMap,
6081    ) -> Vec<DbEvent> {
6082        match rec {
6083            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
6084                label: label.clone(),
6085                key: key.clone(),
6086                commit_seq: seq,
6087            }],
6088            // *Id arms run after a successful apply, so resolution can only
6089            // fail on a programming error. Skip the event rather than emit a
6090            // fabricated "" that clients can't tell from a real empty value
6091            // (mirrors event_from_record returning None).
6092            WalRecord::InsertNodeId { label, key, .. } => intern
6093                .resolve(*label)
6094                .map(|label| DbEvent::NodeInserted {
6095                    label: label.to_string(),
6096                    key: key.clone(),
6097                    commit_seq: seq,
6098                })
6099                .into_iter()
6100                .collect(),
6101            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
6102                key: key.clone(),
6103                field: field.clone(),
6104                commit_seq: seq,
6105            }],
6106            WalRecord::SetPropId { id, field, .. } => ids
6107                .key_of(*id)
6108                .zip(intern.resolve(*field))
6109                .map(|(key, field)| DbEvent::PropSet {
6110                    key: key.to_string(),
6111                    field: field.to_string(),
6112                    commit_seq: seq,
6113                })
6114                .into_iter()
6115                .collect(),
6116            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
6117                key: key.clone(),
6118                field: field.clone(),
6119                commit_seq: seq,
6120            }],
6121            WalRecord::InsertEdge {
6122                edge_type,
6123                src_key,
6124                dst_key,
6125            } => vec![DbEvent::EdgeInserted {
6126                edge_type: edge_type.clone(),
6127                src: src_key.clone(),
6128                dst: dst_key.clone(),
6129                commit_seq: seq,
6130            }],
6131            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
6132                Some(DbEvent::EdgeInserted {
6133                    edge_type: intern.resolve(*etype)?.to_string(),
6134                    src: ids.key_of(*src)?.to_string(),
6135                    dst: ids.key_of(*dst)?.to_string(),
6136                    commit_seq: seq,
6137                })
6138            })()
6139            .into_iter()
6140            .collect(),
6141            WalRecord::DeleteEdge {
6142                edge_type,
6143                src_key,
6144                dst_key,
6145            } => vec![DbEvent::EdgeDeleted {
6146                edge_type: edge_type.clone(),
6147                src: src_key.clone(),
6148                dst: dst_key.clone(),
6149                commit_seq: seq,
6150            }],
6151            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
6152                key: key.clone(),
6153                commit_seq: seq,
6154            }],
6155            WalRecord::Batch(inner) => inner
6156                .iter()
6157                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
6158                .collect(),
6159            WalRecord::CreateRule { .. }
6160            | WalRecord::DeleteRule { .. }
6161            | WalRecord::RebuildRule { .. }
6162            | WalRecord::CreateView { .. }
6163            | WalRecord::DeleteView { .. }
6164            | WalRecord::EnableFulltext { .. }
6165            | WalRecord::DisableFulltext { .. }
6166            | WalRecord::EnableIndex { .. }
6167            | WalRecord::DisableIndex { .. }
6168            | WalRecord::Intern { .. }
6169            // History markers produce no DbEvent — the engine delta already
6170            // fired the EdgeFired/EdgeRetracted subscription events.
6171            | WalRecord::DerivedEdgeAdded { .. }
6172            | WalRecord::DerivedEdgeRetracted { .. }
6173            // A count is not an edge event: the pair it counts already fired one
6174            // when it was first inserted.
6175            | WalRecord::SetEdgeCount { .. }
6176            | WalRecord::RenameNode { .. } => vec![],
6177        }
6178    }
6179
6180    /// Subscribe to edge-fire and edge-retract events for one named rule.
6181    ///
6182    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
6183    /// currently registered. Dropping the returned [`Subscription`] handle
6184    /// unregisters the subscriber — no further events are queued, no
6185    /// resources leak.
6186    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
6187        if self.read_only {
6188            return Err(core_storage::GraphError::ReadOnly);
6189        }
6190        if !self.engine.rules().any(|r| r.name == rule_name) {
6191            return Err(core_storage::GraphError::RuleNotFound {
6192                name: rule_name.to_string(),
6193            });
6194        }
6195        let inner = SubInner::new(self.sub_capacity());
6196        self.subscriptions.push(SubEntry {
6197            filter: SubFilter::Rule(rule_name.to_string()),
6198            inner: std::sync::Arc::downgrade(&inner),
6199        });
6200        self.engine.set_emit_deltas(true);
6201        Ok(Subscription(inner))
6202    }
6203
6204    /// Subscribe to edge-fire and edge-retract events for **all** rules.
6205    ///
6206    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6207    /// as-of instances never commit, so `distribute_events` never runs and the
6208    /// subscription would never deliver events.
6209    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
6210        if self.read_only {
6211            return Err(core_storage::GraphError::ReadOnly);
6212        }
6213        let inner = SubInner::new(self.sub_capacity());
6214        self.subscriptions.push(SubEntry {
6215            filter: SubFilter::AllRules,
6216            inner: std::sync::Arc::downgrade(&inner),
6217        });
6218        self.engine.set_emit_deltas(true);
6219        Ok(Subscription(inner))
6220    }
6221
6222    /// Subscribe to write events: node insert/delete, prop set/remove.
6223    ///
6224    /// Does not include edge-fire / edge-retract (rule-derived edge events).
6225    ///
6226    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6227    /// as-of instances never commit, so `distribute_events` never runs and the
6228    /// subscription would never deliver events.
6229    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
6230        if self.read_only {
6231            return Err(core_storage::GraphError::ReadOnly);
6232        }
6233        let inner = SubInner::new(self.sub_capacity());
6234        self.subscriptions.push(SubEntry {
6235            filter: SubFilter::Writes,
6236            inner: std::sync::Arc::downgrade(&inner),
6237        });
6238        self.engine.set_emit_deltas(true);
6239        Ok(Subscription(inner))
6240    }
6241
6242    /// Subscribe to incremental Cypher query results.
6243    ///
6244    /// Parses and plans `cypher`; rejects the query if the plan is not in the
6245    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
6246    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
6247    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
6248    ///
6249    /// SKIP is not supported — it shifts the result window on every commit,
6250    /// causing spurious Added/Removed churn for rows whose data never changed.
6251    /// Multi-hop Expand chains are not supported; each additional MATCH clause
6252    /// widens scope beyond the documented single-scan / single-hop subset.
6253    ///
6254    /// After each successful commit, the plan is **fully re-executed** and the
6255    /// result is diffed against the previous run. Added rows produce
6256    /// [`DbEvent::QueryRowAdded`]; removed rows produce
6257    /// [`DbEvent::QueryRowRemoved`].
6258    ///
6259    /// **Full re-run per commit; use LIMIT to bound execution cost.**
6260    /// The existing 1 M intermediate-row cap applies. Differential evaluation
6261    /// is roadmap / Phase 5.
6262    ///
6263    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6264    /// as-of instances never commit, so `distribute_events` never runs and the
6265    /// subscription would never deliver events.
6266    ///
6267    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
6268    /// or if the plan shape is not in the allowlist.
6269    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
6270        if self.read_only {
6271            return Err(GraphError::ReadOnly);
6272        }
6273        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6274            detail: format!("lex: {e}"),
6275        })?;
6276        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
6277            detail: format!("parse: {e}"),
6278        })?;
6279        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
6280            detail: format!("plan: {e}"),
6281        })?;
6282        if !is_subscribable(&ops) {
6283            return Err(GraphError::QueryError {
6284                detail: "subscribe_query only supports allowlisted plan shapes: \
6285                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
6286                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
6287                         Not supported: multi-hop Expand chains, SKIP (creates \
6288                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
6289                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
6290                         Use LIMIT to bound re-execution cost."
6291                    .to_string(),
6292            });
6293        }
6294        // Execute once to capture initial state (initial rows are not emitted as
6295        // events — the subscriber learns the baseline via the first query call).
6296        let empty_params = BTreeMap::new();
6297        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
6298            GraphError::QueryError {
6299                detail: format!("execute: {e}"),
6300            }
6301        })?;
6302        let columns = initial.columns().to_vec();
6303        let prev_row_map = Self::result_to_row_map(&initial);
6304        let inner = SubInner::new(self.sub_capacity());
6305        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
6306        // or unrecognized leading scan → None (always re-execute).
6307        let scan_label = extract_scan_label(&ops, Arc::make_mut(&mut self.syms));
6308        self.query_subscriptions.push(QuerySubEntry {
6309            ops,
6310            columns,
6311            prev_row_map,
6312            inner: std::sync::Arc::downgrade(&inner),
6313            scan_label,
6314        });
6315        Ok(Subscription(inner))
6316    }
6317
6318    /// Queue capacity used for new subscriptions.
6319    fn sub_capacity(&self) -> usize {
6320        self.sub_capacity
6321    }
6322
6323    /// Override per-subscriber queue capacity for subsequently created
6324    /// subscriptions on this db instance.
6325    ///
6326    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
6327    /// value in tests to exercise the [`DbEvent::Lagged`] path without
6328    /// generating tens of thousands of events.
6329    ///
6330    /// This is a test-support escape hatch. Calling it in production reduces
6331    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
6332    /// to discourage accidental production use.
6333    #[doc(hidden)]
6334    pub fn set_sub_capacity(&mut self, capacity: usize) {
6335        self.sub_capacity = capacity;
6336    }
6337
6338    // -----------------------------------------------------------------------
6339
6340    /// Start an atomic batch.
6341    ///
6342    /// The returned [`BatchBuilder`] borrows `self` mutably until
6343    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
6344    /// validation, no WAL I/O. `commit` validates every queued op against
6345    /// live state plus preceding ops in this batch (duplicate key inside
6346    /// the batch is `Err`; an edge between two nodes created earlier in
6347    /// the batch is valid; `delete_node` then insert of the same key is a
6348    /// fresh identity). Validation never mutates the database. Any failure
6349    /// leaves WAL bytes and in-memory state identical to before `commit`.
6350    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
6351    /// and each inner record is applied in order so rules fire per record.
6352    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
6353    ///
6354    /// **Rule-window limitation:** batch validation cannot see edges that a
6355    /// rule created earlier in the *same* batch will derive at apply time, so
6356    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
6357    /// where sequential calls would return `Err(RuleOwned)`. State integrity
6358    /// is unaffected (idempotent apply, provenance intact). Create rules in
6359    /// their own batch, or sequentially, when later ops may touch derived
6360    /// edges.
6361    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
6362        BatchBuilder {
6363            db: self,
6364            ops: Vec::new(),
6365        }
6366    }
6367
6368    /// Closure-style atomic write batch.
6369    ///
6370    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
6371    /// then committing. All ops queued inside `build` are validated in order and
6372    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
6373    /// once per inner record, in order, after commit — semantically identical to
6374    /// sequential single-op writes.
6375    ///
6376    /// **Error semantics — validate-then-apply.** `build` queues ops without
6377    /// touching the database. [`BatchBuilder::commit`] validates every op against
6378    /// live state plus earlier ops in this batch before writing anything. If op N
6379    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
6380    /// entire batch is rejected: no WAL bytes are written and no in-memory state
6381    /// changes. The database is identical to its state before `write_batch` was
6382    /// called.
6383    ///
6384    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
6385    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
6386    /// either fully applied or not at all. However, while applying a committed
6387    /// batch, concurrent readers may observe intermediate states as ops are applied
6388    /// sequentially in memory. There is no interactive transaction isolation in v1.
6389    /// This is documented as "crash-atomic write batches; no interactive
6390    /// transactions or read isolation."
6391    ///
6392    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
6393    /// writes zero WAL bytes and returns `(0, 0)`.
6394    ///
6395    /// # Example
6396    ///
6397    /// ```rust,ignore
6398    /// let (nodes, edges) = db.write_batch(|b| {
6399    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
6400    ///     b.insert_node("Person", "bob", vec![]);
6401    ///     b.insert_edge("KNOWS", "alice", "bob");
6402    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
6403    ///     b.delete_node("old_key");
6404    /// })?;
6405    /// // One fsync; on crash replay: all five ops land or none do.
6406    /// ```
6407    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
6408    where
6409        C: FnOnce(&mut BatchBuilder<'_, F>),
6410    {
6411        let mut b = self.batch();
6412        build(&mut b);
6413        b.commit()
6414    }
6415
6416    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
6417    /// auto-declared KeyMatch rules (if any) first, then the accepted node
6418    /// inserts, so incremental fire sees the new rules. Per-row key problems
6419    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
6420    /// `Err` means nothing was applied.
6421    ///
6422    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
6423    /// distinct source labels sharing an FK field each get their own rule.
6424    pub fn ingest(
6425        &mut self,
6426        label: &str,
6427        rows: Vec<BTreeMap<String, Value>>,
6428        opts: &IngestOptions,
6429    ) -> Result<IngestReport> {
6430        self.ingest_with_edges(label, rows, opts, &[])
6431    }
6432
6433    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
6434    /// A failing edge rejects the whole request; nothing is applied.
6435    pub fn ingest_with_edges(
6436        &mut self,
6437        label: &str,
6438        rows: Vec<BTreeMap<String, Value>>,
6439        opts: &IngestOptions,
6440        edges: &[(String, String, String)],
6441    ) -> Result<IngestReport> {
6442        crate::ingest::run(self, label, rows, opts, edges)
6443    }
6444
6445    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
6446    ///
6447    /// JSON `null` fields are silently omitted (not stored, not a row error).
6448    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
6449    /// Parse failures and a top-level value that is not an array of objects
6450    /// return [`GraphError::IngestError`].
6451    pub fn ingest_json(
6452        &mut self,
6453        label: &str,
6454        json: &str,
6455        opts: &IngestOptions,
6456    ) -> Result<IngestReport> {
6457        crate::ingest::run_json(self, label, json, opts)
6458    }
6459
6460    fn commit_logged_batch(
6461        &mut self,
6462        ops: Vec<BatchOp>,
6463        ingest: Option<(String, usize)>,
6464        // Two-source rule: write_batch_authz threads authz here directly (never
6465        // touches pending_write_authz); query_write_authz sets the field instead
6466        // and passes None.  Only one source is non-None per call.
6467        param_authz: Option<WriteAuthz>,
6468    ) -> Result<BatchOutcome> {
6469        // Read-only guard: catches empty-batch calls before the early-return
6470        // that skips log_then_apply_with, ensuring all mutation entry points fail.
6471        if self.read_only {
6472            return Err(GraphError::ReadOnly);
6473        }
6474        // Ensure provenance is decoded before MutPreview accesses it
6475        // (note_delete_rule / is_rule_owned may call engine.provenance()).
6476        self.engine.ensure_provenance_loaded_mut();
6477
6478        // ── Authz pre-check ──────────────────────────────────────────────────
6479        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
6480        // produces no WAL frame (all-or-nothing at the authz boundary extends
6481        // the existing validate-then-apply contract to role-scope checks).
6482        //
6483        // `batch_created` tracks key→label for nodes created by earlier ops in
6484        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
6485        // as visible without needing to call `self.ids.get` on not-yet-committed
6486        // keys (they won't be there yet).
6487        //
6488        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
6489        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
6490        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
6491        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
6492        if let Some(ref authz) = authz_opt {
6493            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
6494            for op in &ops {
6495                self.check_single_op_authz(authz, op, &batch_created)?;
6496                // Update batch_created after a passing authz check so that
6497                // subsequent ops in this batch see the nodes as "about to exist".
6498                match op {
6499                    BatchOp::InsertNode { label, key, .. } => {
6500                        // Only track genuinely new nodes (absent from the
6501                        // snapshot at authz-check time). A pre-existing visible
6502                        // key would be a DuplicateKey — not a real creation —
6503                        // so MutPreview handles it. Letting it into batch_created
6504                        // would allow a later SetProp to bypass update_labels
6505                        // via the "batch-created → always updatable" ruling
6506                        // (delete+recreate exploit, fix for I1 review round 2).
6507                        //
6508                        // Accepted edge: for a delete+recreate-with-different-
6509                        // label batch, node_status resolves the pre-delete
6510                        // (store) label for any subsequent update checks. This
6511                        // grants no net-new capability — a role that can delete+
6512                        // create can already place arbitrary props via
6513                        // InsertNode's own props field.
6514                        if self.ids.get(key.as_str()).is_none() {
6515                            batch_created.insert(key.clone(), label.clone());
6516                        }
6517                    }
6518                    BatchOp::InsertEdgeUpsert {
6519                        placeholder_label,
6520                        src_key,
6521                        dst_key,
6522                        ..
6523                    } => {
6524                        // Both endpoints will be created if not already in store.
6525                        for ep_key in [src_key, dst_key] {
6526                            if self.ids.get(ep_key.as_str()).is_none()
6527                                && !batch_created.contains_key(ep_key.as_str())
6528                            {
6529                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
6530                            }
6531                        }
6532                    }
6533                    _ => {}
6534                }
6535            }
6536        }
6537
6538        let mut outcome = BatchOutcome::default();
6539        let recs = {
6540            let mut preview = MutPreview::new(self);
6541            let mut recs = Vec::with_capacity(ops.len());
6542            // Which node row we are on, counted over the node-insert ops only.
6543            // A caller that queues its rows in order reads this straight back
6544            // as the index into its own list.
6545            let mut node_row = 0usize;
6546            // Every field name the store knows, which a `Replace` needs to work
6547            // out what it removes. Resolved on the first `Replace` in the frame
6548            // and reused, so N replaces read the field list once, not N times.
6549            let mut store_fields: Option<Vec<String>> = None;
6550            // Duplicate inserts this frame has to count, each paired with the
6551            // position in `recs` it belongs at. The count itself is named in the
6552            // dense rewrite and not here: a duplicate's endpoints and edge type
6553            // may all be created by earlier ops in this same frame, and nothing
6554            // in the frame has a dense id yet. See [`PlannedRec`].
6555            let mut deferred_counts: Vec<(usize, String, String, String)> = Vec::new();
6556            for op in ops {
6557                match op {
6558                    BatchOp::InsertNode { label, key, props } => {
6559                        node_row += 1;
6560                        preview.check_insert_node(&key, &props)?;
6561                        preview.note_insert_node(&label, &key, &props);
6562                        recs.push(WalRecord::InsertNode { label, key, props });
6563                    }
6564                    BatchOp::InsertNodeOnConflict {
6565                        label,
6566                        key,
6567                        props,
6568                        on_conflict,
6569                    } => {
6570                        let row = node_row;
6571                        node_row += 1;
6572                        if !preview.has_key(&key) {
6573                            // No conflict: an ordinary insert on any policy —
6574                            // except that a supplied view-owned field is the
6575                            // same mistake here as on a taken key, and gets the
6576                            // same row error rather than a frame error. Without
6577                            // this, one op answered one request two ways
6578                            // depending on whether the store already had the
6579                            // key (defect #19).
6580                            if let Some(why) = preview.supplied_view_owned_prop(&key, &props) {
6581                                outcome.row_errors.push((row, why));
6582                                continue;
6583                            }
6584                            preview.note_insert_node(&label, &key, &props);
6585                            recs.push(WalRecord::InsertNode { label, key, props });
6586                            continue;
6587                        }
6588                        match on_conflict {
6589                            OnConflict::Error => {
6590                                return Err(GraphError::DuplicateKey { key });
6591                            }
6592                            OnConflict::Skip => outcome.skipped += 1,
6593                            OnConflict::Replace => {
6594                                if store_fields.is_none() {
6595                                    store_fields = Some(preview.db.props_view().field_names());
6596                                }
6597                                let fields = store_fields.as_deref().unwrap_or_default();
6598                                match preview.plan_replace(&label, &key, &props, fields) {
6599                                    Ok((writes, kept_view_owned)) => {
6600                                        outcome.kept_view_owned += kept_view_owned;
6601                                        for (field, value) in writes {
6602                                            match value {
6603                                                Some(value) => {
6604                                                    preview.note_set_prop(&key, &field, &value);
6605                                                    recs.push(WalRecord::SetProp {
6606                                                        key: key.clone(),
6607                                                        field,
6608                                                        value,
6609                                                    });
6610                                                }
6611                                                None => {
6612                                                    preview.note_remove_prop(&key, &field);
6613                                                    recs.push(WalRecord::RemoveProp {
6614                                                        key: key.clone(),
6615                                                        field,
6616                                                    });
6617                                                }
6618                                            }
6619                                        }
6620                                        outcome.replaced += 1;
6621                                    }
6622                                    Err(why) => outcome.row_errors.push((row, why)),
6623                                }
6624                            }
6625                        }
6626                    }
6627                    BatchOp::InsertEdge {
6628                        edge_type,
6629                        src_key,
6630                        dst_key,
6631                    } => {
6632                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6633                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6634                            recs.push(WalRecord::InsertEdge {
6635                                edge_type,
6636                                src_key,
6637                                dst_key,
6638                            });
6639                        } else if preview.db.multiplicity {
6640                            // A duplicate inside a batch counts the way a
6641                            // duplicate through `insert_edge` does: `ingest` and
6642                            // Cypher `CREATE` reach this choke-point and not
6643                            // that one, and a count only one entry point keeps
6644                            // would be worse than no count at all.
6645                            //
6646                            // This is the one gate on discriminant 23 from the
6647                            // batch path: a store that never opted in queues
6648                            // nothing here and so writes no such record.
6649                            deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6650                        }
6651                    }
6652                    BatchOp::SetProp { key, field, value } => {
6653                        if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
6654                            return Err(GraphError::ViewPropReadOnly {
6655                                view_name: view_name.to_string(),
6656                            });
6657                        }
6658                        preview.check_live_key(&key)?;
6659                        preview.note_set_prop(&key, &field, &value);
6660                        recs.push(WalRecord::SetProp { key, field, value });
6661                    }
6662                    BatchOp::RemoveProp { key, field } => {
6663                        if preview.prepare_remove_prop(&key, &field)? {
6664                            preview.note_remove_prop(&key, &field);
6665                            recs.push(WalRecord::RemoveProp { key, field });
6666                        }
6667                    }
6668                    BatchOp::DeleteEdge {
6669                        edge_type,
6670                        src_key,
6671                        dst_key,
6672                    } => {
6673                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
6674                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
6675                            recs.push(WalRecord::DeleteEdge {
6676                                edge_type,
6677                                src_key,
6678                                dst_key,
6679                            });
6680                        }
6681                    }
6682                    BatchOp::DeleteNode { key } => {
6683                        preview.check_live_key(&key)?;
6684                        preview.note_delete_node(&key);
6685                        recs.push(WalRecord::DeleteNode { key });
6686                    }
6687                    BatchOp::CreateRule(def) => {
6688                        preview.check_create_rule(&def)?;
6689                        let def_bytes =
6690                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6691                                detail: format!("serialize rule: {e}"),
6692                            })?;
6693                        preview.note_create_rule(&def);
6694                        recs.push(WalRecord::CreateRule { def_bytes });
6695                    }
6696                    BatchOp::DeleteRule { name } => {
6697                        preview.check_delete_rule(&name)?;
6698                        preview.note_delete_rule(&name);
6699                        recs.push(WalRecord::DeleteRule { name });
6700                    }
6701                    BatchOp::RenameNode { old_key, new_key } => {
6702                        preview.check_rename_node(&old_key, &new_key)?;
6703                        // A stored `id` that equals the key is the key, written
6704                        // as a property by `upsert_entity`'s create path and by
6705                        // Cypher `CREATE {id: …}`. It moves with the key, in
6706                        // this frame; left behind, `WHERE n.id = '<new key>'`
6707                        // finds nothing (ledger row 72). An `id` holding
6708                        // anything else is the caller's and stays.
6709                        let id_follows = preview.prop_value(&old_key, "id")
6710                            == Some(Value::Str(old_key.clone()))
6711                            && preview.db.view_store.view_for_prop("id").is_none();
6712                        preview.note_rename_node(&old_key, &new_key);
6713                        recs.push(WalRecord::RenameNode {
6714                            old_key,
6715                            new_key: new_key.clone(),
6716                        });
6717                        if id_follows {
6718                            let value = Value::Str(new_key.clone());
6719                            preview.note_set_prop(&new_key, "id", &value);
6720                            recs.push(WalRecord::SetProp {
6721                                key: new_key,
6722                                field: "id".into(),
6723                                value,
6724                            });
6725                        }
6726                    }
6727                    BatchOp::InsertEdgeUpsert {
6728                        edge_type,
6729                        src_key,
6730                        dst_key,
6731                        placeholder_label,
6732                    } => {
6733                        // Auto-create any missing endpoints as plain InsertNode ops.
6734                        // Rules fire and last-change is updated for each created node.
6735                        for key in [&src_key, &dst_key] {
6736                            if !preview.has_key(key) {
6737                                // A placeholder endpoint carries no props, so
6738                                // the view-owned check has nothing to refuse.
6739                                preview.check_insert_node(key, &[])?;
6740                                preview.note_insert_node(&placeholder_label, key, &[]);
6741                                recs.push(WalRecord::InsertNode {
6742                                    label: placeholder_label.clone(),
6743                                    key: key.clone(),
6744                                    props: vec![],
6745                                });
6746                            }
6747                        }
6748                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6749                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6750                            recs.push(WalRecord::InsertEdge {
6751                                edge_type,
6752                                src_key,
6753                                dst_key,
6754                            });
6755                        } else if preview.db.multiplicity {
6756                            // Same choke-point, same gate as `BatchOp::InsertEdge`
6757                            // above: an upsert that finds the pair already there
6758                            // is a duplicate insert and counts as one.
6759                            deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6760                        }
6761                    }
6762                }
6763            }
6764            // Splice the deferred counts back into the positions they were
6765            // raised at, so a count still sits exactly where the duplicate did
6766            // — before any later op in the frame that deletes the pair.
6767            let mut planned: Vec<PlannedRec> =
6768                Vec::with_capacity(recs.len() + deferred_counts.len());
6769            let mut deferred = deferred_counts.into_iter().peekable();
6770            for (i, rec) in recs.into_iter().enumerate() {
6771                while deferred.peek().is_some_and(|(at, ..)| *at == i) {
6772                    let (_, edge_type, src_key, dst_key) = deferred.next().expect("just peeked");
6773                    planned.push(PlannedRec::DuplicateCount {
6774                        edge_type,
6775                        src_key,
6776                        dst_key,
6777                    });
6778                }
6779                planned.push(PlannedRec::Rec(rec));
6780            }
6781            for (_, edge_type, src_key, dst_key) in deferred {
6782                planned.push(PlannedRec::DuplicateCount {
6783                    edge_type,
6784                    src_key,
6785                    dst_key,
6786                });
6787            }
6788            planned
6789        };
6790        // A frame that is nothing but skips or refused rows writes no WAL, but
6791        // it still has counts to report, so the early returns carry `outcome`
6792        // rather than zeros.
6793        if recs.is_empty() {
6794            return Ok(outcome);
6795        }
6796        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
6797        // *Id form, so only the dense variants can appear in `recs` here.
6798        let recs = self.rewrite_wal_dense_planned(recs)?;
6799        // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
6800        // namespace the node is already in is a no-op and is dropped there. An
6801        // empty `Batch` frame would still take a commit sequence and a WAL
6802        // record, so a batch that turns out to be nothing writes nothing.
6803        if recs.is_empty() {
6804            return Ok(outcome);
6805        }
6806        outcome.nodes_inserted = recs
6807            .iter()
6808            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
6809            .count();
6810        outcome.edges_inserted = recs
6811            .iter()
6812            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
6813            .count();
6814        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
6815        // under Strict.  Pass self.fsync directly so Strict stays Strict —
6816        // wal_needs_sync(Strict, _) always returns true regardless of op count.
6817        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
6818        // short-circuit on single-op batches and silently skip the fsync.
6819        // Batched fsyncs only for multi-op batches; Relaxed always skips.
6820        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
6821        Ok(outcome)
6822    }
6823
6824    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
6825        self.commit_logged_batch(ops, None, None).map(inserted_pair)
6826    }
6827
6828    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
6829    /// and the group-commit drain thread, which do a single group fsync later.
6830    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
6831        // Restore fsync policy even on panic via a raw-pointer drop guard.
6832        // A panic here would poison the RwLock anyway, but the correct policy
6833        // must be in place if the guard is ever unwrapped.
6834        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
6835        impl Drop for RestoreFsync {
6836            fn drop(&mut self) {
6837                // SAFETY: the pointer is valid for the full duration of
6838                // commit_batch_nosync; the guard is dropped before the frame
6839                // returns, and GraphDb outlives this frame.
6840                unsafe {
6841                    *self.0 = self.1;
6842                }
6843            }
6844        }
6845        let saved = self.fsync;
6846        // SAFETY: raw pointer into self; guard dropped within this frame.
6847        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
6848        self.fsync = FsyncPolicy::Relaxed;
6849        self.commit_logged_batch(ops, None, None).map(inserted_pair)
6850    }
6851
6852    /// Commit multiple op-batches as a **group**: each submission gets its own
6853    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
6854    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
6855    ///
6856    /// # Durability semantics
6857    ///
6858    /// A crash before the group fsync may lose **all** submissions in the group.
6859    /// A crash after the group fsync preserves all of them.  No submission is
6860    /// ever torn: each WAL frame is either fully applied on replay or dropped
6861    /// in its entirety (CRC-protected frame boundaries).
6862    ///
6863    /// Events and subscription notifications fire per-submission immediately
6864    /// after apply, which may be before the group fsync.  From a subscriber's
6865    /// perspective this is equivalent to the `Relaxed` durability window.
6866    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
6867    /// fsync, so from their perspective durability is fully guaranteed.
6868    ///
6869    /// # MVCC interplay
6870    ///
6871    /// Each submission records its own `CommitDelta`; the fold-every-K counter
6872    /// increments per submission (not per group), preserving existing reader
6873    /// snapshot semantics.
6874    ///
6875    /// # Returns
6876    ///
6877    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
6878    /// in order.  Failures are per-submission (validation errors); the group
6879    /// fsync error (if any) is returned as the second tuple element.
6880    pub fn commit_group(
6881        &mut self,
6882        groups: Vec<Vec<BatchOp>>,
6883    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
6884        let mut results = Vec::with_capacity(groups.len());
6885        for ops in groups {
6886            results.push(self.commit_batch_nosync(ops));
6887        }
6888        let any_ok = results.iter().any(|r| r.is_ok());
6889        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
6890            self.fs
6891                .sync(core_storage::fs::FileId::Wal)
6892                .map_err(GraphError::Io)
6893                .err()
6894        } else {
6895            None
6896        };
6897        (results, sync_err)
6898    }
6899
6900    /// Like [`commit_group`] but skips the group fsync entirely.
6901    ///
6902    /// Used by the drain thread to apply submissions under the write lock and
6903    /// then perform the single fsync OUTSIDE the lock (via
6904    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
6905    /// to concurrent readers.
6906    pub fn commit_group_nosync(
6907        &mut self,
6908        groups: Vec<Vec<BatchOp>>,
6909    ) -> Vec<Result<(usize, usize)>> {
6910        let mut results = Vec::with_capacity(groups.len());
6911        for ops in groups {
6912            results.push(self.commit_batch_nosync(ops));
6913        }
6914        results
6915    }
6916
6917    pub fn insert_node(
6918        &mut self,
6919        label: &str,
6920        key: &str,
6921        props: Vec<(String, Value)>,
6922    ) -> Result<()> {
6923        if self.read_only {
6924            return Err(GraphError::ReadOnly);
6925        }
6926        MutPreview::new(self).check_insert_node(key, &props)?;
6927        self.log_dense(vec![WalRecord::InsertNode {
6928            label: label.into(),
6929            key: key.into(),
6930            props,
6931        }])
6932    }
6933
6934    /// Insert a user edge. `Ok(true)` when the pair was new, `Ok(false)` when it
6935    /// was already there — the question is "was this pair new", and a duplicate
6936    /// does not make it so.
6937    ///
6938    /// On a store that called [`enable_multiplicity`](Self::enable_multiplicity)
6939    /// a duplicate is no longer a total no-op: it raises the pair's insert count
6940    /// (§5.13). Adjacency is still a set, so [`degree`](Self::degree) is
6941    /// unchanged and the return value is still `Ok(false)`; the count is visible
6942    /// only through [`degree_multiplicity`](Self::degree_multiplicity) and the
6943    /// reserved [`EDGE_COUNT_PROP`]. On every other store a duplicate writes
6944    /// nothing at all, as it always has.
6945    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
6946        if self.read_only {
6947            return Err(GraphError::ReadOnly);
6948        }
6949        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
6950            // The pair exists. The only thing left to record is that it was
6951            // asked for again, and only where the store asked to be told.
6952            if let Some(rec) = self.edge_count_record(edge_type, src_key, dst_key) {
6953                self.log_then_apply(rec)?;
6954            }
6955            return Ok(false);
6956        }
6957        self.log_dense(vec![WalRecord::InsertEdge {
6958            edge_type: edge_type.into(),
6959            src_key: src_key.into(),
6960            dst_key: dst_key.into(),
6961        }])?;
6962        Ok(true)
6963    }
6964
6965    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
6966        if self.read_only {
6967            return Err(GraphError::ReadOnly);
6968        }
6969        if let Some(view_name) = self.view_store.view_for_prop(field) {
6970            return Err(GraphError::ViewPropReadOnly {
6971                view_name: view_name.to_string(),
6972            });
6973        }
6974        MutPreview::new(self).check_live_key(key)?;
6975        self.log_dense(vec![WalRecord::SetProp {
6976            key: key.into(),
6977            field: field.into(),
6978            value,
6979        }])
6980    }
6981
6982    /// Set several properties on one live node in a single WAL commit.
6983    ///
6984    /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
6985    /// names, live key, the `ns` immutability rule and its type — is evaluated
6986    /// for the whole list before any record is logged. The first refusal
6987    /// returns and the node is unchanged. An empty list writes nothing.
6988    pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
6989        if self.read_only {
6990            return Err(GraphError::ReadOnly);
6991        }
6992        MutPreview::new(self).check_live_key(key)?;
6993        for (field, _) in &props {
6994            if let Some(view_name) = self.view_store.view_for_prop(field) {
6995                return Err(GraphError::ViewPropReadOnly {
6996                    view_name: view_name.to_string(),
6997                });
6998            }
6999        }
7000        if props.is_empty() {
7001            return Ok(());
7002        }
7003        self.write_batch(|b| {
7004            for (field, value) in props {
7005                b.set_prop(key, &field, value);
7006            }
7007        })
7008        .map(|_| ())
7009    }
7010
7011    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
7012    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
7013    /// A field a view owns is `Err(ViewPropReadOnly)` — stated once, in
7014    /// [`MutPreview::prepare_remove_prop`], so that the batch ops reaching that
7015    /// same choke-point cannot miss it.
7016    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
7017        if self.read_only {
7018            return Err(GraphError::ReadOnly);
7019        }
7020        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
7021            return Ok(false);
7022        }
7023        self.log_then_apply(WalRecord::RemoveProp {
7024            key: key.into(),
7025            field: field.into(),
7026        })?;
7027        Ok(true)
7028    }
7029
7030    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
7031    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
7032    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
7033    /// (the rule would just put the edge back; delete or change the rule).
7034    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7035        if self.read_only {
7036            return Err(GraphError::ReadOnly);
7037        }
7038        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
7039            return Ok(false);
7040        }
7041        self.log_then_apply(WalRecord::DeleteEdge {
7042            edge_type: edge_type.into(),
7043            src_key: src_key.into(),
7044            dst_key: dst_key.into(),
7045        })?;
7046        Ok(true)
7047    }
7048
7049    /// Delete a live node. Unknown or already-tombstoned keys are
7050    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
7051    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
7052    /// (crash window) is a clean no-op.
7053    ///
7054    /// Returns a [`DeleteReport`] with counts of manual and derived edges
7055    /// removed (computed from live state before the deletion is applied).
7056    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
7057        if self.read_only {
7058            return Err(GraphError::ReadOnly);
7059        }
7060        // Provenance must be loaded before we query provenance_touching.
7061        self.engine.ensure_provenance_loaded_mut();
7062        let id = self
7063            .ids
7064            .get(key)
7065            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7066
7067        // Count edges before the delete is applied so we can report counts.
7068        let derived_set: BTreeSet<(u32, u32, u32)> = self
7069            .engine
7070            .provenance_touching(id)
7071            .map(|(_, etype, src, dst)| (etype, src, dst))
7072            .collect();
7073        let derived_edges = derived_set.len() as u64;
7074
7075        let mut total_topo = 0u64;
7076        let tv = self.topo_view();
7077        for et in tv.etypes() {
7078            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
7079                + tv.neighbors(et, Direction::In, id).len() as u64;
7080        }
7081        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
7082        // triples in both the topo scan (Out and In from id) and in provenance_touching.
7083        // The subtraction remains correct because both counts include both directions.
7084        let manual_edges = total_topo.saturating_sub(derived_edges);
7085
7086        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
7087        Ok(DeleteReport {
7088            manual_edges,
7089            derived_edges,
7090        })
7091    }
7092
7093    /// Rename a live node's key.  The dense id (and therefore all edges,
7094    /// props, history, and last-change tracking) is unaffected.
7095    ///
7096    /// A stored `id` property that equals `old` is rewritten to `new` in the
7097    /// same commit, so `n.id` and `key(n)` keep agreeing; an `id` holding any
7098    /// other value, and a node that stores none, are left as they are.
7099    ///
7100    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
7101    /// Returns `Err(DuplicateKey)` if `new` is already live.
7102    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
7103        if self.read_only {
7104            return Err(GraphError::ReadOnly);
7105        }
7106        // The `id` rewrite lives in the batch arm, which HTTP reaches without
7107        // this function. A node with no such `id` keeps the bare frame.
7108        if self.get_prop(old, "id") == Some(Value::Str(old.to_string())) {
7109            return self
7110                .commit_batch(vec![BatchOp::RenameNode {
7111                    old_key: old.into(),
7112                    new_key: new.into(),
7113                }])
7114                .map(|_| ());
7115        }
7116        MutPreview::new(self).check_rename_node(old, new)?;
7117        self.log_then_apply(WalRecord::RenameNode {
7118            old_key: old.into(),
7119            new_key: new.into(),
7120        })
7121    }
7122
7123    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
7124    /// `None` if the rule does not exist or is not approximate.
7125    ///
7126    /// The drift counter increments on IVF insert/remove after the last fit.
7127    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
7128    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
7129    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
7130        // SideIvfExport = (centroids, node→cluster, drift)
7131        self.engine
7132            .export_ivf_state()
7133            .remove(rule)
7134            .map(|(_src, dst)| dst.2)
7135    }
7136
7137    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
7138    /// Validation and duplicate-name check run before logging so invalid rules
7139    /// never enter the WAL.
7140    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
7141        if self.read_only {
7142            return Err(GraphError::ReadOnly);
7143        }
7144        MutPreview::new(self).check_create_rule(&def)?;
7145        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7146            detail: format!("serialize rule: {e}"),
7147        })?;
7148        self.log_dense(vec![WalRecord::CreateRule { def_bytes }])
7149    }
7150
7151    /// Override this handle's HNSW build-slice size, or `None` to restore
7152    /// [`core_rules::HNSW_BUILD_BATCH`].
7153    ///
7154    /// Exposed for tests that need a small slice without a large corpus; not
7155    /// part of the stable surface.
7156    #[doc(hidden)]
7157    pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
7158        self.engine.set_hnsw_build_batch(batch);
7159    }
7160
7161    /// Rules whose vector index is still being built, in name order.
7162    ///
7163    /// The same list [`GraphDb::stats`] reports per rule in `building`.
7164    /// After a clean open this includes a build a snapshot cut short, so
7165    /// `serve`'s ticker can pump it without a write.
7166    pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
7167        self.engine.builds_in_progress()
7168    }
7169
7170    /// Advance any vector index still building and backfill each rule that
7171    /// finishes. Returns what is still outstanding.
7172    ///
7173    /// A map lookup when nothing is pending, so it is cheap to call on a timer.
7174    /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
7175    /// inserts per pending rule per call, so a caller can drive a large build
7176    /// to completion without ever holding the lock for more than a slice.
7177    ///
7178    /// A rule that finishes here is backfilled through the same
7179    /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
7180    /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
7181    /// and appear all at once.
7182    ///
7183    /// Every ordinary write pumps one slice on its own (see the post-commit
7184    /// hook in `log_then_apply_with`), so this is for quiescent stores and for
7185    /// operators who want the build finished before traffic arrives.
7186    pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
7187        Ok(self.pump_index_build_reporting()?.1)
7188    }
7189
7190    /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
7191    /// call finished, so a progress display can say so.
7192    ///
7193    /// A build can be registered and completed inside a single call — that is
7194    /// what a mid-build snapshot looks like on reopen, where the index scan
7195    /// finishes the graph and only the backfill is outstanding — and the
7196    /// outstanding list alone cannot show that anything happened.
7197    pub fn pump_index_build_reporting(
7198        &mut self,
7199    ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
7200        // A read-only handle cannot issue the `RebuildRule` a finished build
7201        // needs, so it would advance the index and then silently fail to
7202        // produce the edges. Refusing is the honest answer.
7203        if self.read_only {
7204            return Err(GraphError::ReadOnly);
7205        }
7206        let finished = self.pump_one_slice();
7207        for done in &finished {
7208            // The index is whole but the rule still owns no edges. A failed
7209            // second commit must leave the rule re-pumpable rather than
7210            // silently edge-less, so the error is surfaced here — unlike the
7211            // post-commit hook, this call is not riding someone else's commit.
7212            self.log_then_apply(WalRecord::RebuildRule {
7213                name: done.rule.clone(),
7214            })?;
7215        }
7216        Ok((finished, self.engine.builds_in_progress()))
7217    }
7218
7219    /// Run the deferred candidate-index build, if it is still owed, against the
7220    /// graph as it stands *now* — before the caller applies anything.
7221    ///
7222    /// A no-op bool test once the indexes are populated, which is after the
7223    /// first write of the handle's life, and for a store with no rules at all.
7224    fn populate_indexes_before_write(&mut self) {
7225        if !self.engine.needs_index_population() {
7226            return;
7227        }
7228        // The retained snapshot blobs arrive with the V8 base sections; without
7229        // them the scan would rebuild every graph the snapshot already holds.
7230        self.ensure_v8_base_sections_loaded();
7231        if !self.engine.needs_index_population() {
7232            return;
7233        }
7234        let mut eng = std::mem::take(&mut self.engine);
7235        {
7236            let gm = make_graph_mut(
7237                &self.ids,
7238                Arc::make_mut(&mut self.syms),
7239                &self.labels,
7240                build_props_view(&self.props, &self.base),
7241                Arc::make_mut(&mut self.topo),
7242                &self.base,
7243                Arc::make_mut(&mut self.edge_props),
7244            );
7245            eng.populate_indexes(&gm);
7246        }
7247        self.engine = eng;
7248    }
7249
7250    /// One slice of build work for every pending rule. Returns the rules whose
7251    /// index just became whole, which the caller must `RebuildRule`.
7252    ///
7253    /// Goes through the engine even with nothing pending when the indexes have
7254    /// not been populated yet: that call adopts the persisted graphs and, for
7255    /// an incomplete blob already registered at open, leaves the remainder to
7256    /// this slice rather than inserting it inline.
7257    fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
7258        // The retained snapshot blobs — and the id count an interrupted build
7259        // is recognised against — arrive with the V8 base sections, which a
7260        // clean open reads lazily. Without this a freshly opened handle pumps
7261        // against empty retained state and concludes there is nothing to do,
7262        // which is precisely the store `build-index` exists for.
7263        self.ensure_v8_base_sections_loaded();
7264        let mut eng = std::mem::take(&mut self.engine);
7265        let finished = {
7266            let mut gm = make_graph_mut(
7267                &self.ids,
7268                Arc::make_mut(&mut self.syms),
7269                &self.labels,
7270                build_props_view(&self.props, &self.base),
7271                Arc::make_mut(&mut self.topo),
7272                &self.base,
7273                Arc::make_mut(&mut self.edge_props),
7274            );
7275            eng.pump_index_build(&mut gm)
7276        };
7277        self.engine = eng;
7278        finished
7279    }
7280
7281    /// Register a sliced build a snapshot cut short, from blobs with
7282    /// `complete == false`.
7283    ///
7284    /// Peeks the V8 mmap for incomplete entries without copying complete
7285    /// graphs. V5–V7 already hold the blobs in the engine from restore.
7286    fn register_outstanding_index_builds(&mut self) {
7287        if self.engine.indexes_populated() {
7288            return;
7289        }
7290        let extra = self.collect_incomplete_hnsw_blobs();
7291        let mut eng = std::mem::take(&mut self.engine);
7292        {
7293            let gm = make_graph_mut(
7294                &self.ids,
7295                Arc::make_mut(&mut self.syms),
7296                &self.labels,
7297                build_props_view(&self.props, &self.base),
7298                Arc::make_mut(&mut self.topo),
7299                &self.base,
7300                Arc::make_mut(&mut self.edge_props),
7301            );
7302            eng.register_incomplete_hnsw_builds(&extra, &gm);
7303        }
7304        self.engine = eng;
7305    }
7306
7307    /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
7308    /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
7309    /// engine's retained map instead).
7310    fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
7311        let Some(base) = &self.base else {
7312            return BTreeMap::new();
7313        };
7314        let Ok(archived) = base.hnsw_section() else {
7315            return BTreeMap::new();
7316        };
7317        archived
7318            .rules
7319            .iter()
7320            .filter_map(|e| {
7321                let src = e.src_blob.as_slice();
7322                let dst = e.dst_blob.as_slice();
7323                if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
7324                    || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
7325                {
7326                    Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
7327                } else {
7328                    None
7329                }
7330            })
7331            .collect()
7332    }
7333
7334    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
7335    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
7336        if self.read_only {
7337            return Err(GraphError::ReadOnly);
7338        }
7339        MutPreview::new(self).check_delete_rule(name)?;
7340        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
7341    }
7342
7343    /// Return a snapshot of all registered rules.
7344    pub fn rules(&self) -> Vec<RuleDef> {
7345        self.engine.rules().cloned().collect()
7346    }
7347
7348    // -----------------------------------------------------------------------
7349    // Rule suggestion API
7350    // -----------------------------------------------------------------------
7351
7352    /// Profile the database and suggest linking rules with previewed edge counts.
7353    ///
7354    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
7355    /// sampling. Suggestions are sorted by estimated edge count (descending).
7356    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
7357    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
7358        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
7359    }
7360
7361    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
7362    /// reproducibility. Same seed + same data = identical output.
7363    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
7364        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
7365            .suggestions
7366    }
7367
7368    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
7369    ///
7370    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
7371    /// and a `truncated` flag indicating whether the global budget fired before all
7372    /// candidates were evaluated.
7373    pub fn suggest_rules_with_config(
7374        &self,
7375        config: &core_rules::suggest::SuggestConfig,
7376        seed: u64,
7377    ) -> core_rules::SuggestReport {
7378        use std::collections::BTreeMap;
7379
7380        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
7381        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
7382        for id in 0..self.ids.len() as u32 {
7383            let Some(key) = self.ids.key_of(id) else {
7384                continue;
7385            };
7386            let Some(&sym) = self.labels.get(id as usize) else {
7387                continue;
7388            };
7389            if sym == u32::MAX {
7390                continue; // tombstoned
7391            }
7392            let Some(label) = self.syms.resolve(sym) else {
7393                continue;
7394            };
7395            label_nodes
7396                .entry(label.to_string())
7397                .or_default()
7398                .push((id, key.to_string()));
7399        }
7400
7401        let existing = self.rules();
7402        let pv = build_props_view(&self.props, &self.base);
7403        let all_fields: Vec<String> = pv.field_names();
7404
7405        core_rules::suggest::suggest_rules(
7406            &label_nodes,
7407            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
7408            &all_fields,
7409            &existing,
7410            config,
7411            seed,
7412        )
7413    }
7414
7415    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
7416    /// plus later mutations replay identically (rebuild is a pure function
7417    /// of state).
7418    ///
7419    /// Only exit from the tripped latch: if the full desired set fits the
7420    /// budget, it is applied completely and `tripped` clears; if it still
7421    /// exceeds the budget, provenance is left untouched and `tripped` stays
7422    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
7423    /// Unknown rule → `RuleNotFound`, nothing logged.
7424    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
7425        if self.read_only {
7426            return Err(GraphError::ReadOnly);
7427        }
7428        if !self.engine.rules().any(|r| r.name == name) {
7429            return Err(GraphError::RuleNotFound { name: name.into() });
7430        }
7431        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
7432    }
7433
7434    // -----------------------------------------------------------------------
7435    // Materialized view API
7436    // -----------------------------------------------------------------------
7437
7438    /// Register a new materialized property view, backfill its values for all
7439    /// existing nodes, and WAL-log the definition.
7440    ///
7441    /// # Errors
7442    /// - `ReadOnly`: called on an as-of instance.
7443    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
7444    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
7445        if self.read_only {
7446            return Err(GraphError::ReadOnly);
7447        }
7448        // Pre-validate before WAL write.
7449        def.validate()
7450            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
7451        if self.view_store.has_view(&def.name) {
7452            return Err(GraphError::RuleInvalid {
7453                detail: format!("view {:?} already exists", def.name),
7454            });
7455        }
7456        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
7457            return Err(GraphError::RuleInvalid {
7458                detail: format!(
7459                    "view_prop {:?} is already used by view {:?}",
7460                    def.view_prop, existing
7461                ),
7462            });
7463        }
7464        // A view cannot take the name of a property nodes already carry —
7465        // in the snapshot or written since. Refused here, before the WAL
7466        // write: `apply` refuses some of it too, but by then the record is
7467        // logged, and the next open replays it and fails.
7468        //
7469        // "Carry" is a node that reads a value under the name. The column
7470        // alone does not count: a snapshot keeps the column of a view that
7471        // has since been deleted, with every value in it hidden, and that
7472        // name is free.
7473        let taken = {
7474            let pv = build_props_view(&self.props, &self.base);
7475            pv.field_names().contains(&def.view_prop)
7476                && (0..self.ids.len() as u32)
7477                    .any(|id| !self.ids.is_tombstoned(id) && pv.get(id, &def.view_prop).is_some())
7478        };
7479        if taken {
7480            return Err(GraphError::RuleInvalid {
7481                detail: format!(
7482                    "view_prop {:?} conflicts with an existing node property",
7483                    def.view_prop
7484                ),
7485            });
7486        }
7487        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7488            detail: format!("serialize view: {e}"),
7489        })?;
7490        // Enable delta accumulation before the view is registered so subsequent
7491        // incremental edge events reach view maintenance from this point onward.
7492        // (The backfill inside create_view reads topo directly; it does not rely
7493        // on pending deltas.)
7494        self.engine.set_emit_deltas(true);
7495        self.log_then_apply(WalRecord::CreateView { def_bytes })
7496    }
7497
7498    /// Remove a named view and delete its values from every node.
7499    ///
7500    /// # Errors
7501    /// - `ReadOnly`: called on an as-of instance.
7502    /// - `RuleNotFound`: view does not exist.
7503    pub fn delete_view(&mut self, name: &str) -> Result<()> {
7504        if self.read_only {
7505            return Err(GraphError::ReadOnly);
7506        }
7507        if !self.view_store.has_view(name) {
7508            return Err(GraphError::RuleNotFound { name: name.into() });
7509        }
7510        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
7511        // After deletion, disable accumulation if no listeners remain.
7512        if !self.needs_emit_deltas() {
7513            self.engine.set_emit_deltas(false);
7514        }
7515        result
7516    }
7517
7518    /// Snapshot of all registered view definitions.
7519    pub fn views(&self) -> Vec<ViewDef> {
7520        self.view_store.views().cloned().collect()
7521    }
7522
7523    // -----------------------------------------------------------------------
7524    // Full-text-lite API
7525    // -----------------------------------------------------------------------
7526
7527    /// Enable full-text indexing for all nodes of `label` on property `field`.
7528    ///
7529    /// After this call, every subsequent write to `(label, field)` is reflected
7530    /// in the index incrementally.  Existing nodes are backfilled immediately.
7531    /// The declaration is persisted as a WAL record; the index itself is rebuilt
7532    /// from scratch on re-open (no snapshot format changes).
7533    ///
7534    /// # Errors
7535    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7536    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
7537    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7538        if self.read_only {
7539            return Err(GraphError::ReadOnly);
7540        }
7541        if self.fulltext.is_enabled(label, field) {
7542            return Err(GraphError::RuleInvalid {
7543                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
7544            });
7545        }
7546        self.log_then_apply(WalRecord::EnableFulltext {
7547            label: label.into(),
7548            field: field.into(),
7549        })
7550    }
7551
7552    /// Disable full-text indexing for `(label, field)` and drop its postings.
7553    ///
7554    /// # Errors
7555    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7556    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
7557    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7558        if self.read_only {
7559            return Err(GraphError::ReadOnly);
7560        }
7561        if !self.fulltext.is_enabled(label, field) {
7562            return Err(GraphError::RuleNotFound {
7563                name: format!("fulltext({label},{field})"),
7564            });
7565        }
7566        self.log_then_apply(WalRecord::DisableFulltext {
7567            label: label.into(),
7568            field: field.into(),
7569        })
7570    }
7571
7572    /// Remove every edge the snapshot holds to or from a node that has been
7573    /// deleted.
7574    ///
7575    /// There should be none. Through 0.7.1 a node deleted after a snapshot
7576    /// kept the edges that snapshot held (ledger row 95), and the next
7577    /// snapshot wrote them into its own base, where they answered a
7578    /// neighbour read with `topology id N has no key`. This is what repairs
7579    /// such a store. It asks for the neighbours of every retired id under
7580    /// every edge type, which on a store with half a million deleted nodes is
7581    /// over a second, so it runs only over a snapshot that does not say its
7582    /// writer sweeps them (`MappedBase::edges_swept`). The removal is in
7583    /// memory until the next snapshot, which no longer carries them and says
7584    /// so.
7585    fn drop_edges_of_retired_nodes(&mut self) {
7586        let mut doomed = Vec::new();
7587        {
7588            let tv = self.topo_view();
7589            let etypes: Vec<u32> = tv.etypes().collect();
7590            for n in self.ids.tombstoned_ids() {
7591                for et in &etypes {
7592                    for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
7593                        doomed.push((*et, n, dst));
7594                    }
7595                    for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
7596                        if src != n {
7597                            doomed.push((*et, src, n));
7598                        }
7599                    }
7600                }
7601            }
7602        }
7603        for (et, s, d) in doomed {
7604            Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
7605            Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
7606            self.view_store.on_edge_changed(
7607                et,
7608                s,
7609                d,
7610                false,
7611                Arc::make_mut(&mut self.props),
7612                &build_topo_view(&self.topo, &self.base),
7613                &self.ids,
7614                &self.syms,
7615                &self.labels,
7616                base_columns(&self.base),
7617            );
7618        }
7619    }
7620
7621    /// Whether `(label, field)` is currently indexed for full-text search.
7622    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
7623        self.fulltext.is_enabled(label, field)
7624    }
7625
7626    /// Every `(label, field)` pair with a live full-text index, sorted.
7627    ///
7628    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
7629    /// declares which nodes are *indexed*, so callers that want to search
7630    /// everything indexed should query each distinct field once.
7631    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
7632        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
7633        v.sort();
7634        v
7635    }
7636
7637    /// Enable an equality index for all nodes of `label` on scalar property
7638    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
7639    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
7640    /// declaration persists via WAL and the postings rebuild on re-open.
7641    ///
7642    /// # Errors
7643    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7644    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
7645    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
7646        if self.read_only {
7647            return Err(GraphError::ReadOnly);
7648        }
7649        if self.prop_index.is_enabled(label, field) {
7650            return Err(GraphError::RuleInvalid {
7651                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
7652            });
7653        }
7654        self.log_then_apply(WalRecord::EnableIndex {
7655            label: label.into(),
7656            field: field.into(),
7657        })
7658    }
7659
7660    /// Disable the equality index for `(label, field)` and drop its postings.
7661    ///
7662    /// # Errors
7663    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7664    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
7665    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
7666        if self.read_only {
7667            return Err(GraphError::ReadOnly);
7668        }
7669        if !self.prop_index.is_enabled(label, field) {
7670            return Err(GraphError::RuleNotFound {
7671                name: format!("index({label},{field})"),
7672            });
7673        }
7674        self.log_then_apply(WalRecord::DisableIndex {
7675            label: label.into(),
7676            field: field.into(),
7677        })
7678    }
7679
7680    /// Whether `(label, field)` currently has an equality index.
7681    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
7682        self.prop_index.is_enabled(label, field)
7683    }
7684
7685    /// Every `(label, field)` pair with a live equality index, sorted.
7686    ///
7687    /// The enumerator [`is_index_enabled`](Self::is_index_enabled) never had:
7688    /// the MCP `schema` tool lists a store's indexes rather than probing them
7689    /// one guess at a time.
7690    pub fn index_pairs(&self) -> Vec<(String, String)> {
7691        self.prop_index.enabled_pairs().cloned().collect()
7692    }
7693
7694    /// The dense id of a live key, `None` for an unknown or deleted one.
7695    ///
7696    /// Ids are allocated in insertion order and never reused, so the lowest
7697    /// live id among a set of keys is the oldest node — which is how identity
7698    /// resolution picks a canonical node without a timestamp field
7699    /// (`memory::identity`). Crate-private: an id is an engine detail, not
7700    /// something a caller should hold.
7701    pub(crate) fn dense_id(&self, key: &str) -> Option<u32> {
7702        self.ids.get(key)
7703    }
7704
7705    /// Start recording insert-count multiplicity on this store (§5.13).
7706    ///
7707    /// Adjacency stays a set and nothing about an existing read changes: a
7708    /// duplicate [`insert_edge`](Self::insert_edge) still returns `Ok(false)`
7709    /// and still leaves [`degree`](Self::degree) alone. What it gains is that
7710    /// the duplicate is *counted*, as the reserved edge property
7711    /// [`EDGE_COUNT_PROP`], readable through
7712    /// [`degree_multiplicity`](Self::degree_multiplicity).
7713    ///
7714    /// # This is a one-way step, and that is why it is a call
7715    ///
7716    /// The count is durable, so it is written to the WAL — as discriminant 23,
7717    /// which no release before v0.6.10 knows. A reader meeting an unknown WAL
7718    /// discriminant cannot know what the record would have changed, so it
7719    /// cannot degrade the way an unreadable index blob can. **After this call
7720    /// the store can no longer be read by an older binary, and there is no call
7721    /// that undoes it.** Gating the record behind this method is what keeps
7722    /// that step a decision an operator makes when they want the feature,
7723    /// rather than one everybody takes by upgrading.
7724    ///
7725    /// # It fails loudly, and that costs a snapshot
7726    ///
7727    /// An older binary does not refuse discriminant 23 — it truncates the WAL
7728    /// at it and, with `repair_wal`, persists the truncation. So this call also
7729    /// writes a **V10 snapshot**, a version no earlier release knows, and it
7730    /// writes it *first*: the snapshot is read before the WAL, so an older
7731    /// binary stops at `snapshot: unsupported version 10` with the WAL
7732    /// untouched. Taking the snapshot before appending the record is what makes
7733    /// the guard unconditional — the store is never, at any interruption point,
7734    /// carrying the record without the stamp that announces it.
7735    ///
7736    /// The snapshot keeps the WAL (`keep_wal: true`): opting in is not a
7737    /// compaction, and history reachable by [`open_at`](Self::open_at) stays
7738    /// reachable. On a large store the call therefore costs one full snapshot
7739    /// write.
7740    ///
7741    /// # What it costs a store that archives
7742    ///
7743    /// Writing `snapshot.bin` is also how the archive path decides whether the
7744    /// store may have a *genesis chain* — whether `open_at` can replay
7745    /// archive-resident commits from empty state. The rule is conservative: a
7746    /// snapshot that was already on disk might have been a truncating one, and
7747    /// once the handle that took it is gone this binary cannot tell. A
7748    /// `keep_wal` snapshot taken by **this** handle is the case where it can, so
7749    /// opting in and then archiving **in the same session** keeps the chain.
7750    ///
7751    /// Opting in, closing the store, and archiving in a *later* session does
7752    /// not — but that is the answer any store with a prior snapshot gets, not
7753    /// something this call causes. A store that wants the chain should take its
7754    /// first archive in the session that opted in.
7755    ///
7756    /// Calling it on a store that has already opted in writes nothing and
7757    /// returns `Ok(())`: an operator should not have to ask first.
7758    ///
7759    /// # This call is not atomic, and an `Err` does not undo it
7760    ///
7761    /// There is no rollback here, and there never was one. An `Err` means this
7762    /// handle stopped believing the store is opted in — `self.multiplicity` is
7763    /// reset, so this handle reports `false` from then on — and nothing more. It
7764    /// says nothing about what reached disk. Two reachable failures leave the
7765    /// opt-in standing:
7766    ///
7767    /// * **The declaration landed and only its fsync failed.** `log_then_apply`
7768    ///   appends, then syncs; a failed barrier leaves `MULTIPLICITY_ENABLED`
7769    ///   already in `wal.bin`. The next open replays it and the store is opted
7770    ///   in. No archive is involved — this one predates the recovery below.
7771    /// * **The declaration never landed, but the V10 snapshot did, on a store
7772    ///   that already had an archive.** The open-time recovery in
7773    ///   `load_from_disk` reads V10-beside-an-archive as an interrupted archive
7774    ///   sequence and opts the store in.
7775    ///
7776    /// So a failed call may leave the opt-in on disk immediately (the first
7777    /// case) or conjure it at the next open (the second), and nothing puts the
7778    /// store back out. Treat `Err` as "the outcome is unknown", not as "nothing
7779    /// happened".
7780    ///
7781    /// **This is safe, and the ordering is the reason.** The V10 stamp is
7782    /// written *before* the declaration, so every one of these intermediate
7783    /// states is one an older binary refuses by name rather than truncates at.
7784    /// The failure direction costs a refusal, never a commit. That ordering is
7785    /// the property worth protecting, not the atomicity this call never had.
7786    ///
7787    /// **To know where the store stands, ask the store.** Reopen it and call
7788    /// [`is_multiplicity_enabled`](Self::is_multiplicity_enabled); that is the
7789    /// only answer that accounts for what reached disk.
7790    ///
7791    /// The one case that really does leave the store opted out is a failure with
7792    /// no archive present and no record written: a stray V10 snapshot remains,
7793    /// costing an older reader a refusal it did not strictly need, and *that*
7794    /// store's next snapshot rewrites at V9.
7795    ///
7796    /// # Errors
7797    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7798    /// - Anything [`snapshot_with`](Self::snapshot_with) can return, and
7799    ///   anything the WAL append or its fsync can return. See the atomicity
7800    ///   section above for what the store is left holding.
7801    pub fn enable_multiplicity(&mut self) -> Result<()> {
7802        if self.read_only {
7803            return Err(GraphError::ReadOnly);
7804        }
7805        if self.multiplicity {
7806            return Ok(());
7807        }
7808        // The snapshot goes first, and the order is the guard.
7809        //
7810        // An older reader refuses a V10 snapshot by name and stops; it does not
7811        // refuse discriminant 23, it truncates the WAL at it. So the store must
7812        // never hold the record without the snapshot that announces it — not
7813        // even for the width of one fsync. Writing the snapshot before the
7814        // record makes the only reachable intermediate state "V10 snapshot, no
7815        // record", which is merely conservative: this binary reads it as a
7816        // store that has not opted in, and an older one refuses it.
7817        //
7818        // `keep_wal: true` because opting in is not a compaction: an operator
7819        // asking for multiplicity has not asked to lose the history `open_at`
7820        // can reach.
7821        self.multiplicity = true;
7822        let forced = self
7823            .snapshot_with(SnapshotOptions {
7824                keep_wal: true,
7825                ..SnapshotOptions::default()
7826            })
7827            .and_then(|()| self.log_then_apply(core_storage::wal::MULTIPLICITY_ENABLED));
7828        if forced.is_err() {
7829            // This handle stops believing it is opted in. That is all this line
7830            // does — it is not a rollback, and cannot be one: the declaration
7831            // may already be in `wal.bin` (the append succeeded and only the
7832            // fsync failed), and even when it is not, the V10 snapshot beside an
7833            // existing archive is enough for the open-time recovery to opt the
7834            // store in. See the "not atomic" section on this method.
7835            //
7836            // It fails in the safe direction either way: the V10 stamp reached
7837            // disk before anything a v0.6.9 reader would truncate at, so the
7838            // worst an interruption costs that reader is a refusal by name.
7839            self.multiplicity = false;
7840        }
7841        forced
7842    }
7843
7844    /// Whether this store records insert-count multiplicity.
7845    ///
7846    /// `false` on every store that has not called
7847    /// [`enable_multiplicity`](Self::enable_multiplicity) — which is every
7848    /// store that did not ask for it, including one upgraded from an earlier
7849    /// release.
7850    pub fn is_multiplicity_enabled(&self) -> bool {
7851        self.multiplicity
7852    }
7853
7854    /// How many times `(etype, src, dst)` has been inserted: the reserved
7855    /// `count` edge property, or 1 when it is absent.
7856    ///
7857    /// Answers 1 for a pair on a store that never opted in, which is the truth
7858    /// available there — the pair was inserted at least once, and the store
7859    /// kept no record of any second insert.
7860    fn edge_insert_count(&self, etype: u32, src: u32, dst: u32) -> u64 {
7861        match self.edge_props_view().get(etype, src, dst, EDGE_COUNT_PROP) {
7862            Some(Value::Int(n)) if n > 0 => n as u64,
7863            _ => 1,
7864        }
7865    }
7866
7867    /// The `SetEdgeCount` record a duplicate insert of `(edge_type, src_key,
7868    /// dst_key)` should log, or `None` when nothing should be written.
7869    ///
7870    /// `None` when the store has not opted in, so **no discriminant-23 record
7871    /// is written at all** — the gate the whole feature rests on.
7872    ///
7873    /// The other two `None`s are unreachable from the one caller. This is the
7874    /// single-mutation path, where `prepare_insert_edge` has already refused a
7875    /// missing endpoint and an existing pair's edge type is necessarily
7876    /// interned. A batch is the case where a pair's endpoints and type can all
7877    /// be created by the same frame, and it does not come through here: it
7878    /// queues a [`PlannedRec::DuplicateCount`] and names the count in the dense
7879    /// rewrite, which is the only pass that knows the frame's own ids.
7880    fn edge_count_record(
7881        &self,
7882        edge_type: &str,
7883        src_key: &str,
7884        dst_key: &str,
7885    ) -> Option<WalRecord> {
7886        if !self.multiplicity {
7887            return None;
7888        }
7889        let etype = self.syms.get(edge_type)?;
7890        let src = self.ids.get(src_key)?;
7891        let dst = self.ids.get(dst_key)?;
7892        Some(WalRecord::SetEdgeCount {
7893            etype,
7894            src,
7895            dst,
7896            count: self.edge_insert_count(etype, src, dst).saturating_add(1),
7897        })
7898    }
7899
7900    /// Search a full-text-indexed field.
7901    ///
7902    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
7903    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
7904    ///
7905    /// **Query syntax:**
7906    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
7907    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
7908    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
7909    /// - `AND` keyword is accepted explicitly and is the default.
7910    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
7911    ///
7912    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
7913    /// Pin: this is the documented, tested, stable behavior for v1.
7914    ///
7915    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
7916    /// in-memory and proportional to total indexed text across all enabled fields.
7917    ///
7918    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
7919    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
7920    /// key ascending for deterministic tiebreaking.
7921    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
7922        // Resolve node_ids to keys (excluding tombstones) then re-sort by
7923        // (score DESC, key ASC) to give a deterministic, key-lexicographic
7924        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
7925        // which diverges from key order when nodes were not inserted in key-lex order.
7926        let mut results: Vec<(String, f64)> = self
7927            .fulltext
7928            .search(field, query, 0)
7929            .into_iter()
7930            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
7931            .collect();
7932        results.sort_by(|a, b| {
7933            b.1.partial_cmp(&a.1)
7934                .unwrap_or(std::cmp::Ordering::Equal)
7935                .then(a.0.cmp(&b.0))
7936        });
7937        results
7938    }
7939
7940    /// [`search`](Self::search), stopping at the `k` best hits.
7941    ///
7942    /// Same ranking and the same deterministic tiebreak, but the index drops
7943    /// everything past `k` before any key is resolved, so a caller that wants
7944    /// the top few out of a field that matched thousands does not pay to
7945    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
7946    /// [`search`](Self::search) behaves.
7947    ///
7948    /// The BM25 scoring itself is not bounded by `k` — every candidate is
7949    /// scored either way — so this trims the resolve and the sort, not the
7950    /// search.
7951    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
7952        // A tombstoned id resolves to nothing, so asking the index for exactly
7953        // `k` could return fewer. Over-fetching a little and truncating after
7954        // the filter keeps the count right without unbounding the call.
7955        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
7956        let mut results: Vec<(String, f64)> = self
7957            .fulltext
7958            .search(field, query, want)
7959            .into_iter()
7960            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
7961            .collect();
7962        results.sort_by(|a, b| {
7963            b.1.partial_cmp(&a.1)
7964                .unwrap_or(std::cmp::Ordering::Equal)
7965                .then(a.0.cmp(&b.0))
7966        });
7967        if k > 0 {
7968            results.truncate(k);
7969        }
7970        results
7971    }
7972
7973    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
7974    ///
7975    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
7976    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
7977    /// them with RRF using a fixed constant of 60.
7978    ///
7979    /// ```text
7980    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
7981    /// ```
7982    ///
7983    /// Returns the top `k` nodes by fused score, ties broken by node key
7984    /// ascending (deterministic).
7985    ///
7986    /// # Vector leg fallback
7987    ///
7988    /// When `query_vec` is empty the vector leg is skipped entirely and
7989    /// results are ranked by the text list alone through the same RRF path
7990    /// (each text result scores `1/(60 + rank)` from that single list).
7991    ///
7992    /// When `label` is `None`, the vector leg **always** returns empty results.
7993    /// Internally `label` is mapped to `""`, which does not match any rule-created
7994    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
7995    /// the brute-force fallback finds no nodes with an empty label.  The fused
7996    /// ranking is therefore text-only in this case.
7997    pub fn search_hybrid(
7998        &self,
7999        text_field: &str,
8000        query_text: &str,
8001        vector_field: &str,
8002        query_vec: &[f64],
8003        label: Option<&str>,
8004        k: usize,
8005    ) -> Vec<(String, f64)> {
8006        self.search_hybrid_inner(
8007            text_field,
8008            query_text,
8009            vector_field,
8010            query_vec,
8011            label,
8012            k,
8013            None,
8014        )
8015    }
8016
8017    /// [`search_hybrid`](Self::search_hybrid) with **each leg** filtered to the
8018    /// mask before the fusion.
8019    ///
8020    /// Filtering the fused list afterwards would quietly return fewer than `k`.
8021    /// Each leg over-fetches `4*k` candidates, so when the visible nodes rank
8022    /// below `4*k` hidden ones neither leg carries them into the fusion at all
8023    /// and the post-filter has nothing left to keep. Filtering first spends the
8024    /// `4*k` on **visible** hits, so a scoped call is as long as the corpus it
8025    /// can see allows.
8026    ///
8027    /// The ranks that enter RRF are therefore the ranks of the visible corpus,
8028    /// not the visible entries of the store-wide ranking. The constant stays 60
8029    /// and the tiebreak stays key-ascending.
8030    #[allow(clippy::too_many_arguments)]
8031    pub fn search_hybrid_scoped(
8032        &self,
8033        text_field: &str,
8034        query_text: &str,
8035        vector_field: &str,
8036        query_vec: &[f64],
8037        label: Option<&str>,
8038        k: usize,
8039        mask: &crate::mask::NodeMask,
8040    ) -> Vec<(String, f64)> {
8041        self.search_hybrid_inner(
8042            text_field,
8043            query_text,
8044            vector_field,
8045            query_vec,
8046            label,
8047            k,
8048            Some(mask),
8049        )
8050    }
8051
8052    /// The body shared by [`search_hybrid`](Self::search_hybrid) and
8053    /// [`search_hybrid_scoped`](Self::search_hybrid_scoped). `mask = None` is
8054    /// the unscoped contract unchanged: the filter below is then a no-op and
8055    /// the vector leg is the same unmasked call it has always been.
8056    #[allow(clippy::too_many_arguments)]
8057    fn search_hybrid_inner(
8058        &self,
8059        text_field: &str,
8060        query_text: &str,
8061        vector_field: &str,
8062        query_vec: &[f64],
8063        label: Option<&str>,
8064        k: usize,
8065        mask: Option<&crate::mask::NodeMask>,
8066    ) -> Vec<(String, f64)> {
8067        use std::collections::HashMap;
8068
8069        const RRF_K: f64 = 60.0;
8070        let pool = 4 * k;
8071
8072        // Accumulate per-node RRF scores.
8073        let mut scores: HashMap<String, f64> = HashMap::new();
8074
8075        // Text leg. The mask bites on the candidates, before `take(pool)`, so
8076        // the over-fetch is a budget of visible hits rather than one a hidden
8077        // prefix can exhaust.
8078        let text_hits = self.search(text_field, query_text);
8079        let visible_text = text_hits
8080            .into_iter()
8081            .filter(|(key, _count)| mask.is_none_or(|m| m.contains_node(self, key)));
8082        for (rank0, (key, _count)) in visible_text.take(pool).enumerate() {
8083            let rank = (rank0 + 1) as f64;
8084            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8085        }
8086
8087        // Vector leg (skipped when query_vec is empty). The masked variant
8088        // applies the mask before its own k-truncation, for the same reason.
8089        if !query_vec.is_empty() {
8090            // `ExactnessCaller::Hybrid`: the leg is the same one
8091            // `find_similar_vector_masked` runs, but the advice its warning
8092            // gives has to fit *this* signature, which has no `exact`.
8093            let vec_hits = self
8094                .find_similar_vector_as(
8095                    vector_field,
8096                    label,
8097                    query_vec,
8098                    pool,
8099                    0.0,
8100                    mask,
8101                    None,
8102                    false,
8103                    ExactnessCaller::Hybrid,
8104                )
8105                .expect("find_similar_vector_as is infallible without where_");
8106            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
8107                let rank = (rank0 + 1) as f64;
8108                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8109            }
8110        }
8111
8112        // Sort: score DESC, then key ASC for deterministic tie-breaking.
8113        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
8114        ranked.sort_by(|a, b| {
8115            b.1.partial_cmp(&a.1)
8116                .unwrap_or(std::cmp::Ordering::Equal)
8117                .then(a.0.cmp(&b.0))
8118        });
8119        ranked.truncate(k);
8120        ranked
8121    }
8122
8123    /// For DST/testing: scratch BM25 search over live nodes without the index.
8124    /// Walks every live node, re-stems field tokens, computes corpus stats, and
8125    /// returns BM25-ranked results.
8126    ///
8127    /// The oracle: the ordered key list of `search(field, q)` must equal that of
8128    /// `scratch_search(field, q)` at every quiescent state.
8129    #[doc(hidden)]
8130    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8131        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
8132        use std::collections::BTreeMap;
8133
8134        let groups = parse_query(query);
8135        if groups.is_empty() {
8136            return vec![];
8137        }
8138
8139        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
8140        struct NodeData {
8141            key: String,
8142            /// stemmed_token → positions (sorted)
8143            tokens: BTreeMap<String, Vec<u32>>,
8144            dl: u32,
8145        }
8146
8147        let mut nodes: Vec<NodeData> = Vec::new();
8148        for id in 0..self.ids.len() as u32 {
8149            let Some(key) = self.ids.key_of(id) else {
8150                continue;
8151            };
8152            let Some(&sym) = self.labels.get(id as usize) else {
8153                continue;
8154            };
8155            if sym == u32::MAX {
8156                continue;
8157            }
8158            let label = match self.syms.resolve(sym) {
8159                Some(l) => l,
8160                None => continue,
8161            };
8162            if !self.fulltext.is_enabled(label, field) {
8163                continue;
8164            }
8165            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
8166                continue;
8167            };
8168            // Use value_tokens_stemmed_with_positions so list elements are
8169            // separated by POSITION_GAP — identical to the index path, which
8170            // prevents phrase queries from matching across element boundaries.
8171            let stemmed_with_pos = match &value {
8172                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
8173                _ => continue,
8174            };
8175            let dl = stemmed_with_pos.len() as u32;
8176            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
8177            for (tok, pos) in stemmed_with_pos {
8178                tok_map.entry(tok).or_default().push(pos);
8179            }
8180            nodes.push(NodeData {
8181                key: key.to_string(),
8182                tokens: tok_map,
8183                dl,
8184            });
8185        }
8186
8187        if nodes.is_empty() {
8188            return vec![];
8189        }
8190
8191        // --- BM25 corpus stats ---
8192        let n = nodes.len() as f64;
8193        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
8194        // df per stemmed token across all live indexed nodes.
8195        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
8196        for nd in &nodes {
8197            for tok in nd.tokens.keys() {
8198                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
8199            }
8200        }
8201
8202        const K1: f64 = 1.2;
8203        const B: f64 = 0.75;
8204
8205        // --- Pass 2: score each node against each OR-group ---
8206        let mut results: Vec<(String, f64)> = Vec::new();
8207        for nd in &nodes {
8208            let dl = nd.dl as f64;
8209            let mut total_score = 0.0f64;
8210
8211            'group: for group in &groups {
8212                let mut group_score = 0.0f64;
8213
8214                for term in group {
8215                    if term.negated {
8216                        // Negated: if doc has this stemmed token → group fails.
8217                        let present = if term.prefix {
8218                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
8219                        } else {
8220                            nd.tokens.contains_key(term.token.as_str())
8221                        };
8222                        if present {
8223                            continue 'group;
8224                        }
8225                        continue;
8226                    }
8227                    if term.prefix {
8228                        // Prefix: sum BM25 for all matching stemmed tokens.
8229                        let mut prefix_matched = false;
8230                        for (tok, positions) in &nd.tokens {
8231                            if tok.starts_with(term.token.as_str()) {
8232                                let tf = positions.len() as f64;
8233                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
8234                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8235                                let tf_norm =
8236                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8237                                group_score += idf * tf_norm;
8238                                prefix_matched = true;
8239                            }
8240                        }
8241                        if !prefix_matched {
8242                            continue 'group;
8243                        }
8244                    } else {
8245                        // term.token is already stemmed by parse_query; use directly.
8246                        match nd.tokens.get(term.token.as_str()) {
8247                            None => continue 'group,
8248                            Some(positions) => {
8249                                let tf = positions.len() as f64;
8250                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
8251                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8252                                let tf_norm =
8253                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8254                                group_score += idf * tf_norm;
8255                            }
8256                        }
8257                    }
8258                }
8259
8260                if group_score > 0.0 {
8261                    total_score += group_score;
8262                }
8263            }
8264
8265            if total_score > 0.0 {
8266                results.push((nd.key.clone(), total_score));
8267            }
8268        }
8269
8270        results.sort_by(|a, b| {
8271            b.1.partial_cmp(&a.1)
8272                .unwrap_or(std::cmp::Ordering::Equal)
8273                .then(a.0.cmp(&b.0))
8274        });
8275        results
8276    }
8277
8278    /// Return the current view-maintained value of `view_prop` for node `key`.
8279    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
8280    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
8281        let id = self.ids.get(key)?;
8282        self.props_view()
8283            .get(id, view_prop)
8284            .map(|vr| vr.into_value())
8285    }
8286
8287    /// For testing / DST oracle: scratch recompute of a view value for one node.
8288    ///
8289    /// Returns `None` if the node does not exist, the view does not exist, or
8290    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
8291    #[doc(hidden)]
8292    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
8293        let node = self.ids.get(key)?;
8294        let def = self.view_store.views().find(|v| v.name == view_name)?;
8295        // Use TopologyView so that NeighborAgg sees base + overlay edges
8296        // without materialising a temporary Topology (I1).
8297        let topo_view = self.topo_view();
8298        core_rules::views::compute_view_value(
8299            def,
8300            node,
8301            self.props_view(),
8302            &topo_view,
8303            &self.ids,
8304            &self.syms,
8305            &self.labels,
8306        )
8307    }
8308
8309    // -----------------------------------------------------------------------
8310    // Graph algorithm API
8311    // -----------------------------------------------------------------------
8312
8313    /// Run PageRank over the unified topology (manual + derived edges).
8314    ///
8315    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
8316    /// ascending).  Set `config.edge_type` to restrict to one edge type.
8317    /// `config.converged` is `true` only when the power iteration converged
8318    /// within `config.max_iters` and within any time budget.
8319    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
8320        let topo = build_topo_view(&self.topo, &self.base);
8321        let edge_props = self.edge_props_view();
8322        crate::algo::pagerank(
8323            &topo,
8324            &self.ids,
8325            &self.syms,
8326            &self.labels,
8327            &edge_props,
8328            config,
8329        )
8330    }
8331
8332    /// Weakly-connected components over the unified topology (treated as
8333    /// undirected regardless of how edges were inserted).
8334    ///
8335    /// Component IDs are the key of the smallest member in the component
8336    /// (deterministic).  Result sorted by (component_id, key).
8337    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
8338        let topo = build_topo_view(&self.topo, &self.base);
8339        let edge_props = self.edge_props_view();
8340        crate::algo::wcc(
8341            &topo,
8342            &self.ids,
8343            &self.syms,
8344            &self.labels,
8345            &edge_props,
8346            config,
8347        )
8348    }
8349
8350    /// Degree centrality for every live node.
8351    ///
8352    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
8353    /// `AlgoDir::Both` = out + in (total directed degree).
8354    ///
8355    /// For one-shot ranking use this; for a live property updated on every
8356    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
8357    pub fn degree_centrality(
8358        &self,
8359        config: &crate::algo::DegreeConfig,
8360    ) -> crate::algo::DegreeReport {
8361        let topo = build_topo_view(&self.topo, &self.base);
8362        let edge_props = self.edge_props_view();
8363        crate::algo::degree_centrality(
8364            &topo,
8365            &self.ids,
8366            &self.syms,
8367            &self.labels,
8368            &edge_props,
8369            config,
8370        )
8371    }
8372
8373    /// Louvain community detection over the unified topology (undirected).
8374    ///
8375    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
8376    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
8377    /// result (communities sorted size-desc, then smallest member key asc).
8378    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
8379        let topo = build_topo_view(&self.topo, &self.base);
8380        let edge_props = self.edge_props_view();
8381        crate::algo::louvain(
8382            &topo,
8383            &self.ids,
8384            &self.syms,
8385            &self.labels,
8386            &edge_props,
8387            config,
8388        )
8389    }
8390
8391    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
8392    /// atomically via a single write-batch (one WAL frame, one fsync).
8393    ///
8394    /// # Errors
8395    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8396    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
8397    ///   (collision check mirrors `create_view`).
8398    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
8399    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
8400        if self.read_only {
8401            return Err(GraphError::ReadOnly);
8402        }
8403        // Collision check: refuse if prop_name is view-managed.
8404        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
8405            return Err(GraphError::RuleInvalid {
8406                detail: format!(
8407                    "prop {:?} is managed by view {:?} and cannot be written as scores",
8408                    prop_name, view_name
8409                ),
8410            });
8411        }
8412        // Refuse if prop_name is a view name itself (confusing namespace collision).
8413        if self.view_store.has_view(prop_name) {
8414            return Err(GraphError::RuleInvalid {
8415                detail: format!(
8416                    "prop_name {:?} collides with an existing view name",
8417                    prop_name
8418                ),
8419            });
8420        }
8421        // Write all scores in a single crash-atomic batch.
8422        self.write_batch(|b| {
8423            for (key, score) in scores {
8424                b.set_prop(key, prop_name, Value::Float(*score));
8425            }
8426        })?;
8427        Ok(())
8428    }
8429
8430    /// Return the value of `field` for the node with key `key`, or `None` if
8431    /// the node or field is absent.  Reads through the overlay-over-base
8432    /// `ColumnsView`, materialising base values on demand (zero heap cost for
8433    /// overlay hits; one clone per base hit).
8434    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
8435        let id = self.ids.get(key)?;
8436        self.props_view().get(id, field).map(|vr| vr.into_value())
8437    }
8438
8439    pub fn has_node(&self, key: &str) -> bool {
8440        self.ids.get(key).is_some()
8441    }
8442
8443    /// One human line for a node: the first of `text`, `summary` or `name` it
8444    /// carries, truncated. Used by the recall digest.
8445    pub fn node_summary_line(&self, key: &str) -> Option<String> {
8446        for field in ["text", "summary", "name"] {
8447            if let Some(Value::Str(s)) = self.get_prop(key, field) {
8448                let s = s.trim();
8449                if !s.is_empty() {
8450                    return Some(s.chars().take(120).collect());
8451                }
8452            }
8453        }
8454        None
8455    }
8456
8457    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
8458    pub(crate) fn ids(&self) -> &IdMap {
8459        &self.ids
8460    }
8461
8462    // -----------------------------------------------------------------------
8463    // Namespaces
8464    // -----------------------------------------------------------------------
8465
8466    /// The index `name` already has in `ns_names`, if any.
8467    fn ns_index_of(&self, name: &str) -> Option<u32> {
8468        self.ns_names
8469            .iter()
8470            .position(|n| n == name)
8471            .map(|i| i as u32)
8472    }
8473
8474    /// The index for `name`, appending it to `ns_names` when it is new.
8475    ///
8476    /// The table holds one entry per distinct namespace in the store — a
8477    /// tenant count, not a node count — so the linear scan is cheaper than a
8478    /// map and keeps `namespaces()` allocation-free of a second index.
8479    fn ns_index_for(&mut self, name: &str) -> u32 {
8480        match self.ns_index_of(name) {
8481            Some(i) => i,
8482            None => {
8483                self.ns_names.push(name.to_string());
8484                (self.ns_names.len() - 1) as u32
8485            }
8486        }
8487    }
8488
8489    /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
8490    /// does not know (unreachable; the default is the narrowing answer).
8491    fn ns_name(&self, idx: u32) -> &str {
8492        self.ns_names
8493            .get(idx as usize)
8494            .map(String::as_str)
8495            .unwrap_or(NS_DEFAULT)
8496    }
8497
8498    /// The namespace index of dense node `id`, defaulting for an id with no
8499    /// entry (a node inserted before this handle rebuilt the array cannot
8500    /// exist: every insert path maintains it).
8501    fn node_ns_idx(&self, id: u32) -> u32 {
8502        self.node_ns
8503            .get(id as usize)
8504            .copied()
8505            .unwrap_or(NS_DEFAULT_IDX)
8506    }
8507
8508    /// File node `id` under namespace `name`, growing `node_ns` as `labels`
8509    /// grows. Called from `apply` for every node insert, live and replayed.
8510    fn set_node_ns(&mut self, id: u32, name: &str) {
8511        let idx = if name == NS_DEFAULT {
8512            NS_DEFAULT_IDX
8513        } else {
8514            self.ns_index_for(name)
8515        };
8516        if self.node_ns.len() <= id as usize {
8517            self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
8518        }
8519        self.node_ns[id as usize] = idx;
8520    }
8521
8522    /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
8523    /// open or a reload, after the snapshot is restored and the WAL replayed.
8524    ///
8525    /// A store with no `ns` column reads nothing: the column-name check fails
8526    /// and the vector is filled with one constant.
8527    fn rebuild_node_ns(&mut self) {
8528        let total = self.ids.len();
8529        self.ns_names.truncate(1);
8530        self.node_ns.clear();
8531        self.node_ns.resize(total, NS_DEFAULT_IDX);
8532        let has_ns_column = {
8533            let cv = self.props_view();
8534            cv.field_names().iter().any(|f| f == NS_PROP)
8535        };
8536        if !has_ns_column {
8537            return;
8538        }
8539        // Collected first so the props view is released before `ns_index_for`
8540        // takes `&mut self`.
8541        let named: Vec<(u32, String)> = {
8542            let cv = self.props_view();
8543            (0..total as u32)
8544                .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
8545                    Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
8546                    _ => None,
8547                })
8548                .collect()
8549        };
8550        for (id, name) in named {
8551            let idx = self.ns_index_for(&name);
8552            self.node_ns[id as usize] = idx;
8553        }
8554    }
8555
8556    /// Every namespace with at least one live node, in name order.
8557    ///
8558    /// `["default"]` on any store that has never named a namespace, including
8559    /// an empty one: a store is always at least its default namespace.
8560    pub fn namespaces(&self) -> Vec<String> {
8561        let mut out: BTreeSet<&str> = BTreeSet::new();
8562        out.insert(NS_DEFAULT);
8563        for (id, &idx) in self.node_ns.iter().enumerate() {
8564            if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
8565                continue;
8566            }
8567            out.insert(self.ns_name(idx));
8568        }
8569        out.into_iter().map(str::to_string).collect()
8570    }
8571
8572    /// The namespace of `key`, or `None` when the key names no live node.
8573    pub fn namespace_of(&self, key: &str) -> Option<String> {
8574        let id = self.ids.get(key)?;
8575        if !self.is_live_node(id) {
8576            return None;
8577        }
8578        Some(self.ns_name(self.node_ns_idx(id)).to_string())
8579    }
8580
8581    /// Every live node in `namespace`, as a visibility mask.
8582    ///
8583    /// Built off `node_ns` on whichever handle this is, so on a temporal handle
8584    /// it is the namespace's membership at that commit. A name no node uses
8585    /// gives an empty mask — a namespace scope never widens.
8586    pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
8587        let Some(idx) = self.ns_index_of(namespace) else {
8588            return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
8589        };
8590        let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
8591            .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
8592            .collect();
8593        crate::mask::NodeMask::from_ids(visible)
8594    }
8595
8596    /// Live-node test used by the namespace accessors: a deleted node keeps its
8597    /// dense id and its `node_ns` slot, and the label sentinel is what marks it
8598    /// gone — the same test `mask_for_role`'s label leg applies implicitly.
8599    fn is_live_node(&self, id: u32) -> bool {
8600        self.labels
8601            .get(id as usize)
8602            .is_some_and(|&sym| sym != u32::MAX)
8603            && self.ids.key_of(id).is_some()
8604    }
8605
8606    /// Per-namespace live node counts for [`Stats`], in name order.
8607    fn namespace_stats(&self) -> Vec<NamespaceStats> {
8608        let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
8609        counts.insert(NS_DEFAULT, 0);
8610        for id in 0..self.ids.len() as u32 {
8611            if !self.is_live_node(id) {
8612                continue;
8613            }
8614            *counts
8615                .entry(self.ns_name(self.node_ns_idx(id)))
8616                .or_insert(0) += 1;
8617        }
8618        counts
8619            .into_iter()
8620            .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
8621            .map(|(name, nodes_live)| NamespaceStats {
8622                name: name.to_string(),
8623                nodes_live,
8624            })
8625            .collect()
8626    }
8627
8628    /// The namespace a create-class op would put its node in: the `ns` entry of
8629    /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
8630    fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
8631        Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
8632    }
8633
8634    /// The one `ns` entry in a node's props, or `None` when it carries none.
8635    ///
8636    /// A props list naming `ns` twice is refused. Without that refusal the
8637    /// write path and the authorisation path can read the same list
8638    /// differently — one taking the first entry, the other the last — and
8639    /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
8640    /// the role was checked against the other of. One entry is the only shape
8641    /// where "the node's namespace" is a single fact, so it is the only shape
8642    /// accepted, and every reader of it agrees by construction.
8643    fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
8644        let mut found: Option<&'a Value> = None;
8645        for (field, value) in props {
8646            if field != NS_PROP {
8647                continue;
8648            }
8649            if found.is_some() {
8650                return Err(GraphError::RuleInvalid {
8651                    detail: format!(
8652                        "node {key}: {NS_PROP} is given more than once; a node has exactly \
8653                         one namespace"
8654                    ),
8655                });
8656            }
8657            found = Some(value);
8658        }
8659        Ok(found)
8660    }
8661
8662    /// The definition of the role a write authorisation names.
8663    ///
8664    /// `None` when `roles.json` was corrupt at open or the role has since been
8665    /// removed — neither can reach a write, because the authorisation carries a
8666    /// mask `mask_for_role` already resolved for that name.
8667    fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
8668        self.roles.as_ref()?.iter().find(|r| r.name == role)
8669    }
8670
8671    /// Validate the `ns` entry of a node's props and drop an explicit default.
8672    ///
8673    /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
8674    /// a record that reached the WAL was already accepted here.
8675    fn normalise_insert_ns(
8676        key: &str,
8677        props: Vec<(String, Value)>,
8678    ) -> Result<(Vec<(String, Value)>, String)> {
8679        // One `ns` or none: this is where that is enforced, so every later
8680        // reader of the list — the authorisation gate, the two `apply` arms,
8681        // `node_ns` — is looking at a single entry and cannot disagree about
8682        // which one counts.
8683        Self::sole_ns_entry(key, &props)?;
8684        let mut name = NS_DEFAULT.to_string();
8685        let mut out = Vec::with_capacity(props.len());
8686        for (field, value) in props {
8687            if field != NS_PROP {
8688                out.push((field, value));
8689                continue;
8690            }
8691            let Value::Str(ref s) = value else {
8692                return Err(GraphError::RuleInvalid {
8693                    detail: format!(
8694                        "node {key}: {NS_PROP} must be a string naming a namespace, \
8695                         got {value:?}"
8696                    ),
8697                });
8698            };
8699            if !valid_namespace(s) {
8700                return Err(GraphError::RuleInvalid {
8701                    detail: format!(
8702                        "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
8703                         characters of [A-Za-z0-9_.-]"
8704                    ),
8705                });
8706            }
8707            name = s.clone();
8708            // An explicit default stores nothing, so a single-tenant store
8709            // never grows an `ns` column.
8710            if name != NS_DEFAULT {
8711                out.push((field, value));
8712            }
8713        }
8714        Ok((out, name))
8715    }
8716
8717    // -----------------------------------------------------------------------
8718    // RBAC role resolution
8719    // -----------------------------------------------------------------------
8720
8721    /// Parse `roles.json` bytes from `fs`.
8722    ///
8723    /// Return values:
8724    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
8725    ///                       and valid; in both cases `mask_for_role` uses the
8726    ///                       list normally (an absent file means no roles defined).
8727    ///   `Ok(None)`        — file present but corrupt or unrecognised version
8728    ///                       → poisoned state; `mask_for_role` returns `Err` for
8729    ///                       any role name until the file is fixed and the DB
8730    ///                       re-opened (or `apply_schema` is called to repair it).
8731    ///
8732    /// Note: `None` signals corruption, not absence — the opposite of what an
8733    /// optional "file missing" convention would suggest.  The open path stores
8734    /// this result on `db.roles` directly.
8735    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
8736        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
8737        if bytes.is_empty() {
8738            // Empty bytes means either the file is absent or zero-byte — both
8739            // are treated identically as "no roles defined".  A zero-byte
8740            // roles.json does NOT widen access: an absent file and a zero-byte
8741            // file both resolve to an empty role list (sees nothing by default).
8742            return Ok(Some(vec![]));
8743        }
8744        match serde_json::from_slice::<RolesFile>(&bytes) {
8745            Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
8746            // Corrupt or unrecognised version (>4): poison the roles state.
8747            // Never widen: a version this binary does not know may carry a
8748            // narrowing this binary would not apply.
8749            _ => Ok(None),
8750        }
8751    }
8752
8753    /// Resolve a role to a node-visibility mask against the current graph state.
8754    ///
8755    /// Returns `Err` when:
8756    /// - `roles.json` was present but corrupt at open (poisoned state), or
8757    /// - `role` does not match any defined role name.
8758    ///
8759    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
8760    /// all live nodes carrying any label in `labels` that also pass the role's
8761    /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
8762    /// has one.  Label resolution is live — new nodes of an allowed label are
8763    /// visible without re-applying the schema, and a property edited out of the
8764    /// predicate takes its node out of the mask on the next read.  An empty
8765    /// union = empty mask = sees nothing.
8766    ///
8767    /// This is the one resolver every read path calls, live and as-of alike, so
8768    /// the predicate applies everywhere at once.  On an as-of handle the role
8769    /// *definition* is the current one and the graph is the historical one: the
8770    /// predicate is evaluated against the property values at the commit being
8771    /// read.
8772    ///
8773    /// The result is memoised per `(role, commit_seq)`, so a scoped reader
8774    /// between two writes resolves the role once.  See
8775    /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
8776    /// stale.
8777    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
8778        self.role_masks
8779            .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
8780            .map(|m| (*m).clone())
8781    }
8782
8783    /// The mask an [`AsOfScope`] names, resolved against this handle.
8784    ///
8785    /// Shared by [`GraphDb::query_at_scoped`] and
8786    /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
8787    /// however the namespace leg is added.
8788    fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
8789        // One resolver answers "what may this role see" — `mask_for_role` — and
8790        // it runs against this handle, so on a temporal one the answer is the
8791        // as-of one.
8792        Ok(match scope {
8793            AsOfScope::Role(role) => self.mask_for_role(role)?,
8794            AsOfScope::Keys(keys) => {
8795                crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
8796            }
8797            AsOfScope::RoleAndKeys(role, keys) => {
8798                self.mask_for_role(role)?
8799                    .intersect(&crate::mask::NodeMask::from_keys(
8800                        self,
8801                        keys.iter().map(String::as_str),
8802                    ))
8803            }
8804            AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
8805        })
8806    }
8807
8808    /// Resolve `role` against the current graph, ignoring the memo.
8809    fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
8810        let roles = self.roles.as_ref().ok_or_else(roles_poisoned)?;
8811        let def = roles
8812            .iter()
8813            .find(|r| r.name == role)
8814            .ok_or_else(|| GraphError::KeyNotFound {
8815                key: format!("role:{role}"),
8816            })?;
8817
8818        let mut visible = std::collections::HashSet::new();
8819
8820        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
8821        // An administrative grant, never narrowed by the predicate.
8822        for key in &def.keys {
8823            if let Some(id) = self.ids.get(key) {
8824                visible.insert(id);
8825            }
8826        }
8827
8828        // Label leg: live scan — iterate labels vec for matching symbol, and
8829        // when the role carries a predicate, test the property as well.  The
8830        // property comes from the store's own merged view (overlay over the
8831        // mmap'd base), so an as-of handle reads the values of its own commit.
8832        let props = def.visible_where.as_ref().map(|_| self.props_view());
8833        for label_name in &def.labels {
8834            if let Some(sym) = self.syms.get(label_name) {
8835                for (i, &s) in self.labels.iter().enumerate() {
8836                    if s != sym {
8837                        continue;
8838                    }
8839                    let id = i as u32;
8840                    match (&def.visible_where, &props) {
8841                        (Some(pred), Some(view)) => {
8842                            let value = view.get(id, &pred.field).map(|vr| vr.into_value());
8843                            if pred.holds(value.as_ref()) {
8844                                visible.insert(id);
8845                            }
8846                        }
8847                        _ => {
8848                            visible.insert(id);
8849                        }
8850                    }
8851                }
8852            }
8853        }
8854
8855        // Namespace leg: an intersection over the whole union, the key leg
8856        // included. A namespace is a tenancy boundary, so a key naming a node in
8857        // another tenant's namespace is not an administrative grant — and
8858        // `apply_schema` has already refused that role, so this only has to be
8859        // right about the node that moved into existence afterwards.
8860        if def.namespaces.is_some() {
8861            visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
8862        }
8863
8864        Ok(crate::mask::NodeMask::from_ids(visible))
8865    }
8866
8867    /// Return the current list of role definitions.
8868    ///
8869    /// Returns an empty list when no roles are defined or when `roles.json`
8870    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
8871    /// the fail-loud error in that case, or call
8872    /// [`roles_checked`](Self::roles_checked), which is this readout with that
8873    /// error in it).
8874    pub fn roles(&self) -> Vec<RoleDef> {
8875        self.roles.as_deref().unwrap_or(&[]).to_vec()
8876    }
8877
8878    /// The role definitions, or the poison error when `roles.json` was corrupt
8879    /// at open.
8880    ///
8881    /// [`roles`](Self::roles) answers `[]` both for a store that defines no
8882    /// roles and for one whose sidecar did not parse, and a caller validating a
8883    /// role name at boot cannot tell those apart. The wrong reading of the pair
8884    /// is the dangerous one: a store with no roles at all is an unrestricted
8885    /// store, so a poisoned file would read as "nothing is restricted here".
8886    ///
8887    /// This is the same answer, for the same cause, that
8888    /// [`mask_for_role`](Self::mask_for_role) gives on the first read.
8889    pub fn roles_checked(&self) -> Result<Vec<RoleDef>> {
8890        match self.roles.as_deref() {
8891            Some(roles) => Ok(roles.to_vec()),
8892            None => Err(roles_poisoned()),
8893        }
8894    }
8895
8896    // ── Role-scoped write authz ───────────────────────────────────────────────
8897
8898    /// Execute `ops` with optional role-scoped write authorization.
8899    ///
8900    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
8901    ///   (zero-cost bypass of all authz checks).
8902    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
8903    ///   record is built.  A denial returns an error with no WAL frame written
8904    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
8905    ///
8906    /// See the plan's "authz decision table" section for the full semantics.
8907    pub fn write_batch_authz(
8908        &mut self,
8909        authz: Option<&WriteAuthz>,
8910        ops: Vec<BatchOp>,
8911    ) -> Result<(usize, usize)> {
8912        // Thread authz as a direct parameter — never touches pending_write_authz.
8913        self.commit_logged_batch(ops, None, authz.cloned())
8914            .map(inserted_pair)
8915    }
8916
8917    /// Execute a Cypher write statement with role-scoped write authorization.
8918    ///
8919    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
8920    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
8921    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
8922    /// call so that all inner `batch.commit()` calls are authz-checked.
8923    ///
8924    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
8925    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
8926    /// timing-oracle item (hidden ≡ absent for unscoped roles).
8927    ///
8928    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
8929    /// "this endpoint is not permitted".
8930    pub fn query_write_authz(
8931        &mut self,
8932        role: &str,
8933        cypher: &str,
8934        params: &BTreeMap<String, Value>,
8935    ) -> Result<ResultSet> {
8936        // Resolve scope (fails fast if role has no write scope).
8937        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
8938        let scope =
8939            {
8940                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
8941                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
8942                })?;
8943                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
8944                    GraphError::KeyNotFound {
8945                        key: format!("role:{role}"),
8946                    }
8947                })?;
8948                def.write
8949                    .clone()
8950                    .ok_or_else(|| GraphError::RoleWriteDenied {
8951                        reason: "role-bound token: writes are not permitted".into(),
8952                    })?
8953            };
8954        // Resolve mask inside the call (same guard, §5 coherence).
8955        let mask = self.mask_for_role(role)?;
8956        self.pending_write_authz = Some(WriteAuthz {
8957            role: role.into(),
8958            scope,
8959            mask,
8960        });
8961        // RAII guard: always clears pending_write_authz on scope exit, including
8962        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
8963        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
8964        impl Drop for ClearPendingAuthzOnDrop {
8965            fn drop(&mut self) {
8966                // SAFETY: pointer into the owning GraphDb; guard is dropped
8967                // within this function's frame before it returns.
8968                unsafe { *self.0 = None };
8969            }
8970        }
8971        // SAFETY: raw pointer into self; guard dropped before this fn returns.
8972        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
8973        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
8974            detail: format!("lex: {e}"),
8975        })?;
8976        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
8977            detail: format!("parse: {e}"),
8978        })?;
8979        self.exec_write_stmt(stmt, params)
8980    }
8981
8982    /// Execute `ops` with optional role-scoped write authorization, suppressing
8983    /// fsync (for use inside the group-commit drain thread, which performs one
8984    /// group fsync after releasing the write lock).
8985    ///
8986    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
8987    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
8988    /// contract established by [`commit_batch_nosync`].
8989    pub(crate) fn write_batch_authz_nosync(
8990        &mut self,
8991        authz: Option<&WriteAuthz>,
8992        ops: Vec<BatchOp>,
8993    ) -> Result<(usize, usize)> {
8994        let saved = self.fsync;
8995        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
8996        impl Drop for RestoreFsync {
8997            fn drop(&mut self) {
8998                // SAFETY: pointer into the owning GraphDb; guard is dropped
8999                // within the enclosing function's frame before it returns.
9000                unsafe { *self.0 = self.1 };
9001            }
9002        }
9003        // SAFETY: raw pointer into self; guard dropped before this fn returns.
9004        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
9005        self.fsync = FsyncPolicy::Relaxed;
9006        self.commit_logged_batch(ops, None, authz.cloned())
9007            .map(inserted_pair)
9008    }
9009
9010    /// Execute a `/ingest` request with role-scoped write authorization.
9011    ///
9012    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
9013    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
9014    /// Sets `pending_write_authz` for the duration of the call so that the
9015    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
9016    /// and evaluates the decision table per-op before any WAL write.
9017    ///
9018    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
9019    /// denied by the decision table with the appropriate §4.3 scope reason;
9020    /// no special HTTP-layer check is needed.
9021    ///
9022    /// Roles with `write: None` return `RoleWriteDenied` with
9023    /// "writes are not permitted" (byte-identical to v1 blanket 403).
9024    pub fn ingest_with_edges_authz(
9025        &mut self,
9026        role: &str,
9027        label: &str,
9028        rows: Vec<std::collections::BTreeMap<String, Value>>,
9029        opts: &crate::ingest::IngestOptions,
9030        edges: &[(String, String, String)],
9031    ) -> Result<crate::ingest::IngestReport> {
9032        // Resolve scope (fails fast if role has no write scope).
9033        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9034        let scope =
9035            {
9036                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9037                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9038                })?;
9039                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9040                    GraphError::KeyNotFound {
9041                        key: format!("role:{role}"),
9042                    }
9043                })?;
9044                def.write
9045                    .clone()
9046                    .ok_or_else(|| GraphError::RoleWriteDenied {
9047                        reason: "role-bound token: writes are not permitted".into(),
9048                    })?
9049            };
9050        let mask = self.mask_for_role(role)?;
9051        self.pending_write_authz = Some(WriteAuthz {
9052            role: role.into(),
9053            scope,
9054            mask,
9055        });
9056        // RAII guard: always clears pending_write_authz on scope exit, including
9057        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9058        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9059        impl Drop for ClearPendingAuthzOnDrop {
9060            fn drop(&mut self) {
9061                // SAFETY: pointer into the owning GraphDb; guard is dropped
9062                // within this function's frame before it returns.
9063                unsafe { *self.0 = None };
9064            }
9065        }
9066        // SAFETY: raw pointer into self; guard dropped before this fn returns.
9067        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9068        self.ingest_with_edges(label, rows, opts, edges)
9069    }
9070
9071    /// Evaluate the write-authz decision table for one `BatchOp`.
9072    ///
9073    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
9074    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
9075    /// the remaining ops are not evaluated and no WAL frame is written.
9076    ///
9077    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
9078    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
9079    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
9080    /// batch creates counts as visible if its label passed the create-class gate").
9081    fn check_single_op_authz(
9082        &self,
9083        authz: &WriteAuthz,
9084        op: &BatchOp,
9085        batch_created: &BTreeMap<String, String>,
9086    ) -> Result<()> {
9087        // Helper: 3-way node status under the authz mask.
9088        //
9089        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
9090        // as Visible with their recorded label — their create gate already passed
9091        // and they are not yet in self.ids (not committed).  This fixes the
9092        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
9093        // the SetProp must not see the node as Absent.
9094        let node_status = |key: &str| -> NodeAuthzStatus {
9095            if let Some(label) = batch_created.get(key) {
9096                return NodeAuthzStatus::Visible(label.clone());
9097            }
9098            match self.ids.get(key) {
9099                None => NodeAuthzStatus::Absent,
9100                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
9101                Some(id) => {
9102                    let label = self
9103                        .labels
9104                        .get(id as usize)
9105                        .and_then(|&sym| {
9106                            if sym == u32::MAX {
9107                                None
9108                            } else {
9109                                self.syms.resolve(sym).map(str::to_string)
9110                            }
9111                        })
9112                        .unwrap_or_default();
9113                    NodeAuthzStatus::Visible(label)
9114                }
9115            }
9116        };
9117
9118        // Helper: is an InsertEdgeUpsert endpoint visible?
9119        // A same-batch placeholder counts as visible if its label passed
9120        // the create-class gate (spec "upsert placeholder-counts-as-visible").
9121        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
9122            // In store and visible?
9123            if let Some(id) = self.ids.get(ep_key) {
9124                return authz.mask.contains_id(id);
9125            }
9126            // Created by an earlier op in this batch?
9127            if let Some(created_label) = batch_created.get(ep_key) {
9128                return authz.scope.create_labels.contains(created_label);
9129            }
9130            // Will be created by THIS InsertEdgeUpsert: placeholder_label
9131            // must pass the create-class gate.
9132            authz
9133                .scope
9134                .create_labels
9135                .contains(&placeholder_label.to_string())
9136        };
9137
9138        match op {
9139            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
9140            // These ops are never routed to role-scoped paths by the HTTP layer,
9141            // but we 403 them here to close any future bypass route.
9142            //
9143            // InsertNodeOnConflict joins them: it is reachable only from the
9144            // embedded Python binding, which has no role token, and `Replace`
9145            // is a create and an update at once. Rather than split the decision
9146            // table for an op no role-scoped path constructs, refuse it — a
9147            // role-scoped caller writes through the ops that are already in the
9148            // table.
9149            BatchOp::RenameNode { .. }
9150            | BatchOp::CreateRule(_)
9151            | BatchOp::DeleteRule { .. }
9152            | BatchOp::InsertNodeOnConflict { .. } => {
9153                return Err(GraphError::RoleWriteDenied {
9154                    reason: "role-bound token: this endpoint is not permitted".into(),
9155                });
9156            }
9157
9158            // ── CREATE-class: InsertNode ─────────────────────────────────────
9159            //
9160            // Decision table row 1 (scope-before-lookup): check label in
9161            // create_labels BEFORE any key lookup.  This is the structural
9162            // closure of the §6.2 timing-oracle item — the denial fires even
9163            // when the store is EMPTY (see test_create_scope_denied_empty_store).
9164            BatchOp::InsertNode { label, key, props } => {
9165                if !authz.scope.create_labels.contains(label) {
9166                    return Err(GraphError::RoleWriteDenied {
9167                        reason: format!(
9168                            "role-bound token: label '{}' not in write scope (create_labels)",
9169                            label
9170                        ),
9171                    });
9172                }
9173                // A role bound to namespaces may only create inside them. The
9174                // never-widen rule is about what a write makes visible to *any*
9175                // party, not only to the writer: a node this role could never
9176                // read back is a write into somebody else's tenancy. Also a
9177                // scope check, so it runs before the key lookup — it discloses
9178                // nothing about the store. Covers Cypher `CREATE` and the node
9179                // `MERGE` creates, both of which arrive as this op.
9180                // Resolved before the role lookup so a props list naming `ns`
9181                // twice is refused for every role, scoped or not: it is the same
9182                // malformed write the seam refuses, and leaving it to the seam
9183                // would mean the gate had already read one of the two.
9184                let target = Self::created_namespace(key, props)?;
9185                if let Some(def) = self.role_def_for(&authz.role) {
9186                    if !def.sees_namespace(target) {
9187                        return Err(GraphError::RoleWriteDenied {
9188                            reason: format!(
9189                                "role-bound token: namespace '{target}' not in the role's \
9190                                 namespaces"
9191                            ),
9192                        });
9193                    }
9194                }
9195                // Row 2/3: key lookup.
9196                match self.ids.get(key.as_str()) {
9197                    Some(id) if authz.mask.contains_id(id) => {
9198                        // Visible: DuplicateKey — let MutPreview handle this.
9199                    }
9200                    Some(_) => {
9201                        // Hidden: indistinguishable from absent to the role.
9202                        return Err(GraphError::RoleWriteDenied {
9203                            reason: "role-bound token: target node not visible".into(),
9204                        });
9205                    }
9206                    None => {
9207                        // Absent: proceed (create).
9208                    }
9209                }
9210            }
9211
9212            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
9213            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
9214                if batch_created.contains_key(key.as_str()) {
9215                    // Batch-created node: create gate already passed this batch.
9216                    // Updating it in the same batch is always allowed, regardless
9217                    // of update_labels (ruling §3.5: "writer just created it").
9218                } else {
9219                    let label = match node_status(key) {
9220                        NodeAuthzStatus::Visible(lbl) => lbl,
9221                        _ => {
9222                            return Err(GraphError::RoleWriteDenied {
9223                                reason: "role-bound token: target node not visible".into(),
9224                            });
9225                        }
9226                    };
9227                    if !authz.scope.update_labels.contains(&label) {
9228                        return Err(GraphError::RoleWriteDenied {
9229                            reason: format!(
9230                                "role-bound token: label '{}' not in write scope (update_labels)",
9231                                label
9232                            ),
9233                        });
9234                    }
9235                }
9236            }
9237
9238            // ── DELETE-class: DeleteNode ─────────────────────────────────────
9239            BatchOp::DeleteNode { key } => {
9240                let label = match node_status(key) {
9241                    NodeAuthzStatus::Visible(lbl) => lbl,
9242                    _ => {
9243                        return Err(GraphError::RoleWriteDenied {
9244                            reason: "role-bound token: target node not visible".into(),
9245                        });
9246                    }
9247                };
9248                if !authz.scope.delete_labels.contains(&label) {
9249                    return Err(GraphError::RoleWriteDenied {
9250                        reason: format!(
9251                            "role-bound token: label '{}' not in write scope (delete_labels)",
9252                            label
9253                        ),
9254                    });
9255                }
9256            }
9257
9258            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
9259            //
9260            // Derived-edge rejection runs BEFORE the delete_edge_types scope
9261            // check (spec §3.5: "existing derived-edge rejection precedes
9262            // delete_edge_types check").
9263            BatchOp::DeleteEdge {
9264                edge_type,
9265                src_key,
9266                dst_key,
9267            } => {
9268                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
9269                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
9270                    self.ids.get(src_key.as_str()),
9271                    self.ids.get(dst_key.as_str()),
9272                    self.syms.get(edge_type.as_str()),
9273                ) {
9274                    if self.engine.is_owned(et_sym, src_id, dst_id) {
9275                        return Err(GraphError::RuleOwned {
9276                            detail: format!(
9277                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9278                                 delete or change the owning rule"
9279                            ),
9280                        });
9281                    }
9282                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
9283                    let preview = MutPreview::new(self);
9284                    if preview.would_derive(edge_type, src_key, dst_key) {
9285                        return Err(GraphError::RuleOwned {
9286                            detail: format!(
9287                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9288                                 delete or change the owning rule, or a live rule would \
9289                                 re-derive it"
9290                            ),
9291                        });
9292                    }
9293                }
9294                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
9295                if !authz.scope.delete_edge_types.contains(edge_type) {
9296                    return Err(GraphError::RoleWriteDenied {
9297                        reason: format!(
9298                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
9299                            edge_type
9300                        ),
9301                    });
9302                }
9303                // Both endpoints must be visible.
9304                for ep_key in [src_key.as_str(), dst_key.as_str()] {
9305                    match self.ids.get(ep_key) {
9306                        None => {
9307                            return Err(GraphError::RoleWriteDenied {
9308                                reason: "role-bound token: edge endpoint not visible".into(),
9309                            });
9310                        }
9311                        Some(id) if !authz.mask.contains_id(id) => {
9312                            return Err(GraphError::RoleWriteDenied {
9313                                reason: "role-bound token: edge endpoint not visible".into(),
9314                            });
9315                        }
9316                        _ => {}
9317                    }
9318                }
9319            }
9320
9321            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
9322            //
9323            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
9324            BatchOp::InsertEdge {
9325                edge_type,
9326                src_key,
9327                dst_key,
9328            } => {
9329                if !authz.scope.create_edge_types.contains(edge_type) {
9330                    return Err(GraphError::RoleWriteDenied {
9331                        reason: format!(
9332                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9333                            edge_type
9334                        ),
9335                    });
9336                }
9337                // Both endpoints must be visible. A node created by an earlier
9338                // InsertNode in the same batch (tracked in batch_created) counts
9339                // as visible if its label passed the create-class gate.
9340                for ep_key in [src_key.as_str(), dst_key.as_str()] {
9341                    if batch_created.contains_key(ep_key) {
9342                        // Created earlier this batch — already scope-checked.
9343                        continue;
9344                    }
9345                    match self.ids.get(ep_key) {
9346                        None => {
9347                            return Err(GraphError::RoleWriteDenied {
9348                                reason: "role-bound token: edge endpoint not visible".into(),
9349                            });
9350                        }
9351                        Some(id) if !authz.mask.contains_id(id) => {
9352                            return Err(GraphError::RoleWriteDenied {
9353                                reason: "role-bound token: edge endpoint not visible".into(),
9354                            });
9355                        }
9356                        _ => {}
9357                    }
9358                }
9359            }
9360
9361            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
9362            //
9363            // Scope check first; then endpoint visibility using same-batch
9364            // placeholder awareness (spec: "a placeholder endpoint the SAME
9365            // batch creates counts as visible if its label passed the
9366            // create-class gate").
9367            BatchOp::InsertEdgeUpsert {
9368                edge_type,
9369                src_key,
9370                dst_key,
9371                placeholder_label,
9372            } => {
9373                if !authz.scope.create_edge_types.contains(edge_type) {
9374                    return Err(GraphError::RoleWriteDenied {
9375                        reason: format!(
9376                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9377                            edge_type
9378                        ),
9379                    });
9380                }
9381                // Check placeholder label against create_labels (create-class gate).
9382                // This ensures the auto-created endpoints are scope-allowed.
9383                for ep_key in [src_key.as_str(), dst_key.as_str()] {
9384                    if !upsert_ep_visible(ep_key, placeholder_label) {
9385                        return Err(GraphError::RoleWriteDenied {
9386                            reason: "role-bound token: edge endpoint not visible".into(),
9387                        });
9388                    }
9389                }
9390                // A placeholder is created with no props, so it lands in the
9391                // default namespace. A role that cannot read `default` must not
9392                // create one there, for the same reason it may not create a node
9393                // there outright.
9394                //
9395                // The refusal is byte-identical to the hidden-endpoint one above,
9396                // and deliberately so: this arm fires only for an endpoint that
9397                // does **not** exist, and the one above only for an endpoint that
9398                // does. Two different strings would make the pair an existence
9399                // oracle — ask for an upsert and read off whether the key is
9400                // taken. Hidden ≡ absent is the rule everywhere else in this
9401                // table and it holds here too.
9402                if let Some(def) = self.role_def_for(&authz.role) {
9403                    if !def.sees_namespace(NS_DEFAULT) {
9404                        for ep_key in [src_key.as_str(), dst_key.as_str()] {
9405                            if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
9406                            {
9407                                return Err(GraphError::RoleWriteDenied {
9408                                    reason: "role-bound token: edge endpoint not visible".into(),
9409                                });
9410                            }
9411                        }
9412                    }
9413                }
9414            }
9415        }
9416        Ok(())
9417    }
9418
9419    /// Write `roles` to `roles.json` atomically and update the in-memory list.
9420    ///
9421    /// Called by `apply_schema` when roles change. Never called on unchanged
9422    /// re-apply — this preserves byte-identical idempotency.
9423    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
9424        let file = RolesFile::new_versioned(roles.clone());
9425        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
9426            detail: format!("roles serialization: {e}"),
9427        })?;
9428        self.fs
9429            .write_atomic(FileId::Roles, &bytes)
9430            .map_err(GraphError::Io)?;
9431        self.roles = Some(roles);
9432        // Rewriting the sidecar is not a commit, so `commit_seq` does not move
9433        // and a memoised mask would still match its version. Install a fresh
9434        // cache instead of clearing the shared one: a reader snapshot frozen
9435        // against the old definitions keeps the old `Arc` to itself and can
9436        // never publish an answer this handle would read back.
9437        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
9438        // Refresh the MVCC frozen overlay so that reader() immediately sees the
9439        // updated role definitions without waiting for the next K-commit fold.
9440        self.fold_now();
9441        Ok(())
9442    }
9443
9444    fn view(&self) -> GraphView<'_> {
9445        GraphView {
9446            ids: &self.ids,
9447            syms: &self.syms,
9448            labels: &self.labels,
9449            props: self.props_view(),
9450            topo: self.topo_view(),
9451            edge_props: self.edge_props_view(),
9452            mask: None,
9453            prop_index: Some(&self.prop_index),
9454        }
9455    }
9456
9457    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
9458        GraphView {
9459            ids: &self.ids,
9460            syms: &self.syms,
9461            labels: &self.labels,
9462            props: self.props_view(),
9463            topo: self.topo_view(),
9464            edge_props: self.edge_props_view(),
9465            mask: Some(&mask.visible),
9466            prop_index: Some(&self.prop_index),
9467        }
9468    }
9469
9470    /// Execute a read-only Cypher query with a node visibility mask.
9471    ///
9472    /// Only nodes whose key is in `mask` are accessible: label scans, key
9473    /// lookups, and neighbor expansions all respect the mask. Edges where
9474    /// either endpoint is hidden are silently dropped.
9475    ///
9476    /// Returns `Err` with a "masked queries are read-only" message when
9477    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
9478    pub fn query_masked(
9479        &self,
9480        cypher: &str,
9481        params: &std::collections::BTreeMap<String, Value>,
9482        mask: &crate::mask::NodeMask,
9483    ) -> Result<ResultSet> {
9484        // Reject write statements up front.
9485        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9486            detail: format!("lex: {e}"),
9487        })?;
9488        if is_write_tokens(&tokens) {
9489            return Err(GraphError::MaskedReadOnly);
9490        }
9491        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9492            detail: format!("parse: {e}"),
9493        })?;
9494        // Each UNION part executes against the same masked view, so the mask
9495        // applies uniformly across the chain.
9496        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
9497            GraphError::QueryError {
9498                detail: format!("execute: {e}"),
9499            }
9500        })
9501    }
9502
9503    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
9504        let id = self.ids.get(key)?;
9505        Some(NodeRef { db: self, id })
9506    }
9507
9508    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
9509    ///
9510    /// Hidden nodes are never used as traversal intermediaries in either
9511    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
9512    /// only through a hidden node will not appear in results.
9513    ///
9514    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
9515    /// a visited visible node are appended to the result as stub rows
9516    /// (`label` column is `null`, same key+depth columns as visible rows).
9517    /// They are NOT added to the BFS frontier.
9518    ///
9519    /// Returns `None` when `key` does not exist (caller should 404).
9520    ///
9521    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
9522    /// stub rows are never produced on the role path.
9523    pub fn neighborhood_masked(
9524        &self,
9525        key: &str,
9526        depth: u32,
9527        edge_types: Option<&[&str]>,
9528        dir: Dir,
9529        mask: &crate::mask::NodeMask,
9530    ) -> Option<ResultSet> {
9531        let start_id = self.ids.get(key)?;
9532        let view = self.view_masked(mask);
9533        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9534            names
9535                .iter()
9536                .filter_map(|name| view.syms.get(name))
9537                .collect()
9538        });
9539        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
9540        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9541        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
9542        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
9543        visited.push((start_id, 0));
9544        for (nid, d) in &nb.nodes {
9545            let k = view.key_of(*nid);
9546            let label = view
9547                .label_of(*nid)
9548                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9549            rs.push_row(vec![
9550                Some(Value::Str(k.to_string())),
9551                Some(Value::Str(label.to_string())),
9552                Some(Value::Int(*d as i64)),
9553            ]);
9554            visited.push((*nid, *d));
9555        }
9556        // Stub mode: add hidden direct neighbours of each visited node as stubs.
9557        // Hidden nodes are edge-endpoints only — they are not added to the BFS
9558        // frontier, so the BFS never expands through them.
9559        if mask.mode() == crate::mask::MaskMode::Stub {
9560            let raw_view = self.view();
9561            let mut seen: std::collections::HashSet<u32> =
9562                visited.iter().map(|(id, _)| *id).collect();
9563            for (node_id, node_depth) in &visited {
9564                if *node_depth >= depth {
9565                    continue;
9566                }
9567                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
9568                    let nbr = if e.src == *node_id { e.dst } else { e.src };
9569                    if !mask.contains_id(nbr) && seen.insert(nbr) {
9570                        if let Some(k) = self.ids.key_of(nbr) {
9571                            rs.push_row(vec![
9572                                Some(Value::Str(k.to_string())),
9573                                None,
9574                                Some(Value::Int((*node_depth + 1) as i64)),
9575                            ]);
9576                        }
9577                    }
9578                }
9579            }
9580        }
9581        Some(rs)
9582    }
9583
9584    /// [`neighborhood_masked`](Self::neighborhood_masked) with the **subject
9585    /// check** a scoped caller needs: a start key the mask hides answers exactly
9586    /// as an absent one does.
9587    ///
9588    /// `neighborhood_masked` expands from any existing key, hidden or not,
9589    /// because a full-token caller supplying a client mask already knows which
9590    /// keys exist. A scoped caller does not, so telling it apart a hidden key
9591    /// from an absent one would be an existence oracle.
9592    ///
9593    /// Expansion itself is unchanged: hidden nodes are neither returned nor used
9594    /// as traversal intermediaries, so a visible node reachable only through a
9595    /// hidden one stays out of the result.
9596    ///
9597    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
9598    pub fn neighborhood_scoped(
9599        &self,
9600        key: &str,
9601        depth: u32,
9602        edge_types: Option<&[&str]>,
9603        dir: Dir,
9604        mask: &crate::mask::NodeMask,
9605    ) -> Result<ResultSet> {
9606        if !mask.contains_node(self, key) {
9607            return Err(GraphError::KeyNotFound { key: key.into() });
9608        }
9609        self.neighborhood_masked(key, depth, edge_types, dir, mask)
9610            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
9611    }
9612
9613    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
9614    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
9615        let n = self.node_ref(key)?;
9616        Some(NodeInfo {
9617            key: n.key().to_string(),
9618            label: n.label().to_string(),
9619            props: n.props(),
9620        })
9621    }
9622
9623    /// Look up a node with mask awareness.
9624    ///
9625    /// | Key state         | Omit mode       | Stub mode              |
9626    /// |-------------------|-----------------|------------------------|
9627    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
9628    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
9629    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
9630    ///
9631    /// **SECURITY**: only call from client-mask (full-token) paths.
9632    /// Role-token paths must use [`node_info`] after an explicit visibility check.
9633    pub fn node_info_masked(
9634        &self,
9635        key: &str,
9636        mask: &crate::mask::NodeMask,
9637    ) -> Option<MaskedNodeResult> {
9638        let id = self.ids.get(key)?;
9639        if mask.contains_id(id) {
9640            Some(MaskedNodeResult::Visible(self.node_info(key)?))
9641        } else {
9642            match mask.mode() {
9643                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
9644                crate::mask::MaskMode::Omit => None,
9645            }
9646        }
9647    }
9648
9649    /// Get edges for `key` with mask-aware hidden-endpoint handling.
9650    ///
9651    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
9652    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
9653    ///   is `true` for each hidden endpoint.
9654    ///
9655    /// Unknown key → [`GraphError::KeyNotFound`].
9656    ///
9657    /// **SECURITY**: only call from client-mask (full-token) paths.
9658    pub fn node_edges_masked(
9659        &self,
9660        key: &str,
9661        mask: &crate::mask::NodeMask,
9662    ) -> Result<Vec<MaskedEdge>> {
9663        self.ensure_v8_base_sections_loaded();
9664        let id = self
9665            .ids
9666            .get(key)
9667            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9668        let derived: BTreeSet<(u32, u32, u32)> = self
9669            .engine
9670            .provenance_touching(id)
9671            .map(|(_rule, etype, src, dst)| (etype, src, dst))
9672            .collect();
9673        let mut edges = Vec::new();
9674        let tv = self.topo_view();
9675        for etype in tv.etypes() {
9676            // etype comes from the archived CSR (access_unchecked, no eager CRC).
9677            // A bit-flip in the large TOPOLOGY section can produce an etype id
9678            // that is not in the interner.  Return Corrupt rather than panic.
9679            let edge_type = self
9680                .syms
9681                .resolve(etype)
9682                .ok_or_else(|| GraphError::Corrupt {
9683                    detail: format!("v8: topology etype {etype} not in interner"),
9684                })?
9685                .to_string();
9686            for dir in [Direction::Out, Direction::In] {
9687                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
9688                    let nbr_restricted = !mask.contains_id(nbr);
9689                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
9690                        continue;
9691                    }
9692                    let nbr_key = self
9693                        .ids
9694                        .key_of(nbr)
9695                        .ok_or_else(|| GraphError::Corrupt {
9696                            detail: format!("topology id {nbr} has no key"),
9697                        })?
9698                        .to_string();
9699                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
9700                        match dir {
9701                            Direction::Out => {
9702                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
9703                            }
9704                            Direction::In => {
9705                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
9706                            }
9707                        };
9708                    edges.push(MaskedEdge {
9709                        edge_type: edge_type.clone(),
9710                        src_key,
9711                        src_restricted,
9712                        dst_key,
9713                        dst_restricted,
9714                        derived: derived.contains(&(etype, src_id, dst_id)),
9715                    });
9716                }
9717            }
9718        }
9719        edges.sort_by(|a, b| {
9720            a.edge_type
9721                .cmp(&b.edge_type)
9722                .then(a.src_key.cmp(&b.src_key))
9723                .then(a.dst_key.cmp(&b.dst_key))
9724        });
9725        edges.dedup_by(|a, b| {
9726            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
9727        });
9728        Ok(edges)
9729    }
9730
9731    /// [`node_edges_masked`](Self::node_edges_masked) with the **subject check**
9732    /// a scoped caller needs, and a plain [`EdgeInfo`] list.
9733    ///
9734    /// `node_edges_masked` raises [`GraphError::KeyNotFound`] only when `key` is
9735    /// unknown; a key that exists but is hidden still yields its (filtered) edge
9736    /// list, which is correct for a full-token client mask and an existence
9737    /// oracle for a scoped one. Here a hidden subject answers exactly as an
9738    /// absent one does.
9739    ///
9740    /// Every edge naming a hidden endpoint is dropped, whatever the mask's
9741    /// [`MaskMode`](crate::mask::MaskMode): a scoped caller never sees a
9742    /// restricted stub, so there is nothing for it to render.
9743    ///
9744    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
9745    pub fn node_edges_scoped(
9746        &self,
9747        key: &str,
9748        mask: &crate::mask::NodeMask,
9749    ) -> Result<Vec<EdgeInfo>> {
9750        if !mask.contains_node(self, key) {
9751            return Err(GraphError::KeyNotFound { key: key.into() });
9752        }
9753        Ok(self
9754            .node_edges_masked(key, mask)?
9755            .into_iter()
9756            .filter(|e| !e.src_restricted && !e.dst_restricted)
9757            .map(|e| EdgeInfo {
9758                edge_type: e.edge_type,
9759                src_key: e.src_key,
9760                dst_key: e.dst_key,
9761                derived: e.derived,
9762            })
9763            .collect())
9764    }
9765
9766    /// Every directed edge incident on `key`, both directions, every etype.
9767    ///
9768    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
9769    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
9770    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
9771    /// Unknown key → [`GraphError::KeyNotFound`].
9772    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
9773        self.ensure_v8_base_sections_loaded();
9774        let id = self
9775            .ids
9776            .get(key)
9777            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
9778        let derived: BTreeSet<(u32, u32, u32)> = self
9779            .engine
9780            .provenance_touching(id)
9781            .map(|(_rule, etype, src, dst)| (etype, src, dst))
9782            .collect();
9783        let mut edges = Vec::new();
9784        let tv = self.topo_view();
9785        for etype in tv.etypes() {
9786            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
9787            let edge_type = self
9788                .syms
9789                .resolve(etype)
9790                .ok_or_else(|| GraphError::Corrupt {
9791                    detail: format!("v8: topology etype {etype} not in interner"),
9792                })?
9793                .to_string();
9794            for dir in [Direction::Out, Direction::In] {
9795                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
9796                    let (src, dst, src_key, dst_key) = match dir {
9797                        Direction::Out => (
9798                            id,
9799                            nbr,
9800                            key.to_string(),
9801                            self.ids
9802                                .key_of(nbr)
9803                                .ok_or_else(|| GraphError::Corrupt {
9804                                    detail: format!("topology id {nbr} has no key"),
9805                                })?
9806                                .to_string(),
9807                        ),
9808                        Direction::In => (
9809                            nbr,
9810                            id,
9811                            self.ids
9812                                .key_of(nbr)
9813                                .ok_or_else(|| GraphError::Corrupt {
9814                                    detail: format!("topology id {nbr} has no key"),
9815                                })?
9816                                .to_string(),
9817                            key.to_string(),
9818                        ),
9819                    };
9820                    edges.push(EdgeInfo {
9821                        edge_type: edge_type.clone(),
9822                        src_key,
9823                        dst_key,
9824                        derived: derived.contains(&(etype, src, dst)),
9825                    });
9826                }
9827            }
9828        }
9829        edges.sort_by(|a, b| {
9830            a.edge_type
9831                .cmp(&b.edge_type)
9832                .then(a.src_key.cmp(&b.src_key))
9833                .then(a.dst_key.cmp(&b.dst_key))
9834        });
9835        // Self-loops appear in both Out and In; sort makes the pair adjacent
9836        // (sort key matches PartialEq for this case) so one pass drops the dup.
9837        edges.dedup();
9838        Ok(edges)
9839    }
9840
9841    // ── Backup ────────────────────────────────────────────────────────────────
9842
9843    /// Copy this store to `dest` as a consistent, verified snapshot.
9844    ///
9845    /// Copies every durable file in the database directory — `snapshot.bin`,
9846    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
9847    /// `roles.json` — into a freshly created `dest` directory using OS-level
9848    /// `copy` calls (no large in-process buffers).
9849    ///
9850    /// # Consistency guarantee
9851    ///
9852    /// The guarantee is **process-local**: the caller holds `&self`, which
9853    /// prevents any concurrent writer in the **same process** from modifying
9854    /// the files during the copy.  Running `mushroomdb backup` against a
9855    /// directory that is **concurrently being written by another process** (e.g.
9856    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
9857    /// `verified: true` result reduces but does not eliminate the risk of a
9858    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
9859    /// consistent mid-write snapshot).
9860    ///
9861    /// **The safe path for a live-served store is `POST /backup` on the HTTP
9862    /// server.** That handler acquires the read lock on the shared database
9863    /// before calling this method, which is the correct cross-process
9864    /// synchronisation point because the server is the single process writing
9865    /// the files.
9866    ///
9867    /// After copying, opens the destination read-only and runs the CRC section
9868    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
9869    /// `BackupReport::verified` reflects whether both checks passed.
9870    ///
9871    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
9872    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
9873        // Derive source directory from snapshot_path (RealFs only).
9874        let src_dir = match self.fs.snapshot_path() {
9875            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
9876                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
9877            })?,
9878            None => {
9879                return Err(GraphError::Io(std::io::Error::other(
9880                    "backup_to requires a real filesystem (RealFs)",
9881                )))
9882            }
9883        };
9884
9885        std::fs::create_dir_all(dest)?;
9886
9887        let mut files: Vec<String> = Vec::new();
9888        let mut bytes: u64 = 0;
9889
9890        // Helper: copy src_dir/name → dest/name if the file exists.
9891        let mut try_copy = |name: &str| -> std::io::Result<()> {
9892            let src_path = src_dir.join(name);
9893            if src_path.exists() {
9894                let n = std::fs::copy(&src_path, dest.join(name))?;
9895                bytes += n;
9896                files.push(name.to_string());
9897            }
9898            Ok(())
9899        };
9900
9901        try_copy("snapshot.bin")?;
9902        try_copy("snapshot.bin.bak")?;
9903        try_copy("wal.bin")?;
9904        try_copy("wal.floor")?;
9905        try_copy("wal.genesis")?;
9906        try_copy("roles.json")?;
9907        // The map from commit to wall-clock time. Without it the copy cannot
9908        // resolve a date to a commit, and answers `NoRecordedTime` for history
9909        // it holds in full (ledger row 86).
9910        try_copy("commit_times.bin")?;
9911
9912        // Copy WAL archives.
9913        let archives = self.fs.list_archives()?;
9914        for n in &archives {
9915            let name = format!("wal.{n}.archive");
9916            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
9917            bytes += n_bytes;
9918            files.push(name);
9919        }
9920
9921        files.sort();
9922
9923        // Post-copy verification: open dest and run CRC checks.
9924        let snap_in_dest = dest.join("snapshot.bin").exists();
9925        let crc_ok = if snap_in_dest {
9926            crate::verify_snapshot(dest)
9927                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
9928                .unwrap_or(false)
9929        } else {
9930            true // WAL-only store: nothing to CRC-check in snapshot
9931        };
9932        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
9933        let verified = crc_ok && opens_ok;
9934
9935        Ok(BackupReport {
9936            files,
9937            bytes,
9938            verified,
9939        })
9940    }
9941
9942    // ── Export helpers ────────────────────────────────────────────────────────
9943
9944    /// All live nodes, sorted by key (deterministic).
9945    ///
9946    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
9947    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
9948        self.ensure_v8_base_sections_loaded();
9949        let pv = self.props_view();
9950        let mut nodes = Vec::new();
9951        for id in 0..self.ids.len() as u32 {
9952            let Some(key) = self.ids.key_of(id) else {
9953                continue;
9954            };
9955            let Some(&sym) = self.labels.get(id as usize) else {
9956                continue;
9957            };
9958            if sym == u32::MAX {
9959                continue; // tombstoned
9960            }
9961            let Some(label) = self.syms.resolve(sym) else {
9962                continue;
9963            };
9964            let mut props = BTreeMap::new();
9965            for field in pv.field_names() {
9966                if let Some(vr) = pv.get(id, &field) {
9967                    props.insert(field, vr.into_value());
9968                }
9969            }
9970            nodes.push(NodeInfo {
9971                key: key.to_string(),
9972                label: label.to_string(),
9973                props,
9974            });
9975        }
9976        nodes.sort_by(|a, b| a.key.cmp(&b.key));
9977        nodes
9978    }
9979
9980    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
9981    ///
9982    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
9983    /// Manual edges carry `derived: false` and `rule: None`.
9984    /// `weight` is the creating rule's `weight_prop` value read off the edge
9985    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
9986    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
9987    /// store state.
9988    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
9989        self.ensure_v8_base_sections_loaded();
9990
9991        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
9992        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
9993        for (rule_name, triples) in self.engine.provenance() {
9994            for &(etype, src, dst) in triples {
9995                prov.insert((etype, src, dst), rule_name.clone());
9996            }
9997        }
9998
9999        // rule_name → weight_prop, for O(1) lookup per derived edge.
10000        let weight_props: HashMap<&str, Option<&str>> = self
10001            .engine
10002            .rules()
10003            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
10004            .collect();
10005
10006        let tv = self.topo_view();
10007        let ep = self.edge_props_view();
10008        let mut edges = Vec::new();
10009
10010        for id in 0..self.ids.len() as u32 {
10011            let Some(key) = self.ids.key_of(id) else {
10012                continue;
10013            };
10014            let Some(&lsym) = self.labels.get(id as usize) else {
10015                continue;
10016            };
10017            if lsym == u32::MAX {
10018                continue; // tombstoned
10019            }
10020
10021            for etype_sym in tv.etypes() {
10022                // etype from archived CSR (access_unchecked, no eager CRC).
10023                // Skip edges whose etype is not in the interner; this can only
10024                // occur with a corrupt large TOPOLOGY section (bit-flip on an
10025                // etype field in the archived data).  The function returns Vec,
10026                // not Result, so we continue rather than propagate.
10027                let Some(edge_type) = self.syms.resolve(etype_sym) else {
10028                    continue;
10029                };
10030                let edge_type = edge_type.to_string();
10031                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10032                    let Some(dst_key) = self.ids.key_of(nbr) else {
10033                        continue; // skip corrupt entries
10034                    };
10035                    let prov_key = (etype_sym, id, nbr);
10036                    let rule = prov.get(&prov_key).cloned();
10037                    let derived = rule.is_some();
10038                    let weight = rule
10039                        .as_deref()
10040                        .and_then(|rn| weight_props.get(rn).copied().flatten())
10041                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10042                            Some(Value::Float(f)) => Some(f),
10043                            Some(Value::Int(i)) => Some(i as f64),
10044                            _ => None,
10045                        });
10046                    edges.push(ExportEdge {
10047                        edge_type: edge_type.clone(),
10048                        src: key.to_string(),
10049                        dst: dst_key.to_string(),
10050                        derived,
10051                        rule,
10052                        weight,
10053                    });
10054                }
10055            }
10056        }
10057
10058        edges.sort_by(|a, b| {
10059            a.edge_type
10060                .cmp(&b.edge_type)
10061                .then(a.src.cmp(&b.src))
10062                .then(a.dst.cmp(&b.dst))
10063        });
10064        edges
10065    }
10066
10067    /// What each edge type *is*, without building one record per edge.
10068    ///
10069    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
10070    /// question by materialising every edge — three `String`s apiece, a
10071    /// provenance `HashMap` over every derived edge, and a final sort. That is
10072    /// the right shape for an export, and the wrong one for a summary: on a
10073    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
10074    /// produce nine lines. This walks the topology instead, summing neighbour
10075    /// slice lengths and collecting *label symbols* rather than label strings,
10076    /// so the per-edge cost is an integer add and a set insert on a set with
10077    /// as many members as the store has labels.
10078    ///
10079    /// The rule names come off the rule *definitions*, which each declare the
10080    /// `edge_type` they derive, so naming them costs one pass over the rules
10081    /// rather than one provenance lookup per edge. That is also why `rules`
10082    /// is a list: two rules may derive the same type — the association store
10083    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
10084    /// talent→job rule — and naming only one of them would be a half-truth.
10085    /// A type with no rules is one written by hand.
10086    ///
10087    /// `sample` is the first edge of the type in the store's own id order,
10088    /// which is insertion order: deterministic for a given store, and not the
10089    /// same as key order, which cannot be had without resolving a key per
10090    /// edge. Sorted by `edge_type`.
10091    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
10092        self.ensure_v8_base_sections_loaded();
10093
10094        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10095        for r in self.engine.rules() {
10096            rules_by_type
10097                .entry(r.edge_type.as_str())
10098                .or_default()
10099                .insert(r.name.as_str());
10100        }
10101
10102        let tv = self.topo_view();
10103        let node_count = self.ids.len() as u32;
10104        let mut out = Vec::new();
10105        for etype_sym in tv.etypes() {
10106            // An etype the interner cannot resolve means a corrupt TOPOLOGY
10107            // section; skip it rather than name it, as `all_edges_for_export`
10108            // does for the same reason.
10109            let Some(edge_type) = self.syms.resolve(etype_sym) else {
10110                continue;
10111            };
10112            let mut edges: u64 = 0;
10113            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
10114            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
10115            let mut sample: Option<(u32, u32)> = None;
10116            for id in 0..node_count {
10117                let Some(&lsym) = self.labels.get(id as usize) else {
10118                    continue;
10119                };
10120                if lsym == u32::MAX {
10121                    continue; // tombstoned
10122                }
10123                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
10124                let nbrs = nbrs.as_ref();
10125                if nbrs.is_empty() {
10126                    continue;
10127                }
10128                edges += nbrs.len() as u64;
10129                src_syms.insert(lsym);
10130                for &nbr in nbrs {
10131                    if let Some(&dsym) = self.labels.get(nbr as usize) {
10132                        if dsym != u32::MAX {
10133                            dst_syms.insert(dsym);
10134                        }
10135                    }
10136                }
10137                if sample.is_none() {
10138                    sample = Some((id, nbrs[0]));
10139                }
10140            }
10141            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
10142                syms.iter()
10143                    .filter_map(|&s| self.syms.resolve(s))
10144                    .map(ToString::to_string)
10145                    .collect()
10146            };
10147            out.push(EdgeTypeCensus {
10148                edge_type: edge_type.to_string(),
10149                edges,
10150                src_labels: resolve(&src_syms),
10151                dst_labels: resolve(&dst_syms),
10152                rules: rules_by_type
10153                    .get(edge_type)
10154                    .map(|rs| rs.iter().map(ToString::to_string).collect())
10155                    .unwrap_or_default(),
10156                sample: sample.and_then(|(s, d)| {
10157                    Some((
10158                        self.ids.key_of(s)?.to_string(),
10159                        self.ids.key_of(d)?.to_string(),
10160                    ))
10161                }),
10162            });
10163        }
10164        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
10165        out
10166    }
10167
10168    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
10169    /// on each edge when given.
10170    ///
10171    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
10172    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
10173    /// `None` — callers that want a default weight (e.g. `1.0` for missing
10174    /// props) apply it themselves, matching the convention used internally
10175    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
10176    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
10177    ///
10178    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
10179    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
10180    /// empty vec.
10181    pub fn weighted_edges(
10182        &self,
10183        edge_type: &str,
10184        weight_prop: Option<&str>,
10185    ) -> Vec<(String, String, Option<f64>)> {
10186        let Some(etype_sym) = self.syms.get(edge_type) else {
10187            return Vec::new();
10188        };
10189        let tv = self.topo_view();
10190        let ep = self.edge_props_view();
10191        let mut out = Vec::new();
10192        for id in 0..self.ids.len() as u32 {
10193            let Some(key) = self.ids.key_of(id) else {
10194                continue;
10195            };
10196            let Some(&sym) = self.labels.get(id as usize) else {
10197                continue;
10198            };
10199            if sym == u32::MAX {
10200                continue; // tombstoned
10201            }
10202            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10203                let Some(dst_key) = self.ids.key_of(nbr) else {
10204                    continue;
10205                };
10206                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10207                    Some(Value::Float(f)) => Some(f),
10208                    Some(Value::Int(i)) => Some(i as f64),
10209                    _ => None,
10210                });
10211                out.push((key.to_string(), dst_key.to_string(), weight));
10212            }
10213        }
10214        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
10215        out
10216    }
10217
10218    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
10219        self.view()
10220            .nodes_with_label(label)
10221            .into_iter()
10222            .map(|id| NodeRef { db: self, id })
10223            .collect()
10224    }
10225
10226    /// Whether any of the first `limit` live nodes under `label`, in id
10227    /// order, carries `field`.
10228    ///
10229    /// Bounded for a caller that asks on every request: one props view, at
10230    /// most `limit` property reads, no value cloned and no `NodeRef` built,
10231    /// and it stops at the first carrier. The label column itself has no
10232    /// per-label index, so on a label with fewer than `limit` nodes the walk
10233    /// of that column runs to its end — integer compares, no property read.
10234    pub(crate) fn first_nodes_carry_prop(&self, label: &str, field: &str, limit: usize) -> bool {
10235        let Some(sym) = self.syms.get(label) else {
10236            return false;
10237        };
10238        let props = self.props_view();
10239        self.labels
10240            .iter()
10241            .enumerate()
10242            .filter(|&(_, &s)| s == sym)
10243            .take(limit)
10244            .any(|(id, _)| props.get(id as u32, field).is_some())
10245    }
10246
10247    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
10248        let view = self.view();
10249        view.nodes_with_label(label)
10250            .into_iter()
10251            .filter(|&id| {
10252                eval_filter(filter, &|field| {
10253                    view.prop(id, field).map(|vr| vr.into_value())
10254                })
10255            })
10256            .map(|id| NodeRef { db: self, id })
10257            .collect()
10258    }
10259
10260    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
10261    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
10262    /// with `label = None` will use the native ANN path rather than the O(n)
10263    /// brute-force scan.
10264    pub fn has_vector_rule(&self, field: &str) -> bool {
10265        self.engine.hnsw_has_rule(field)
10266    }
10267
10268    /// How many HNSW graphs this handle has built from scratch since it was
10269    /// opened (one per side of an approximate rule).
10270    ///
10271    /// An open that restored every graph from the snapshot reports `0`.
10272    /// Exposed for tests that assert the open path reuses the persisted index
10273    /// rather than rebuilding it; not part of the stable surface.
10274    #[doc(hidden)]
10275    pub fn hnsw_build_count(&self) -> u64 {
10276        self.engine.hnsw_build_count()
10277    }
10278
10279    /// How many rules this handle still holds a lazily-decoded HNSW graph for.
10280    ///
10281    /// Zero before the first ANN query on a clean open, and again once the
10282    /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
10283    /// Exposed for tests that assert the lazy copies are released; not part of
10284    /// the stable surface.
10285    #[doc(hidden)]
10286    pub fn lazy_hnsw_len(&self) -> usize {
10287        self.engine.lazy_hnsw_len()
10288    }
10289
10290    /// Find nodes whose `field` vector is most similar to `q` (cosine
10291    /// similarity), returning up to `k` results with similarity ≥ `min`,
10292    /// sorted descending.
10293    ///
10294    /// When `label` is `None` the search spans all labels (via
10295    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
10296    /// `Some(lbl)` it restricts to nodes with that label.
10297    ///
10298    /// Uses the HNSW index when one is available (fast path); otherwise falls
10299    /// back to an O(n) brute-force scan.
10300    ///
10301    /// **The index supplies candidates, never scores.** Its own distances are
10302    /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
10303    /// every candidate is re-scored from the `f64` property vectors by
10304    /// [`exact_vector_similarity`] before `min`, the ordering and the reported
10305    /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
10306    /// the re-ordering cannot drop a true top-`k` member; see that constant for
10307    /// the rule. The score a caller receives is therefore the same number the
10308    /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
10309    /// finds an exact duplicate.
10310    pub fn find_similar_vector(
10311        &self,
10312        field: &str,
10313        label: Option<&str>,
10314        q: &[f64],
10315        k: usize,
10316        min: f64,
10317    ) -> Vec<(String, f64)> {
10318        self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
10319            .expect("find_similar_vector_filtered is infallible without where_")
10320    }
10321
10322    /// Like [`find_similar_vector`] but restricts results to nodes visible in
10323    /// `mask`. Hidden nodes never appear in results; the mask is applied
10324    /// **before** k-truncation so a caller still receives up to `k` visible
10325    /// hits.
10326    ///
10327    /// # HNSW path (widening beam)
10328    ///
10329    /// When an HNSW index covers the request, the beam starts at an over-fetch
10330    /// of `k × n / |visible|` (plus the rescore margin) when the mask's
10331    /// selectivity is known from the index length, otherwise at `k` plus that
10332    /// margin. If fewer than `k` visible candidates remain after the mask and
10333    /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
10334    /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
10335    /// or a beam that comes back short of its own width, falls through to the
10336    /// exhaustive masked scan rather than returning a short result.
10337    ///
10338    /// Every surviving candidate is re-scored from the `f64` property vectors,
10339    /// exactly as [`find_similar_vector`] does and for the same reason.
10340    ///
10341    /// # Brute-force path
10342    ///
10343    /// When no HNSW index covers the request, or the beam cannot admit `k`
10344    /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
10345    /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
10346    /// results (or all visible nodes if fewer than `k` exist).
10347    pub fn find_similar_vector_masked(
10348        &self,
10349        field: &str,
10350        label: Option<&str>,
10351        q: &[f64],
10352        k: usize,
10353        min: f64,
10354        mask: &crate::mask::NodeMask,
10355    ) -> Vec<(String, f64)> {
10356        self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
10357            .expect("find_similar_vector_filtered is infallible without where_")
10358    }
10359
10360    /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
10361    ///
10362    /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
10363    /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
10364    /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
10365    /// uses HNSW when an index covers the field.
10366    #[allow(clippy::too_many_arguments)]
10367    pub fn find_similar_vector_filtered(
10368        &self,
10369        field: &str,
10370        label: Option<&str>,
10371        q: &[f64],
10372        k: usize,
10373        min: f64,
10374        mask: Option<&crate::mask::NodeMask>,
10375        where_: Option<&PropPredicate>,
10376        exact: bool,
10377    ) -> Result<Vec<(String, f64)>> {
10378        self.find_similar_vector_as(
10379            field,
10380            label,
10381            q,
10382            k,
10383            min,
10384            mask,
10385            where_,
10386            exact,
10387            ExactnessCaller::Vector,
10388        )
10389    }
10390
10391    /// [`find_similar_vector_filtered`](Self::find_similar_vector_filtered)
10392    /// with the caller shape named, so the exactness warning can advise the
10393    /// signature that actually reached it. Everything else is identical.
10394    #[allow(clippy::too_many_arguments)]
10395    fn find_similar_vector_as(
10396        &self,
10397        field: &str,
10398        label: Option<&str>,
10399        q: &[f64],
10400        k: usize,
10401        min: f64,
10402        mask: Option<&crate::mask::NodeMask>,
10403        where_: Option<&PropPredicate>,
10404        exact: bool,
10405        caller: ExactnessCaller,
10406    ) -> Result<Vec<(String, f64)>> {
10407        if let Some(pred) = where_ {
10408            pred.validate_named("where")
10409                .map_err(|detail| GraphError::QueryError { detail })?;
10410        }
10411
10412        // Ensure any HNSW blobs retained from the snapshot are deserialized
10413        // before the first ANN query on a clean-open (no-WAL) path.  The
10414        // section read has to come first: on a clean open nothing else has
10415        // called it, so without it `retained_hnsw_blobs` is empty,
10416        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
10417        // approximate query on the handle runs brute force — correct results,
10418        // silently off the index.  Both calls are idempotent and cheap once hot.
10419        self.ensure_v8_base_sections_loaded();
10420        self.engine.ensure_hnsw_loaded();
10421        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
10422        if norm == 0.0 {
10423            return Ok(vec![]);
10424        }
10425        if let Some(m) = mask {
10426            if k == 0 || m.is_empty() {
10427                return Ok(vec![]);
10428            }
10429        }
10430        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
10431
10432        // `where` implies exact: a predicate must not ride a silent ANN.
10433        let skip_hnsw = exact || where_.is_some();
10434        if !skip_hnsw {
10435            if let Some(mask) = mask {
10436                if let Some(out) =
10437                    self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask, caller)
10438                {
10439                    return Ok(out);
10440                }
10441            } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
10442                return Ok(out);
10443            }
10444        }
10445
10446        let view = match mask {
10447            Some(m) => self.view_masked(m),
10448            None => self.view(),
10449        };
10450        let candidate_ids = Self::vector_candidates(&view, label, where_);
10451        Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
10452    }
10453
10454    /// Unmasked HNSW path. `None` when no populated index covers the request.
10455    fn find_similar_hnsw(
10456        &self,
10457        field: &str,
10458        label: Option<&str>,
10459        q_unit: &[f64],
10460        k: usize,
10461        min: f64,
10462    ) -> Option<Vec<(String, f64)>> {
10463        // Try HNSW fast path.
10464        // `None` label searches across all VectorSimilar rules covering `field`
10465        // (merging their results); `Some(lbl)` restricts to rules whose
10466        // dst_label matches.  Returns `None` when no populated HNSW index
10467        // covers the request — the O(n) brute-force fallback handles that case.
10468        let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
10469        let hits = match label {
10470            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
10471            None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
10472        };
10473        // Candidates only: the index's `f32` similarity is discarded and
10474        // each hit is re-scored against the `f64` vectors.
10475        let view = self.view();
10476        let mut out: Vec<(String, f64)> = hits
10477            .into_iter()
10478            .filter_map(|(id, _)| {
10479                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
10480                if sim < min {
10481                    return None;
10482                }
10483                Some((self.ids.key_of(id)?.to_string(), sim))
10484            })
10485            .collect();
10486        out.sort_by(|a, b| {
10487            b.1.partial_cmp(&a.1)
10488                .unwrap_or(std::cmp::Ordering::Equal)
10489                .then_with(|| a.0.cmp(&b.0))
10490        });
10491        out.truncate(k);
10492        Some(out)
10493    }
10494
10495    /// Masked HNSW widening beam. `None` when no index covers the request or
10496    /// the beam cannot admit `k` visible hits (caller falls through to brute).
10497    #[allow(clippy::too_many_arguments)]
10498    fn find_similar_hnsw_masked(
10499        &self,
10500        field: &str,
10501        label: Option<&str>,
10502        q_unit: &[f64],
10503        k: usize,
10504        min: f64,
10505        mask: &crate::mask::NodeMask,
10506        caller: ExactnessCaller,
10507    ) -> Option<Vec<(String, f64)>> {
10508        let index_len = match label {
10509            Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
10510            None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
10511        };
10512        let n = index_len?;
10513        // The `?` above is the coverage test: past it, an index exists and this
10514        // masked, non-exact call is about to ride it.
10515        self.note_ambiguous_exactness(field, label, caller);
10516        // Same ceiling the exact-rule widening loop in `hnsw_candidates`
10517        // consults — including the `with_ef_max` test hook.
10518        let cap = ef_max();
10519        let visible = mask.len();
10520        let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
10521        if visible > 0 && n > 0 {
10522            let over = k
10523                .saturating_mul(n)
10524                .div_ceil(visible)
10525                .saturating_add(VECTOR_RESCORE_MARGIN);
10526            ef = ef.max(over);
10527        }
10528        loop {
10529            let hits = match label {
10530                Some(lbl) => self
10531                    .engine
10532                    .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
10533                None => self
10534                    .engine
10535                    .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
10536            };
10537            let hits = hits?;
10538            let full = hits.len() == ef;
10539            let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
10540            if out.len() >= k {
10541                out.truncate(k);
10542                return Some(out);
10543            }
10544            // Short of its width (frontier exhausted) or at the ceiling:
10545            // a wider beam reaches nothing new, so the scan answers.
10546            if !full || ef >= cap {
10547                return None;
10548            }
10549            ef = ef.saturating_mul(2);
10550        }
10551    }
10552
10553    /// Say once, per `(field, label)` index and caller shape, that a masked
10554    /// search is answering approximately.
10555    ///
10556    /// A mask narrows *which nodes may be returned*. It does not choose a
10557    /// kernel — `exact=true` and a `where=` predicate do, and nothing else
10558    /// does. A caller who needed exact answers, passed `mask=` alone, and read
10559    /// the mask as a promise of exhaustiveness gets a correct-looking
10560    /// approximate answer and no signal at all; that is a silent wrong answer,
10561    /// and it has cost an integration team real time.
10562    ///
10563    /// The fix is a question, not a behaviour change. Making a mask imply
10564    /// `exact` would turn every existing masked caller's ANN into an O(n) GEMM
10565    /// without asking them, which is a worse trade than the ambiguity.
10566    ///
10567    /// Printed once per index for the reason the dimension-mismatch skip in
10568    /// `core_rules::hnsw` is: a line on every call is a line callers learn to
10569    /// scroll past.
10570    ///
10571    /// `caller` decides the advice. The same leg is reached from two signatures
10572    /// and only one of them has an `exact` argument to pass; see
10573    /// [`ExactnessCaller`].
10574    fn note_ambiguous_exactness(&self, field: &str, label: Option<&str>, caller: ExactnessCaller) {
10575        let entry = (field.to_string(), label.unwrap_or("").to_string(), caller);
10576        let first = match self.warned_ambiguous_exactness.lock() {
10577            Ok(mut seen) => seen.insert(entry),
10578            Err(poisoned) => poisoned.into_inner().insert(entry),
10579        };
10580        if !first {
10581            return;
10582        }
10583        AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.set(c.get().saturating_add(1)));
10584        let which = match label {
10585            Some(lbl) => format!(" (label `{lbl}`)"),
10586            None => String::new(),
10587        };
10588        let subject = caller.subject();
10589        let advice = caller.advice();
10590        let line = format!(
10591            "mushroomdb: {subject} on field `{field}`{which} is answering \
10592             approximately. A mask narrows which nodes may be returned; it does not \
10593             change which kernel runs, and an index covers this field. For an exact \
10594             answer over the same visible candidate set, {advice} Further masked \
10595             searches of this shape on this index are silent."
10596        );
10597        AMBIGUOUS_EXACTNESS_LAST.with(|c| *c.borrow_mut() = Some(line.clone()));
10598        eprintln!("{line}");
10599    }
10600
10601    /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
10602    /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
10603    fn vector_candidates(
10604        view: &GraphView<'_>,
10605        label: Option<&str>,
10606        where_: Option<&PropPredicate>,
10607    ) -> Vec<u32> {
10608        if let (Some(lbl), Some(pred)) = (label, where_) {
10609            let indexed = view
10610                .prop_index
10611                .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
10612            if indexed {
10613                match (&pred.eq, &pred.in_) {
10614                    (Some(eq), None) => {
10615                        if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
10616                            return ids;
10617                        }
10618                    }
10619                    (None, Some(allowed)) => {
10620                        let mut seen = HashSet::new();
10621                        let mut out = Vec::new();
10622                        for v in allowed {
10623                            if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
10624                                for id in ids {
10625                                    if seen.insert(id) {
10626                                        out.push(id);
10627                                    }
10628                                }
10629                            }
10630                        }
10631                        return out;
10632                    }
10633                    _ => {}
10634                }
10635            }
10636        }
10637
10638        let mut ids: Vec<u32> = match label {
10639            Some(lbl) => view
10640                .nodes_with_label(lbl)
10641                .into_iter()
10642                .filter(|&id| view.visible(id))
10643                .collect(),
10644            None => view.nodes_all(),
10645        };
10646        if let Some(pred) = where_ {
10647            ids.retain(|&id| match view.prop(id, &pred.field) {
10648                None => pred.holds(None),
10649                Some(vr) => pred.holds(Some(vr.as_value())),
10650            });
10651        }
10652        ids
10653    }
10654
10655    /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
10656    /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
10657    fn brute_vector_hits(
10658        &self,
10659        view: &GraphView<'_>,
10660        candidate_ids: impl IntoIterator<Item = u32>,
10661        field: &str,
10662        q_unit: &[f64],
10663        k: usize,
10664        min: f64,
10665    ) -> Vec<(String, f64)> {
10666        let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
10667            .into_iter()
10668            .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
10669            .collect();
10670        let packed =
10671            crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
10672        let scores = crate::exact_knn::gemv(&packed, q_unit);
10673        let mut scored: Vec<(String, f64)> = packed
10674            .ids
10675            .iter()
10676            .zip(scores.iter())
10677            .filter_map(|(&id, &sim)| {
10678                if sim < min {
10679                    return None;
10680                }
10681                let key = self.ids.key_of(id)?.to_string();
10682                Some((key, sim))
10683            })
10684            .collect();
10685        scored.sort_by(|a, b| {
10686            b.1.partial_cmp(&a.1)
10687                .unwrap_or(std::cmp::Ordering::Equal)
10688                .then_with(|| a.0.cmp(&b.0))
10689        });
10690        scored.truncate(k);
10691        scored
10692    }
10693
10694    /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
10695    ///
10696    /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
10697    /// same unit and inequality as `find_similar_vector`. Self-matches are
10698    /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
10699    /// embeddings are omitted as both query and candidate. Duplicate keys are
10700    /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
10701    /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
10702    #[allow(clippy::type_complexity)]
10703    pub fn pairwise_similar(
10704        &self,
10705        keys: &[&str],
10706        field: &str,
10707        k: usize,
10708        min: f64,
10709    ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
10710        let mut seen = HashSet::new();
10711        let mut unique_ids = Vec::new();
10712        for key in keys {
10713            let Some(id) = self.ids.get(key) else {
10714                continue;
10715            };
10716            if seen.insert(id) {
10717                unique_ids.push(id);
10718            }
10719        }
10720        let max_n = crate::exact_knn::pairwise_max_n();
10721        if unique_ids.len() > max_n {
10722            return Err(GraphError::QueryError {
10723                detail: format!(
10724                    "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
10725                    unique_ids.len()
10726                ),
10727            });
10728        }
10729        if unique_ids.is_empty() {
10730            return Ok(Vec::new());
10731        }
10732
10733        let view = self.view();
10734        let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
10735        let mut counts: HashMap<usize, usize> = HashMap::new();
10736        for id in unique_ids {
10737            let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
10738                continue;
10739            };
10740            let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
10741            if norm == 0.0 {
10742                continue;
10743            }
10744            *counts.entry(v.len()).or_default() += 1;
10745            rows.push((id, v));
10746        }
10747        if rows.is_empty() {
10748            return Ok(Vec::new());
10749        }
10750        let dim = counts
10751            .into_iter()
10752            .max_by_key(|&(d, c)| (c, d))
10753            .map(|(d, _)| d)
10754            .expect("rows non-empty");
10755        let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
10756        let n = packed.ids.len();
10757        if n == 0 {
10758            return Ok(Vec::new());
10759        }
10760        let src_keys: Vec<String> = packed
10761            .ids
10762            .iter()
10763            .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
10764            .collect();
10765
10766        let mut out = Vec::with_capacity(n);
10767        if n <= crate::exact_knn::pairwise_gram_max() {
10768            let sims = crate::exact_knn::gram(&packed);
10769            for i in 0..n {
10770                out.push(Self::topk_from_row(
10771                    &src_keys,
10772                    i,
10773                    &sims[i * n..(i + 1) * n],
10774                    k,
10775                    min,
10776                ));
10777            }
10778        } else {
10779            for i in 0..n {
10780                let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
10781                let scores = crate::exact_knn::gemv(&packed, row);
10782                out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
10783            }
10784        }
10785        Ok(out)
10786    }
10787
10788    /// [`pairwise_similar`](Self::pairwise_similar) over the keys the mask
10789    /// admits — intersected **before** the matmul, never filtered after it.
10790    ///
10791    /// A hidden vector packed into the Gram is a row every visible key is
10792    /// scored against. It can take a visible neighbour's place in the top-`k`,
10793    /// and because the packed dimension is a majority vote over the candidate
10794    /// rows it can decide whether a visible pair is scored at all. Dropping
10795    /// hidden names from the finished answer leaves both effects standing, so
10796    /// the intersection happens first and the answer is byte-for-byte the one
10797    /// `pairwise_similar` gives for the visible keys alone.
10798    ///
10799    /// The caps therefore measure the **post-filter** count: a key set over
10800    /// [`PAIRWISE_MAX_N`](crate::PAIRWISE_MAX_N) unscoped can come under it
10801    /// scoped and succeed, because the work the cap refuses is work this call
10802    /// no longer does. A filtered count still over the cap is still refused.
10803    #[allow(clippy::type_complexity)]
10804    pub fn pairwise_similar_scoped(
10805        &self,
10806        keys: &[&str],
10807        field: &str,
10808        k: usize,
10809        min: f64,
10810        mask: &crate::mask::NodeMask,
10811    ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
10812        let visible: Vec<&str> = keys
10813            .iter()
10814            .copied()
10815            .filter(|key| mask.contains_node(self, key))
10816            .collect();
10817        self.pairwise_similar(&visible, field, k, min)
10818    }
10819
10820    /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
10821    /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
10822    /// still appear as `(src, [])`.
10823    fn topk_from_row(
10824        src_keys: &[String],
10825        i: usize,
10826        scores: &[f64],
10827        k: usize,
10828        min: f64,
10829    ) -> (String, Vec<(String, f64)>) {
10830        let mut neigh: Vec<(String, f64)> = scores
10831            .iter()
10832            .enumerate()
10833            .filter_map(|(j, &sim)| {
10834                if i == j || sim < min {
10835                    return None;
10836                }
10837                Some((src_keys[j].clone(), sim))
10838            })
10839            .collect();
10840        neigh.sort_by(|a, b| {
10841            b.1.partial_cmp(&a.1)
10842                .unwrap_or(std::cmp::Ordering::Equal)
10843                .then_with(|| a.0.cmp(&b.0))
10844        });
10845        neigh.truncate(k);
10846        (src_keys[i].clone(), neigh)
10847    }
10848
10849    /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
10850    /// hits, order by score then key. The index's own `f32` similarity is discarded.
10851    fn score_masked_hnsw_hits(
10852        &self,
10853        hits: &[(u32, f64)],
10854        field: &str,
10855        q_unit: &[f64],
10856        min: f64,
10857        mask: &crate::mask::NodeMask,
10858    ) -> Vec<(String, f64)> {
10859        let view = self.view_masked(mask);
10860        let mut out: Vec<(String, f64)> = hits
10861            .iter()
10862            .copied()
10863            .filter(|&(id, _)| mask.contains_id(id))
10864            .filter_map(|(id, _)| {
10865                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
10866                if sim < min {
10867                    return None;
10868                }
10869                Some((self.ids.key_of(id)?.to_string(), sim))
10870            })
10871            .collect();
10872        out.sort_by(|a, b| {
10873            b.1.partial_cmp(&a.1)
10874                .unwrap_or(std::cmp::Ordering::Equal)
10875                .then_with(|| a.0.cmp(&b.0))
10876        });
10877        out
10878    }
10879
10880    /// Read a single property from an edge.
10881    ///
10882    /// Returns `None` when the edge does not exist, the field is absent, or any
10883    /// of the string keys cannot be resolved to interned ids.  Only edge props
10884    /// written by rules (weight fields) are accessible without a `set_edge_prop`
10885    /// binding; topology-only edges (no props set) return `None` for every field.
10886    pub fn get_edge_prop(
10887        &self,
10888        edge_type: &str,
10889        src_key: &str,
10890        dst_key: &str,
10891        field: &str,
10892    ) -> Option<Value> {
10893        let etype = self.syms.get(edge_type)?;
10894        let src = self.ids.get(src_key)?;
10895        let dst = self.ids.get(dst_key)?;
10896        self.edge_props_view().get(etype, src, dst, field)
10897    }
10898
10899    /// Lex → parse → plan → execute `cypher` over a read-only view.
10900    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
10901    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
10902    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
10903        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
10904            detail: format!("lex: {e}"),
10905        })?;
10906        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
10907            detail: format!("parse: {e}"),
10908        })?;
10909        let t0 = std::time::Instant::now();
10910        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
10911            GraphError::QueryError {
10912                detail: format!("execute: {e}"),
10913            }
10914        });
10915        let elapsed_ms = t0.elapsed().as_millis() as u64;
10916        let threshold = self.slow_query_threshold_ms;
10917        if threshold > 0 && elapsed_ms >= threshold {
10918            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
10919            let entry = SlowQueryEntry {
10920                ms: elapsed_ms,
10921                query: cypher.to_string(),
10922                at_commit: self.commit_seq,
10923            };
10924            if let Ok(mut log) = self.slow_queries.lock() {
10925                if log.entries.len() == SLOW_QUERY_RING_CAP {
10926                    log.entries.pop_front();
10927                }
10928                log.entries.push_back(entry);
10929                log.total += 1;
10930            }
10931        }
10932        result
10933    }
10934
10935    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
10936    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
10937    /// calling [`GraphDb::query`].
10938    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
10939        let map: BTreeMap<String, Value> = params
10940            .iter()
10941            .map(|(k, v)| (k.to_string(), v.clone()))
10942            .collect();
10943        self.query(cypher, &map)
10944    }
10945
10946    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
10947    ///
10948    /// All mutations flow through the same `insert_node` / `set_prop` /
10949    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
10950    /// fires and the WAL captures everything with one fsync per statement.
10951    ///
10952    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
10953    /// and `deleted` matching the write-result contract.
10954    ///
10955    /// **Mutation routing**: mutations are collected into a single
10956    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
10957    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
10958    /// over `self.view()` — the borrow is dropped before the batch is opened.
10959    ///
10960    /// **Limitations (v1)**:
10961    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
10962    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
10963    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
10964    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
10965    /// - Deleting a derived edge → named error "cannot delete derived edge".
10966    pub fn query_write(
10967        &mut self,
10968        cypher: &str,
10969        params: &BTreeMap<String, Value>,
10970    ) -> Result<ResultSet> {
10971        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
10972            detail: format!("lex: {e}"),
10973        })?;
10974        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
10975            detail: format!("parse: {e}"),
10976        })?;
10977        self.exec_write_stmt(stmt, params)
10978    }
10979
10980    fn exec_write_stmt(
10981        &mut self,
10982        stmt: WriteStatement,
10983        params: &BTreeMap<String, Value>,
10984    ) -> Result<ResultSet> {
10985        match stmt {
10986            WriteStatement::Create(s) => self.exec_create(s, params),
10987            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
10988            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
10989            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
10990            WriteStatement::Merge(s) => self.exec_merge(s, params),
10991        }
10992    }
10993
10994    fn exec_create(
10995        &mut self,
10996        stmt: core_query::cypher::CreateStmt,
10997        params: &BTreeMap<String, Value>,
10998    ) -> Result<ResultSet> {
10999        // Extract the node key from props: require a string-valued `id` field.
11000        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
11001        for node in &stmt.nodes {
11002            let var = node.var.as_deref().unwrap_or("_cn0");
11003            let key = node
11004                .props
11005                .iter()
11006                .find(|(f, _)| f == "id")
11007                .and_then(|(_, v)| {
11008                    if let Value::Str(s) = v {
11009                        Some(s.clone())
11010                    } else {
11011                        None
11012                    }
11013                })
11014                .ok_or_else(|| GraphError::QueryError {
11015                    detail: format!(
11016                        "CREATE node ({}:{}) requires a string 'id' property",
11017                        var, node.label
11018                    ),
11019                })?;
11020            var_to_key.insert(var.to_string(), key);
11021        }
11022
11023        let mut batch = self.batch();
11024        let mut created: usize = 0;
11025        for node in &stmt.nodes {
11026            let var = node.var.as_deref().unwrap_or("_cn0");
11027            let key = &var_to_key[var];
11028            batch.insert_node(&node.label, key, node.props.clone());
11029            created += 1;
11030        }
11031        for edge in &stmt.edges {
11032            let src_key = var_to_key
11033                .get(&edge.src_var)
11034                .ok_or_else(|| GraphError::QueryError {
11035                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
11036                })?;
11037            let dst_key = var_to_key
11038                .get(&edge.dst_var)
11039                .ok_or_else(|| GraphError::QueryError {
11040                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
11041                })?;
11042            batch.insert_edge(&edge.etype, src_key, dst_key);
11043        }
11044        batch.commit()?;
11045
11046        // Optional RETURN clause: project created bindings as a read result.
11047        if let Some(returns) = stmt.returns {
11048            // Each created node is looked up by its key via a separate MATCH pattern.
11049            // Multiple single-node patterns cross-join to produce 1 output row with
11050            // all variables bound (each pattern returns exactly 1 row).
11051            let patterns: Vec<Pattern> = stmt
11052                .nodes
11053                .iter()
11054                .map(|node| {
11055                    let var = node.var.as_deref().unwrap_or("_cn0");
11056                    let key = var_to_key[var].clone();
11057                    Pattern {
11058                        start: NodePat {
11059                            var: Some(var.to_string()),
11060                            label: Some(node.label.clone()),
11061                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
11062                        },
11063                        chain: vec![],
11064                        shortest: false,
11065                    }
11066                })
11067                .collect();
11068            let q = Query {
11069                matches: patterns,
11070                optional_clauses: vec![],
11071                where_expr: None,
11072                unwinds: vec![],
11073                post_unwind_where: None,
11074                stages: vec![],
11075                returns,
11076                distinct: false,
11077                order_by: vec![],
11078                skip: None,
11079                limit: None,
11080            };
11081            let ops = plan(&q).map_err(|e| GraphError::QueryError {
11082                detail: format!("plan: {e}"),
11083            })?;
11084            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
11085                GraphError::QueryError {
11086                    detail: format!("execute: {e}"),
11087                }
11088            });
11089        }
11090
11091        let mut rs = write_result_set();
11092        rs.push_row(vec![
11093            Some(Value::Int(created as i64)),
11094            Some(Value::Int(0)),
11095            Some(Value::Int(0)),
11096        ]);
11097        Ok(rs)
11098    }
11099
11100    fn exec_match_set(
11101        &mut self,
11102        stmt: core_query::cypher::MatchSetStmt,
11103        params: &BTreeMap<String, Value>,
11104    ) -> Result<ResultSet> {
11105        let project_returns = stmt.returns.clone();
11106        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
11107        // so the post-write projection can look them up by key.
11108        let mut set_vars: Vec<String> = Vec::new();
11109        for s in &stmt.sets {
11110            if !set_vars.contains(&s.var) {
11111                set_vars.push(s.var.clone());
11112            }
11113        }
11114        let rel_vars = pattern_rel_vars(&stmt.matches);
11115        // `count` is the engine's, on an edge: it is the insert-count §5.13
11116        // maintains, and a `SET` that overwrote it would make the number mean
11117        // whatever the last writer said rather than how many times the pair was
11118        // inserted. Refused by name here, before the match runs, so the caller
11119        // is told what is actually wrong instead of meeting the executor's
11120        // generic "did not resolve to a node key" — and so the answer does not
11121        // depend on whether the pattern happened to match a row. The same name
11122        // on a *node* is an ordinary property and is untouched.
11123        for s in &stmt.sets {
11124            if s.field == EDGE_COUNT_PROP && rel_vars.iter().any(|r| r == &s.var) {
11125                return Err(GraphError::QueryError {
11126                    detail: format!(
11127                        "cannot SET {}.{EDGE_COUNT_PROP}: `{EDGE_COUNT_PROP}` is a reserved edge \
11128                         property holding the pair's insert count",
11129                        s.var
11130                    ),
11131                });
11132            }
11133        }
11134        let mut lookup_vars = set_vars.clone();
11135        for v in pattern_node_vars(&stmt.matches) {
11136            add_var(&mut lookup_vars, &v);
11137        }
11138        if let Some(ref returns) = project_returns {
11139            for v in ret_node_vars(returns) {
11140                if !rel_vars.iter().any(|r| r == &v) {
11141                    add_var(&mut lookup_vars, &v);
11142                }
11143            }
11144        }
11145
11146        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
11147        // SET values are projected as ScalarExpr items so that arithmetic expressions
11148        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
11149        let mut set_returns: Vec<RetItem> = lookup_vars
11150            .iter()
11151            .map(|v| RetItem {
11152                value: RetVal::Var(v.clone()),
11153                alias: None,
11154            })
11155            .collect();
11156        // One computed column per SET clause; alias is `__sv_<i>`.
11157        let set_val_cols: Vec<String> = stmt
11158            .sets
11159            .iter()
11160            .enumerate()
11161            .map(|(i, _)| format!("__sv_{i}"))
11162            .collect();
11163        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11164            set_returns.push(RetItem {
11165                value: RetVal::ScalarExpr(sc.value.clone()),
11166                alias: Some(col.clone()),
11167            });
11168        }
11169        // Capture relationship types while r is bound; SET does not change them.
11170        for r in &rel_vars {
11171            set_returns.push(RetItem {
11172                value: RetVal::FuncCall {
11173                    name: "type".into(),
11174                    args: vec![Operand::Var(r.clone())],
11175                },
11176                alias: Some(rel_type_alias(r)),
11177            });
11178        }
11179
11180        let read_q = Query {
11181            matches: stmt.matches.clone(),
11182            optional_clauses: vec![],
11183            where_expr: stmt.where_expr.clone(),
11184            unwinds: vec![],
11185            post_unwind_where: None,
11186            stages: vec![],
11187            returns: set_returns,
11188            distinct: false,
11189            order_by: vec![],
11190            skip: None,
11191            limit: None,
11192        };
11193        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11194            detail: format!("plan: {e}"),
11195        })?;
11196        // MATCH phase is read-only; borrow ends before batch opens.
11197        //
11198        // When a role-scoped write is in flight, run the MATCH read through
11199        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
11200        // zero-rows (no SetProp ops generated, no existence-oracle 403).
11201        // Full-authority writes (pending_write_authz=None) keep view().
11202        let match_rs = {
11203            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11204            if let Some(ref mask) = mask_opt {
11205                execute(&self.view_masked(mask), &ops, &Params(params))
11206            } else {
11207                execute(&self.view(), &ops, &Params(params))
11208            }
11209        }
11210        .map_err(|e| GraphError::QueryError {
11211            detail: format!("execute: {e}"),
11212        })?;
11213
11214        // Collect (key, field, value) for each matched row × each SET clause.
11215        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
11216        for row_i in 0..match_rs.len() {
11217            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11218                let key = match match_rs.get(row_i, &sc.var) {
11219                    Some(Value::Str(k)) => k.clone(),
11220                    _ => {
11221                        return Err(GraphError::QueryError {
11222                            detail: format!(
11223                                "SET variable '{}' did not resolve to a node key",
11224                                sc.var
11225                            ),
11226                        })
11227                    }
11228                };
11229                // The SET value was already evaluated by the executor.
11230                let value = match match_rs.get(row_i, col) {
11231                    Some(v) => v.clone(),
11232                    None => {
11233                        return Err(GraphError::QueryError {
11234                            detail: format!(
11235                                "SET value for {}.{} evaluated to null",
11236                                sc.var, sc.field
11237                            ),
11238                        })
11239                    }
11240                };
11241                set_ops.push((key, sc.field.clone(), value));
11242            }
11243        }
11244
11245        // Apply as one atomic batch.
11246        let props_set = set_ops.len();
11247        let mut batch = self.batch();
11248        for (key, field, value) in set_ops {
11249            batch.set_prop(&key, &field, value);
11250        }
11251        batch.commit()?;
11252
11253        if let Some(returns) = project_returns {
11254            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
11255        }
11256
11257        let mut rs = write_result_set();
11258        rs.push_row(vec![
11259            Some(Value::Int(0)),
11260            Some(Value::Int(props_set as i64)),
11261            Some(Value::Int(0)),
11262        ]);
11263        Ok(rs)
11264    }
11265
11266    fn exec_match_delete(
11267        &mut self,
11268        stmt: core_query::cypher::MatchDeleteStmt,
11269        params: &BTreeMap<String, Value>,
11270    ) -> Result<ResultSet> {
11271        // Collect unique node vars needed to identify edge endpoints.
11272        let mut node_vars: Vec<String> = Vec::new();
11273        for ed in &stmt.deletes {
11274            if !node_vars.contains(&ed.src_var) {
11275                node_vars.push(ed.src_var.clone());
11276            }
11277            if !node_vars.contains(&ed.dst_var) {
11278                node_vars.push(ed.dst_var.clone());
11279            }
11280        }
11281
11282        // Synthesize read query.
11283        let returns: Vec<RetItem> = node_vars
11284            .iter()
11285            .map(|v| RetItem {
11286                value: RetVal::Var(v.clone()),
11287                alias: None,
11288            })
11289            .collect();
11290        let read_q = Query {
11291            matches: stmt.matches,
11292            optional_clauses: vec![],
11293            where_expr: stmt.where_expr,
11294            unwinds: vec![],
11295            post_unwind_where: None,
11296            stages: vec![],
11297            returns,
11298            distinct: false,
11299            order_by: vec![],
11300            skip: None,
11301            limit: None,
11302        };
11303        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11304            detail: format!("plan: {e}"),
11305        })?;
11306        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11307        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11308        let match_rs = {
11309            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11310            if let Some(ref mask) = mask_opt {
11311                execute(&self.view_masked(mask), &ops, &Params(params))
11312            } else {
11313                execute(&self.view(), &ops, &Params(params))
11314            }
11315        }
11316        .map_err(|e| GraphError::QueryError {
11317            detail: format!("execute: {e}"),
11318        })?;
11319
11320        // Collect (etype, src_key, dst_key) for each row × each delete target.
11321        let mut del_ops: Vec<(String, String, String)> = Vec::new();
11322        for row_i in 0..match_rs.len() {
11323            for ed in &stmt.deletes {
11324                let src_key = match match_rs.get(row_i, &ed.src_var) {
11325                    Some(Value::Str(k)) => k.clone(),
11326                    _ => {
11327                        return Err(GraphError::QueryError {
11328                            detail: format!(
11329                                "DELETE src variable '{}' did not resolve to a node key",
11330                                ed.src_var
11331                            ),
11332                        })
11333                    }
11334                };
11335                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
11336                    Some(Value::Str(k)) => k.clone(),
11337                    _ => {
11338                        return Err(GraphError::QueryError {
11339                            detail: format!(
11340                                "DELETE dst variable '{}' did not resolve to a node key",
11341                                ed.dst_var
11342                            ),
11343                        })
11344                    }
11345                };
11346                del_ops.push((ed.etype.clone(), src_key, dst_key));
11347            }
11348        }
11349
11350        // Apply as one atomic batch.
11351        let deleted = del_ops.len();
11352        let mut batch = self.batch();
11353        for (etype, src_key, dst_key) in del_ops {
11354            batch.delete_edge(&etype, &src_key, &dst_key);
11355        }
11356        batch.commit().map_err(|e| match e {
11357            GraphError::RuleOwned { .. } => GraphError::QueryError {
11358                detail: "cannot delete derived edge; retract via the rule or change the property"
11359                    .to_string(),
11360            },
11361            other => other,
11362        })?;
11363
11364        let mut rs = write_result_set();
11365        rs.push_row(vec![
11366            Some(Value::Int(0)),
11367            Some(Value::Int(0)),
11368            Some(Value::Int(deleted as i64)),
11369        ]);
11370        Ok(rs)
11371    }
11372
11373    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
11374    ///
11375    /// Collects the matching node keys via an ephemeral read query, then calls
11376    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
11377    /// the executor first checks that the node has no incident edges; if any
11378    /// remain it returns a named error matching openCypher semantics.
11379    fn exec_match_delete_node(
11380        &mut self,
11381        stmt: MatchDeleteNodeStmt,
11382        params: &BTreeMap<String, Value>,
11383    ) -> Result<ResultSet> {
11384        // Build a read query returning only the node keys we need.
11385        let returns: Vec<RetItem> = stmt
11386            .node_vars
11387            .iter()
11388            .map(|v| RetItem {
11389                value: RetVal::Var(v.clone()),
11390                alias: None,
11391            })
11392            .collect();
11393        let read_q = Query {
11394            matches: stmt.matches,
11395            optional_clauses: vec![],
11396            where_expr: stmt.where_expr,
11397            unwinds: vec![],
11398            post_unwind_where: None,
11399            stages: vec![],
11400            returns,
11401            distinct: false,
11402            order_by: vec![],
11403            skip: None,
11404            limit: None,
11405        };
11406        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11407            detail: format!("plan: {e}"),
11408        })?;
11409        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11410        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11411        let match_rs = {
11412            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11413            if let Some(ref mask) = mask_opt {
11414                execute(&self.view_masked(mask), &ops, &Params(params))
11415            } else {
11416                execute(&self.view(), &ops, &Params(params))
11417            }
11418        }
11419        .map_err(|e| GraphError::QueryError {
11420            detail: format!("execute: {e}"),
11421        })?;
11422
11423        // Collect unique node keys to delete (deduplicate across rows × vars).
11424        let mut keys: Vec<String> = Vec::new();
11425        for row_i in 0..match_rs.len() {
11426            for var in &stmt.node_vars {
11427                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
11428                    if !keys.contains(k) {
11429                        keys.push(k.clone());
11430                    }
11431                }
11432            }
11433        }
11434
11435        if !stmt.detach {
11436            // openCypher bare DELETE: error if any matched node has incident edges.
11437            for key in &keys {
11438                if let Some(id) = self.ids.get(key) {
11439                    let tv = self.topo_view();
11440                    let has_edges = tv.etypes().any(|et| {
11441                        !tv.neighbors(et, Direction::Out, id).is_empty()
11442                            || !tv.neighbors(et, Direction::In, id).is_empty()
11443                    });
11444                    if has_edges {
11445                        return Err(GraphError::QueryError {
11446                            detail: format!(
11447                                "Cannot delete node `{key}` because it still has incident edges. \
11448                                 Use DETACH DELETE to remove the node and all its edges."
11449                            ),
11450                        });
11451                    }
11452                }
11453            }
11454        }
11455
11456        let mut nodes_deleted = 0i64;
11457        let mut edges_deleted = 0i64;
11458        for key in keys {
11459            match self.delete_node(&key) {
11460                Ok(report) => {
11461                    nodes_deleted += 1;
11462                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
11463                }
11464                Err(GraphError::KeyNotFound { .. }) => {
11465                    // Node may have been deleted by an earlier iteration (e.g., via
11466                    // multiple MATCH rows for the same node).  Safe to skip.
11467                }
11468                Err(e) => return Err(e),
11469            }
11470        }
11471
11472        let mut rs = write_result_set();
11473        rs.push_row(vec![
11474            Some(Value::Int(0)),
11475            Some(Value::Int(0)),
11476            Some(Value::Int(nodes_deleted + edges_deleted)),
11477        ]);
11478        Ok(rs)
11479    }
11480
11481    /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
11482    /// the pattern named one, or the executing role's sole namespace when it
11483    /// did not. A role bound to two or more namespaces cannot choose, and is
11484    /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
11485    /// refuses a named `ns` the role cannot write.
11486    fn merge_create_props(
11487        &self,
11488        key_field: &str,
11489        key_value: &Value,
11490        named_ns: Option<&Value>,
11491    ) -> Result<Vec<(String, Value)>> {
11492        let mut props = vec![(key_field.to_string(), key_value.clone())];
11493        if let Some(ns) = named_ns {
11494            props.push((NS_PROP.to_string(), ns.clone()));
11495            return Ok(props);
11496        }
11497        if let Some(ns) = self.merge_create_stamp_ns()? {
11498            props.push((NS_PROP.to_string(), Value::Str(ns)));
11499        }
11500        Ok(props)
11501    }
11502
11503    /// The namespace a role-scoped MERGE create stamps when the pattern does
11504    /// not name `ns`. `None` = unscoped / full authority, so the node lands in
11505    /// `default`.
11506    fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
11507        let Some(authz) = self.pending_write_authz.as_ref() else {
11508            return Ok(None);
11509        };
11510        let Some(def) = self.role_def_for(&authz.role) else {
11511            return Ok(None);
11512        };
11513        match def.namespaces.as_deref() {
11514            Some([only]) => Ok(Some(only.clone())),
11515            Some(_) => Err(GraphError::RoleWriteDenied {
11516                reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
11517            }),
11518            None => Ok(None),
11519        }
11520    }
11521
11522    fn exec_merge(
11523        &mut self,
11524        stmt: core_query::cypher::MergeStmt,
11525        params: &BTreeMap<String, Value>,
11526    ) -> Result<ResultSet> {
11527        // MERGE: check if a node with the given key already exists.
11528        let key = match &stmt.key_value {
11529            Value::Str(s) => s.clone(),
11530            _ => {
11531                return Err(GraphError::QueryError {
11532                    detail: format!(
11533                        "MERGE key value must be a string (got {:?})",
11534                        stmt.key_value
11535                    ),
11536                })
11537            }
11538        };
11539
11540        if let Some(var) = stmt.var.as_deref() {
11541            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
11542                if sc.var != var {
11543                    return Err(GraphError::QueryError {
11544                        detail: format!(
11545                            "SET variable '{}' does not match MERGE variable '{var}'",
11546                            sc.var
11547                        ),
11548                    });
11549                }
11550            }
11551        }
11552
11553        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
11554        //
11555        // MERGE scope precondition: check create OR update scope for the
11556        // declared label BEFORE calling `has_node` (timing-oracle closure,
11557        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
11558        // unscoped roles — the scope denial fires without touching the key store).
11559        //
11560        // Clone to avoid holding a borrow on `self.pending_write_authz` while
11561        // also calling `self.ids.get(key)`.
11562        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
11563            let has_create = authz.scope.create_labels.contains(&stmt.label);
11564            let has_update = authz.scope.update_labels.contains(&stmt.label);
11565            if !has_create && !has_update {
11566                // Scope-before-lookup: 403 without has_node call (timing oracle
11567                // closure — see test_merge_unscoped_no_key_lookup).
11568                return Err(GraphError::RoleWriteDenied {
11569                    reason: format!(
11570                        "role-bound token: label '{}' not in write scope (create_labels)",
11571                        stmt.label
11572                    ),
11573                });
11574            }
11575            // Key lookup under mask.
11576            match self.ids.get(key.as_str()) {
11577                Some(id) if authz.mask.contains_id(id) => {
11578                    // Visible: must have update scope to proceed to match arm.
11579                    if !has_update {
11580                        return Err(GraphError::RoleWriteDenied {
11581                            reason: format!(
11582                                "role-bound token: label '{}' not in write scope (update_labels)",
11583                                stmt.label
11584                            ),
11585                        });
11586                    }
11587                    true // existed = true → match arm
11588                }
11589                Some(_) => {
11590                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
11591                    return Err(GraphError::RoleWriteDenied {
11592                        reason: "role-bound token: target node not visible".into(),
11593                    });
11594                }
11595                None => {
11596                    // Absent: must have create scope to proceed to the create arm.
11597                    //
11598                    // Update-only roles (create_labels empty, update_labels set):
11599                    // return the SAME "not visible" error as the hidden-key branch
11600                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
11601                    // "confirm existence of hidden nodes: No").
11602                    //
11603                    // Create-scoped roles (has_create=true): absent → create arm
11604                    // as before.  The accepted structural key-existence disclosure
11605                    // (§THREAT-MODEL) applies only when the role holds create scope.
11606                    if !has_create {
11607                        return Err(GraphError::RoleWriteDenied {
11608                            reason: "role-bound token: target node not visible".into(),
11609                        });
11610                    }
11611                    false // existed = false → create arm
11612                }
11613            }
11614        } else {
11615            // Full authority: use the existing non-masked has_node check.
11616            self.has_node(&key)
11617        };
11618
11619        let existed = merge_existed;
11620        let create_props = if existed {
11621            None
11622        } else {
11623            Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
11624        };
11625        let mut created = 0i64;
11626        if create_props.is_some() || !stmt.on_match.is_empty() {
11627            let mut batch = self.batch();
11628            if let Some(props) = create_props {
11629                batch.insert_node(&stmt.label, &key, props);
11630                for sc in &stmt.on_create {
11631                    let value = resolve_merge_set_value(&sc.value, params)?;
11632                    batch.set_prop(&key, &sc.field, value);
11633                }
11634                created = 1;
11635            } else {
11636                for sc in &stmt.on_match {
11637                    let value = resolve_merge_set_value(&sc.value, params)?;
11638                    batch.set_prop(&key, &sc.field, value);
11639                }
11640            }
11641            batch.commit()?;
11642        }
11643
11644        // Refresh the role mask so the just-created node is visible to this
11645        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
11646        // (apply_schema subset rule), so the new node's label is already in the
11647        // role's read scope — this never widens beyond the role's declared labels.
11648        if !existed {
11649            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
11650                let new_mask = self.mask_for_role(&role)?;
11651                if let Some(a) = self.pending_write_authz.as_mut() {
11652                    a.mask = new_mask;
11653                }
11654            }
11655        }
11656
11657        // Optional RETURN clause: project the node (created or matched) as a read result.
11658        if let Some(returns) = stmt.returns {
11659            let var = stmt.var.as_deref().unwrap_or("_mn0");
11660            let q = Query {
11661                matches: vec![Pattern {
11662                    start: NodePat {
11663                        var: Some(var.to_string()),
11664                        label: Some(stmt.label.clone()),
11665                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
11666                    },
11667                    chain: vec![],
11668                    shortest: false,
11669                }],
11670                optional_clauses: vec![],
11671                where_expr: None,
11672                unwinds: vec![],
11673                post_unwind_where: None,
11674                stages: vec![],
11675                returns,
11676                distinct: false,
11677                order_by: vec![],
11678                skip: None,
11679                limit: None,
11680            };
11681            let ops = plan(&q).map_err(|e| GraphError::QueryError {
11682                detail: format!("plan: {e}"),
11683            })?;
11684            // Use view_masked when a role-scoped write is in flight so the
11685            // post-merge projection is consistent with the masked read phase.
11686            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11687            return (if let Some(ref mask) = mask_opt {
11688                execute(&self.view_masked(mask), &ops, &Params(params))
11689            } else {
11690                execute(&self.view(), &ops, &Params(params))
11691            })
11692            .map_err(|e| GraphError::QueryError {
11693                detail: format!("execute: {e}"),
11694            });
11695        }
11696
11697        let mut rs = write_result_set();
11698        rs.push_row(vec![
11699            Some(Value::Int(created)),
11700            Some(Value::Int(0)),
11701            Some(Value::Int(0)),
11702        ]);
11703        Ok(rs)
11704    }
11705
11706    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
11707    /// annotated with rule name, edge type, direction, and weight.
11708    /// Results are sorted by (rule, edge_type).
11709    /// Returns `Err(KeyNotFound)` if either key is unknown.
11710    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
11711        self.ensure_v8_base_sections_loaded();
11712        let id_a = self
11713            .ids
11714            .get(key_a)
11715            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
11716        let id_b = self
11717            .ids
11718            .get(key_b)
11719            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
11720
11721        let mut results = Vec::new();
11722
11723        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
11724        // rather than O(total provenance).
11725        let scan = if self.engine.provenance_touching_len(id_a)
11726            <= self.engine.provenance_touching_len(id_b)
11727        {
11728            id_a
11729        } else {
11730            id_b
11731        };
11732        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
11733            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
11734                continue;
11735            }
11736            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
11737                continue;
11738            };
11739            let edge_type = match self.syms.resolve(etype) {
11740                Some(s) => s.to_string(),
11741                None => continue,
11742            };
11743            // Provenance (src, dst) ids come from the archived PROVENANCE section
11744            // (large, no eager CRC).  A corrupt section can produce ids that are
11745            // out of range; return Corrupt rather than panic.
11746            let src_key = self
11747                .ids
11748                .key_of(src)
11749                .ok_or_else(|| GraphError::Corrupt {
11750                    detail: format!("v8: provenance src id {src} not in id table"),
11751                })?
11752                .to_string();
11753            let dst_key = self
11754                .ids
11755                .key_of(dst)
11756                .ok_or_else(|| GraphError::Corrupt {
11757                    detail: format!("v8: provenance dst id {dst} not in id table"),
11758                })?
11759                .to_string();
11760            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
11761                self.edge_props_view()
11762                    .get(etype, src, dst, prop)
11763                    .and_then(|v| {
11764                        if let Value::Float(f) = v {
11765                            Some(f)
11766                        } else {
11767                            None
11768                        }
11769                    })
11770            });
11771            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
11772            // still have a score: recompute it from the predicate so explain
11773            // never reports "no score" for an edge the engine scored.  Via-hop
11774            // rules score over their via set, not over (src, dst), so leave
11775            // those None rather than report a number the rule did not produce.
11776            let weight = stored.or_else(|| {
11777                if rule_def.via_edge.is_some() {
11778                    return None;
11779                }
11780                let props_view = build_props_view(&self.props, &self.base);
11781                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
11782                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
11783                let src_view = NodeView {
11784                    key: &src_key,
11785                    props: &src_get,
11786                };
11787                let dst_view = NodeView {
11788                    key: &dst_key,
11789                    props: &dst_get,
11790                };
11791                evaluate(&rule_def.predicate, &src_view, &dst_view)
11792            });
11793            results.push(Explanation {
11794                rule: rule_name.to_string(),
11795                edge_type,
11796                src_key,
11797                dst_key,
11798                weight,
11799                predicate: PredicateSummary {
11800                    approximate: rule_def.approximate,
11801                    ..PredicateSummary::from(&rule_def.predicate)
11802                },
11803                via_edge: rule_def.via_edge.clone(),
11804            });
11805        }
11806
11807        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
11808        Ok(results)
11809    }
11810
11811    /// [`explain`](Self::explain) with the scoped read contract (§5.3): both
11812    /// endpoints are subject-checked, and any explanation whose evidence runs
11813    /// through a hidden node is **dropped entirely, not redacted**.
11814    ///
11815    /// A plain two-node rule's evidence is the pair itself, so once both
11816    /// subjects are visible there is nothing left to hide. A **via-hop** rule is
11817    /// different: it fires `src → dst` because some node carrying `via_label`
11818    /// sits between them, and [`Explanation`] carries the hop's edge *type*
11819    /// (`via_edge`) and never the hop's key. There is no field to blank, so a
11820    /// redacted explanation would still say "these two are linked through
11821    /// something you cannot see" — which discloses that the something exists.
11822    /// The explanation is therefore kept only when at least one **visible** via
11823    /// node satisfies the rule on its own.
11824    ///
11825    /// The weight is the **visible corpus's** number, not the store's: a via-hop
11826    /// rule stores the max over every via it hopped through, so the stored value
11827    /// can be a score only a hidden via produced. It is recomputed over the
11828    /// visible vias alone.
11829    ///
11830    /// Hidden or unknown `key_a` or `key_b` → [`GraphError::KeyNotFound`].
11831    pub fn explain_scoped(
11832        &self,
11833        key_a: &str,
11834        key_b: &str,
11835        mask: &crate::mask::NodeMask,
11836    ) -> Result<Vec<Explanation>> {
11837        for key in [key_a, key_b] {
11838            if !mask.contains_node(self, key) {
11839                return Err(GraphError::KeyNotFound { key: key.into() });
11840            }
11841        }
11842        Ok(self
11843            .explain(key_a, key_b)?
11844            .into_iter()
11845            .filter_map(|e| self.scoped_explanation(e, mask))
11846            .collect())
11847    }
11848
11849    /// `e` as a caller limited to `mask` may have it, or `None` when it must be
11850    /// dropped entirely.
11851    ///
11852    /// Every non-via-hop explanation passes through untouched: its only nodes
11853    /// are the two subjects, which [`explain_scoped`](Self::explain_scoped) has
11854    /// already checked, and its weight is scored over that pair alone.
11855    ///
11856    /// A via-hop explanation is kept only when some via node the caller may see
11857    /// satisfies the rule on its own — and then its weight is recomputed as the
11858    /// max over exactly those vias. The engine writes the max over **all** of
11859    /// them (`core-rules::engine`, `best = prev.max(score)`), so passing the
11860    /// stored number through would let a hidden node set a figure the caller
11861    /// reads: the same disclosure dropping the explanation exists to prevent.
11862    ///
11863    /// A rule that stores no weight still reports none. The recomputed score is
11864    /// a sanitised version of a number `explain` already returned, never a new
11865    /// one — a scoped read must not say more than the unscoped read it narrows.
11866    fn scoped_explanation(
11867        &self,
11868        e: Explanation,
11869        mask: &crate::mask::NodeMask,
11870    ) -> Option<Explanation> {
11871        let Some(via_edge) = e.via_edge.clone() else {
11872            return Some(e);
11873        };
11874        let Some(rule_def) = self.engine.rules().find(|r| r.name == e.rule) else {
11875            // The rule is gone but its provenance is not; nothing can vouch for
11876            // the hop, so nothing is shown.
11877            return None;
11878        };
11879        let Some(via_label) = rule_def.via_label.as_deref() else {
11880            return Some(e);
11881        };
11882        let (Some(src), Some(dst)) = (self.ids.get(&e.src_key), self.ids.get(&e.dst_key)) else {
11883            return None;
11884        };
11885        let (Some(via_etype), Some(via_sym)) = (self.syms.get(&via_edge), self.syms.get(via_label))
11886        else {
11887            return None;
11888        };
11889        let via_dir = rule_def.via_dir.unwrap_or(Direction::Out);
11890        let props_view = build_props_view(&self.props, &self.base);
11891        let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
11892        let dst_view = NodeView {
11893            key: &e.dst_key,
11894            props: &dst_get,
11895        };
11896        // The rule's own namespace test, the one the engine applies to each via
11897        // candidate (`core-rules::engine::rule_sees_node`). Without it a
11898        // visible, out-of-namespace via — right label, satisfying predicate —
11899        // vouches for a hop the engine never made, and an explanation whose real
11900        // evidence is a hidden in-namespace node is kept.
11901        let rule_sees = |id: u32| match rule_def.namespace.as_deref() {
11902            None => true,
11903            Some(ns) => {
11904                let value = props_view.get(id, NS_PROP).map(|vr| vr.into_value());
11905                namespace_of_value(value.as_ref()) == ns
11906            }
11907        };
11908        let best = self
11909            .topo_view()
11910            .neighbors(via_etype, via_dir, src)
11911            .iter()
11912            .copied()
11913            .filter_map(|via| {
11914                if !mask.contains_id(via) {
11915                    return None;
11916                }
11917                if self.labels.get(via as usize).copied() != Some(via_sym) {
11918                    return None;
11919                }
11920                if !rule_sees(via) {
11921                    return None;
11922                }
11923                let via_key = self.ids.key_of(via)?;
11924                let via_get = |field: &str| props_view.get(via, field).map(|vr| vr.into_value());
11925                let via_view = NodeView {
11926                    key: via_key,
11927                    props: &via_get,
11928                };
11929                evaluate(&rule_def.predicate, &via_view, &dst_view)
11930            })
11931            .fold(None::<f64>, |best, score| {
11932                Some(match best {
11933                    None => score,
11934                    Some(prev) => prev.max(score),
11935                })
11936            })?;
11937        let weight = e.weight.map(|_| best);
11938        Some(Explanation { weight, ..e })
11939    }
11940
11941    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
11942        let id = self
11943            .ids
11944            .get(key)
11945            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
11946        let Some(sym) = self.syms.get(edge_type) else {
11947            return Ok(Vec::new());
11948        };
11949        self.topo_view()
11950            .neighbors(sym, dir, id)
11951            .iter()
11952            .map(|&n| {
11953                self.ids
11954                    .key_of(n)
11955                    .map(|k| k.to_string())
11956                    .ok_or_else(|| GraphError::Corrupt {
11957                        detail: format!("topology id {n} has no key"),
11958                    })
11959            })
11960            .collect::<Result<Vec<_>>>()
11961    }
11962
11963    /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
11964    /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
11965    pub fn degree(
11966        &self,
11967        key: &str,
11968        edge_type: Option<&str>,
11969        direction: crate::algo::AlgoDir,
11970    ) -> Result<u64> {
11971        let id = self
11972            .ids
11973            .get(key)
11974            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
11975        let topo = self.topo_view();
11976        Ok(Self::unique_directed_degree(
11977            &topo, &self.syms, id, edge_type, direction,
11978        ))
11979    }
11980
11981    /// [`degree`](Self::degree) summing each pair's **insert count** instead of
11982    /// counting each pair once (§5.13).
11983    ///
11984    /// The unique degree asks how many neighbours there are; this asks how many
11985    /// times they were inserted. A pair with no recorded count contributes 1,
11986    /// so on a store that never called
11987    /// [`enable_multiplicity`](Self::enable_multiplicity) this returns exactly
11988    /// what [`degree`](Self::degree) returns rather than erroring — the
11989    /// distinction is a readout preference, not a demand the store cannot meet.
11990    ///
11991    /// `AlgoDir::Both` still sums out + in, so a pair visible on both sides
11992    /// still contributes twice: multiplicity changes what a pair is worth, never
11993    /// how a direction is counted.
11994    pub fn degree_multiplicity(
11995        &self,
11996        key: &str,
11997        edge_type: Option<&str>,
11998        direction: crate::algo::AlgoDir,
11999    ) -> Result<u64> {
12000        let id = self
12001            .ids
12002            .get(key)
12003            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12004        Ok(Self::multiplicity_directed_degree(
12005            &self.topo_view(),
12006            &self.edge_props_view(),
12007            &self.syms,
12008            id,
12009            edge_type,
12010            direction,
12011            None,
12012        ))
12013    }
12014
12015    /// [`degree_multiplicity`](Self::degree_multiplicity) under a scope.
12016    ///
12017    /// The sum covers **visible pairs only**. A hidden neighbour's inserts stay
12018    /// out of it for the reason
12019    /// [`degree_scoped`](Self::degree_scoped) documents, and more sharply: an
12020    /// unscoped multiplicity count discloses not only that a hidden neighbour
12021    /// exists but how often it was written.
12022    ///
12023    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
12024    pub fn degree_scoped_multiplicity(
12025        &self,
12026        key: &str,
12027        edge_type: Option<&str>,
12028        direction: crate::algo::AlgoDir,
12029        mask: &crate::mask::NodeMask,
12030    ) -> Result<u64> {
12031        let id = self
12032            .ids
12033            .get(key)
12034            .filter(|&id| mask.contains_id(id))
12035            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12036        Ok(Self::multiplicity_directed_degree(
12037            &self.topo_view(),
12038            &self.edge_props_view(),
12039            &self.syms,
12040            id,
12041            edge_type,
12042            direction,
12043            Some(mask),
12044        ))
12045    }
12046
12047    /// [`degree`](Self::degree) counting **only neighbours the mask admits**.
12048    ///
12049    /// The filter is a correctness requirement, not an optimisation: an
12050    /// unfiltered count discloses the existence of a hidden neighbour to a
12051    /// caller who cannot see it, which is the same leak
12052    /// [`node_edges_scoped`](Self::node_edges_scoped) exists to prevent —
12053    /// reached by arithmetic instead of by name.
12054    ///
12055    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`]. Unknown
12056    /// `edge_type` is still 0, as it is unscoped.
12057    pub fn degree_scoped(
12058        &self,
12059        key: &str,
12060        edge_type: Option<&str>,
12061        direction: crate::algo::AlgoDir,
12062        mask: &crate::mask::NodeMask,
12063    ) -> Result<u64> {
12064        let id = self
12065            .ids
12066            .get(key)
12067            .filter(|&id| mask.contains_id(id))
12068            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12069        let topo = self.topo_view();
12070        Ok(Self::visible_directed_degree(
12071            &topo, &self.syms, id, edge_type, direction, mask,
12072        ))
12073    }
12074
12075    /// Unique directed degree for a subset or a label scan.
12076    ///
12077    /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
12078    /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
12079    /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
12080    #[allow(clippy::too_many_arguments)]
12081    pub fn degrees(
12082        &self,
12083        keys: Option<&[String]>,
12084        label: Option<&str>,
12085        where_: Option<&PropPredicate>,
12086        edge_type: Option<&str>,
12087        direction: crate::algo::AlgoDir,
12088        limit: Option<usize>,
12089    ) -> Result<Vec<(String, u64)>> {
12090        self.degrees_inner(
12091            keys, label, where_, edge_type, direction, limit, None, false,
12092        )
12093    }
12094
12095    /// [`degrees`](Self::degrees) reporting each row's **insert-count** sum
12096    /// instead of its unique neighbour count, as
12097    /// [`degree_multiplicity`](Self::degree_multiplicity) does for one key.
12098    ///
12099    /// The sort is still degree descending, key ascending — over the counts this
12100    /// reading produces — and `limit` still applies after it.
12101    #[allow(clippy::too_many_arguments)]
12102    pub fn degrees_multiplicity(
12103        &self,
12104        keys: Option<&[String]>,
12105        label: Option<&str>,
12106        where_: Option<&PropPredicate>,
12107        edge_type: Option<&str>,
12108        direction: crate::algo::AlgoDir,
12109        limit: Option<usize>,
12110    ) -> Result<Vec<(String, u64)>> {
12111        self.degrees_inner(keys, label, where_, edge_type, direction, limit, None, true)
12112    }
12113
12114    /// [`degrees_scoped`](Self::degrees_scoped) reporting insert counts.
12115    ///
12116    /// Both filters apply: a hidden key stays out of the result, and every
12117    /// row's sum covers its **visible** pairs only.
12118    #[allow(clippy::too_many_arguments)]
12119    pub fn degrees_scoped_multiplicity(
12120        &self,
12121        keys: Option<&[String]>,
12122        label: Option<&str>,
12123        where_: Option<&PropPredicate>,
12124        edge_type: Option<&str>,
12125        direction: crate::algo::AlgoDir,
12126        limit: Option<usize>,
12127        mask: &crate::mask::NodeMask,
12128    ) -> Result<Vec<(String, u64)>> {
12129        self.degrees_inner(
12130            keys,
12131            label,
12132            where_,
12133            edge_type,
12134            direction,
12135            limit,
12136            Some(mask),
12137            true,
12138        )
12139    }
12140
12141    /// [`degrees`](Self::degrees) with the scope applied on both sides: a hidden
12142    /// key is omitted from the input — whether it arrived in `keys` or came out
12143    /// of the `label`/`where_` scan — and every row's count is the count of its
12144    /// **visible** neighbours, for the reason
12145    /// [`degree_scoped`](Self::degree_scoped) documents.
12146    ///
12147    /// Unlike `degree_scoped`, a hidden key here is not
12148    /// [`GraphError::KeyNotFound`]: `degrees` already drops unknown keys
12149    /// silently, so hidden and absent stay one answer by staying out of the
12150    /// result. `limit` still applies after the sort, and so counts visible rows.
12151    #[allow(clippy::too_many_arguments)]
12152    pub fn degrees_scoped(
12153        &self,
12154        keys: Option<&[String]>,
12155        label: Option<&str>,
12156        where_: Option<&PropPredicate>,
12157        edge_type: Option<&str>,
12158        direction: crate::algo::AlgoDir,
12159        limit: Option<usize>,
12160        mask: &crate::mask::NodeMask,
12161    ) -> Result<Vec<(String, u64)>> {
12162        self.degrees_inner(
12163            keys,
12164            label,
12165            where_,
12166            edge_type,
12167            direction,
12168            limit,
12169            Some(mask),
12170            false,
12171        )
12172    }
12173
12174    /// The body shared by [`degrees`](Self::degrees) and
12175    /// [`degrees_scoped`](Self::degrees_scoped). `mask = None` is the unscoped
12176    /// contract unchanged.
12177    #[allow(clippy::too_many_arguments)]
12178    fn degrees_inner(
12179        &self,
12180        keys: Option<&[String]>,
12181        label: Option<&str>,
12182        where_: Option<&PropPredicate>,
12183        edge_type: Option<&str>,
12184        direction: crate::algo::AlgoDir,
12185        limit: Option<usize>,
12186        mask: Option<&crate::mask::NodeMask>,
12187        multiplicity: bool,
12188    ) -> Result<Vec<(String, u64)>> {
12189        if let Some(pred) = where_ {
12190            pred.validate_named("where")
12191                .map_err(|detail| GraphError::QueryError { detail })?;
12192        }
12193        if matches!(keys, Some(ks) if ks.is_empty()) {
12194            return Ok(Vec::new());
12195        }
12196        let view = self.view();
12197        let ids: Vec<u32> = match keys {
12198            Some(ks) => {
12199                let mut seen = HashSet::new();
12200                let mut out = Vec::new();
12201                for k in ks {
12202                    let Some(id) = view.ids.get(k) else {
12203                        continue;
12204                    };
12205                    if !seen.insert(id) {
12206                        continue;
12207                    }
12208                    if let Some(pred) = where_ {
12209                        let holds = match view.prop(id, &pred.field) {
12210                            None => pred.holds(None),
12211                            Some(vr) => pred.holds(Some(vr.as_value())),
12212                        };
12213                        if !holds {
12214                            continue;
12215                        }
12216                    }
12217                    out.push(id);
12218                }
12219                out
12220            }
12221            None => Self::vector_candidates(&view, label, where_),
12222        };
12223        let mut out: Vec<(String, u64)> = ids
12224            .into_iter()
12225            // A hidden candidate leaves as quietly as an unknown key does.
12226            .filter(|&id| mask.is_none_or(|m| m.contains_id(id)))
12227            .filter_map(|id| {
12228                let key = self.ids.key_of(id)?.to_string();
12229                let deg = match (multiplicity, mask) {
12230                    // The same `view` the unique arms read, so the per-row
12231                    // rebuild F9 measured is gone and all three arms agree on
12232                    // the state they are reading.
12233                    (true, m) => Self::multiplicity_directed_degree(
12234                        &view.topo,
12235                        &view.edge_props,
12236                        view.syms,
12237                        id,
12238                        edge_type,
12239                        direction,
12240                        m,
12241                    ),
12242                    (false, Some(m)) => Self::visible_directed_degree(
12243                        &view.topo, view.syms, id, edge_type, direction, m,
12244                    ),
12245                    (false, None) => Self::unique_directed_degree(
12246                        &view.topo, view.syms, id, edge_type, direction,
12247                    ),
12248                };
12249                Some((key, deg))
12250            })
12251            .collect();
12252        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
12253        if let Some(lim) = limit {
12254            out.truncate(lim);
12255        }
12256        Ok(out)
12257    }
12258
12259    /// Unique neighbour count for `id` across `edge_type` (or all types) and
12260    /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
12261    fn unique_directed_degree(
12262        topo: &TopologyView<'_>,
12263        syms: &Interner,
12264        id: u32,
12265        edge_type: Option<&str>,
12266        direction: crate::algo::AlgoDir,
12267    ) -> u64 {
12268        let dirs: &[Direction] = match direction {
12269            crate::algo::AlgoDir::Out => &[Direction::Out],
12270            crate::algo::AlgoDir::In => &[Direction::In],
12271            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12272        };
12273        match edge_type {
12274            Some(name) => {
12275                let Some(et) = syms.get(name) else {
12276                    return 0;
12277                };
12278                dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
12279            }
12280            None => topo
12281                .etypes()
12282                .map(|et| {
12283                    dirs.iter()
12284                        .map(|&d| topo.degree(et, d, id) as u64)
12285                        .sum::<u64>()
12286                })
12287                .sum(),
12288        }
12289    }
12290
12291    /// [`unique_directed_degree`](Self::unique_directed_degree) counting only
12292    /// neighbours `mask` admits.
12293    ///
12294    /// Same shape, one substitution: `topo.degree` is a length, so it cannot be
12295    /// filtered; the neighbour list it measures can. `Both` still sums out + in,
12296    /// so a node visible on both sides still counts twice — the filter changes
12297    /// which neighbours are counted, never how a degree is defined.
12298    fn visible_directed_degree(
12299        topo: &TopologyView<'_>,
12300        syms: &Interner,
12301        id: u32,
12302        edge_type: Option<&str>,
12303        direction: crate::algo::AlgoDir,
12304        mask: &crate::mask::NodeMask,
12305    ) -> u64 {
12306        let dirs: &[Direction] = match direction {
12307            crate::algo::AlgoDir::Out => &[Direction::Out],
12308            crate::algo::AlgoDir::In => &[Direction::In],
12309            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12310        };
12311        let visible = |et: u32| -> u64 {
12312            dirs.iter()
12313                .map(|&d| {
12314                    topo.neighbors(et, d, id)
12315                        .iter()
12316                        .filter(|&&n| mask.contains_id(n))
12317                        .count() as u64
12318                })
12319                .sum()
12320        };
12321        match edge_type {
12322            Some(name) => syms.get(name).map_or(0, visible),
12323            None => topo.etypes().map(visible).sum(),
12324        }
12325    }
12326
12327    /// Sum of the insert counts of `id`'s pairs (§5.13), over `edge_type` (or
12328    /// all types) and `direction`, restricted to what `mask` admits when one is
12329    /// given.
12330    ///
12331    /// The same neighbour lists the unique reading measures, with each entry
12332    /// worth its pair's count rather than worth 1 — so the filter decides which
12333    /// pairs are in the sum and the count decides what each contributes. A
12334    /// direction decides which way round the pair is addressed: an `In`
12335    /// neighbour `n` of `id` is the pair `(et, n, id)`.
12336    ///
12337    /// Takes its views as parameters, exactly as the unique helpers do, because
12338    /// it is called once per row from a label scan. `edge_props_view()` reaches
12339    /// into the mmap'd base's rkyv section on every call, so building the two
12340    /// views inside made an N-row `degrees(multiplicity=True)` do N section
12341    /// accesses where the unique reading does one: worth 2.57 ms of 16.68 ms
12342    /// over 20 000 rows, about 0.13 us per row (defect #29,
12343    /// `tests/f9_bench.rs`). Most of that call's cost is the per-neighbour
12344    /// count lookup and is inherent, so this is a hoist, not a rescue.
12345    ///
12346    /// The views are exactly `self.view()`'s own `topo` and `edge_props`, so a
12347    /// caller that already has a view passes its halves and reads the same
12348    /// state it reads everything else from.
12349    fn multiplicity_directed_degree(
12350        topo: &TopologyView<'_>,
12351        edge_props: &EdgePropsView<'_>,
12352        syms: &Interner,
12353        id: u32,
12354        edge_type: Option<&str>,
12355        direction: crate::algo::AlgoDir,
12356        mask: Option<&crate::mask::NodeMask>,
12357    ) -> u64 {
12358        let dirs: &[Direction] = match direction {
12359            crate::algo::AlgoDir::Out => &[Direction::Out],
12360            crate::algo::AlgoDir::In => &[Direction::In],
12361            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12362        };
12363        let count_of = |et: u32, src: u32, dst: u32| -> u64 {
12364            match edge_props.get(et, src, dst, EDGE_COUNT_PROP) {
12365                Some(Value::Int(n)) if n > 0 => n as u64,
12366                _ => 1,
12367            }
12368        };
12369        let per_etype = |et: u32| -> u64 {
12370            dirs.iter()
12371                .map(|&d| {
12372                    topo.neighbors(et, d, id)
12373                        .iter()
12374                        .filter(|&&n| mask.is_none_or(|m| m.contains_id(n)))
12375                        .map(|&n| match d {
12376                            Direction::Out => count_of(et, id, n),
12377                            Direction::In => count_of(et, n, id),
12378                        })
12379                        .sum::<u64>()
12380                })
12381                .sum()
12382        };
12383        match edge_type {
12384            Some(name) => syms.get(name).map_or(0, per_etype),
12385            None => topo.etypes().map(per_etype).sum(),
12386        }
12387    }
12388
12389    /// Return the last-change commit sequence for `key`, or `None` if the node
12390    /// does not exist or has never been mutated since the last V5-V7 snapshot
12391    /// (horizon-bounded for legacy stores).
12392    ///
12393    /// The returned sequence is a monotonically increasing counter that starts
12394    /// at 1 for the first commit after `open` and increments with every
12395    /// successful write.  WAL replay at open also assigns sequences (1..N for N
12396    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
12397    ///
12398    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
12399    /// in the snapshot but not touched by any WAL frame will return `None`
12400    /// (horizon-bounded: CAS against such nodes is only safe after the first
12401    /// V8 snapshot or after the node is next mutated).
12402    pub fn last_changed(&self, key: &str) -> Option<u64> {
12403        let id = self.ids.get(key)?;
12404        self.last_change.get(&id).copied()
12405    }
12406
12407    /// Which loaded store this handle is.
12408    ///
12409    /// Paired with [`commit_seq`](GraphDb::commit_seq) it identifies a graph
12410    /// state outright, which `commit_seq` alone does not: two stores of the same
12411    /// age share a sequence, and a reload can return to one. Memos of dense
12412    /// node ids that the handle does not own are stamped with both; see
12413    /// [`StoreStamp`](crate::mask::StoreStamp).
12414    pub(crate) fn store_id(&self) -> crate::mask::StoreId {
12415        self.store_id
12416    }
12417
12418    /// The current commit sequence (number of successful commits since open,
12419    /// including WAL replay frames).  Useful for recording a baseline before
12420    /// a read-modify-write cycle.
12421    pub fn commit_seq(&self) -> u64 {
12422        self.commit_seq
12423    }
12424
12425    /// Check that all `preconds` are satisfied against the current db state.
12426    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
12427    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
12428        for precond in preconds {
12429            match precond {
12430                Precondition::NodeUnchangedSince { key, expected } => {
12431                    // Missing entry means the node predates the WAL window or
12432                    // does not exist; treat as 0 (before any commit).
12433                    let actual = self.last_changed(key).unwrap_or_default();
12434                    if actual != *expected {
12435                        return Err(GraphError::CasConflict {
12436                            key: key.clone(),
12437                            expected: *expected,
12438                            actual,
12439                        });
12440                    }
12441                }
12442                Precondition::NodeAbsent { key } => {
12443                    // Node must not exist (not live).
12444                    if self.ids.get(key).is_some() {
12445                        let actual = self.last_changed(key).unwrap_or(0);
12446                        return Err(GraphError::CasConflict {
12447                            key: key.clone(),
12448                            expected: u64::MAX,
12449                            actual,
12450                        });
12451                    }
12452                }
12453            }
12454        }
12455        Ok(())
12456    }
12457
12458    /// Apply a batch of mutations with compare-and-set preconditions.
12459    ///
12460    /// All preconditions are checked atomically before any operation is applied.
12461    /// If any precondition fails, the entire batch is rejected with
12462    /// [`GraphError::CasConflict`] and no WAL frame is written.
12463    ///
12464    /// # Returns
12465    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
12466    ///
12467    /// # Errors
12468    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
12469    /// - Any error that [`write_batch`] would return for the ops themselves.
12470    pub fn write_batch_cas(
12471        &mut self,
12472        preconds: Vec<Precondition>,
12473        ops: Vec<BatchOp>,
12474    ) -> Result<(usize, usize)> {
12475        self.check_preconditions(&preconds)?;
12476        self.commit_logged_batch(ops, None, None).map(inserted_pair)
12477    }
12478
12479    /// Update the per-node last-change map for a WAL record at commit `seq`.
12480    ///
12481    /// Called after a successful apply to record which nodes were touched.
12482    /// For replay, called with the WAL-frame's replayed seq.
12483    ///
12484    /// Touch definition (see [`Precondition`] doc):
12485    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
12486    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
12487    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
12488    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
12489    /// - Batch → recurse into inner records.
12490    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
12491        match rec {
12492            WalRecord::InsertNode { key, .. }
12493            | WalRecord::SetProp { key, .. }
12494            | WalRecord::RemoveProp { key, .. } => {
12495                if let Some(id) = self.ids.get(key) {
12496                    self.last_change.insert(id, seq);
12497                }
12498            }
12499            WalRecord::InsertNodeId { key, .. } => {
12500                if let Some(id) = self.ids.get(key) {
12501                    self.last_change.insert(id, seq);
12502                }
12503            }
12504            WalRecord::SetPropId { id, .. } => {
12505                self.last_change.insert(*id, seq);
12506            }
12507            WalRecord::InsertEdge {
12508                src_key, dst_key, ..
12509            }
12510            | WalRecord::DeleteEdge {
12511                src_key, dst_key, ..
12512            } => {
12513                if let Some(src_id) = self.ids.get(src_key) {
12514                    self.last_change.insert(src_id, seq);
12515                }
12516                if let Some(dst_id) = self.ids.get(dst_key) {
12517                    self.last_change.insert(dst_id, seq);
12518                }
12519            }
12520            WalRecord::InsertEdgeId { src, dst, .. } => {
12521                self.last_change.insert(*src, seq);
12522                self.last_change.insert(*dst, seq);
12523            }
12524            // A count record touches the pair, so it touches both endpoints —
12525            // the same reading `InsertEdgeId` gets, because a duplicate insert
12526            // that raises the count *is* a mutation of that pair. The opt-in
12527            // declaration touches nothing.
12528            WalRecord::SetEdgeCount { src, dst, .. } if !rec.is_multiplicity_decl() => {
12529                self.last_change.insert(*src, seq);
12530                self.last_change.insert(*dst, seq);
12531            }
12532            WalRecord::SetEdgeCount { .. } => {}
12533            // DeleteNode: node is tombstoned; last_changed(key) returns None for
12534            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
12535            // History markers: state no-ops; the underlying mutation already
12536            // touched the relevant nodes' last_change entries.
12537            WalRecord::DeleteNode { .. }
12538            | WalRecord::DerivedEdgeAdded { .. }
12539            | WalRecord::DerivedEdgeRetracted { .. }
12540            | WalRecord::Intern { .. }
12541            | WalRecord::CreateRule { .. }
12542            | WalRecord::DeleteRule { .. }
12543            | WalRecord::RebuildRule { .. }
12544            | WalRecord::CreateView { .. }
12545            | WalRecord::DeleteView { .. }
12546            | WalRecord::EnableFulltext { .. }
12547            | WalRecord::DisableFulltext { .. }
12548            | WalRecord::EnableIndex { .. }
12549            | WalRecord::DisableIndex { .. } => {}
12550            // RenameNode: node id is stable; update last_change via the new key.
12551            // Called after apply(), so ids already reflects new_key.
12552            WalRecord::RenameNode { new_key, .. } => {
12553                if let Some(id) = self.ids.get(new_key) {
12554                    self.last_change.insert(id, seq);
12555                }
12556            }
12557            WalRecord::Batch(inner) => {
12558                for inner_rec in inner {
12559                    self.update_last_change_from_rec(inner_rec, seq);
12560                }
12561            }
12562        }
12563    }
12564
12565    pub fn node_count(&self) -> usize {
12566        self.ids.len()
12567    }
12568
12569    /// Configure archive retention: keep the `N` newest WAL archives at each
12570    /// [`snapshot_with`] call when `archive_wal: true`.
12571    ///
12572    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
12573    /// `Some(0)` or `None` → unlimited (no pruning).
12574    ///
12575    /// Pruning only ever happens inside [`snapshot_with`]; this method only
12576    /// stores the policy.  Archives below the retention limit are deleted
12577    /// oldest-first.  The horizon floor is updated so that
12578    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
12579    /// in pruned archives rather than silently returning wrong data.
12580    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
12581        self.wal_archive_retention = keep;
12582    }
12583
12584    /// Delete any WAL archives that are fully below the current horizon floor.
12585    ///
12586    /// Orphaned archives arise when the floor is written first during retention
12587    /// pruning and then a crash interrupts the archive-delete sequence.  The
12588    /// opening cleanup ensures no subsequent read path sees stale data.
12589    ///
12590    /// Under the monotonic naming scheme, the archive name N equals the
12591    /// cumulative end-frame index of the archive in global commit space (i.e.
12592    /// the archive covers global frames `[prev_n, N)`).  An archive is
12593    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
12594    /// below the floor and have already been counted in it.
12595    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
12596        if self.wal_horizon_floor == 0 {
12597            // Floor at 0 means no pruning has ever occurred; nothing to clean.
12598            return Ok(());
12599        }
12600        let archive_ns = self.fs.list_archives()?;
12601        for n in archive_ns {
12602            if n <= self.wal_horizon_floor {
12603                // Archive N ends at global frame N; all its frames are below
12604                // the floor (floor already accounts for them) → orphaned.
12605                self.fs.delete_archive(n).map_err(GraphError::Io)?;
12606            } else {
12607                // Archives are sorted ascending; first one above floor stops scan.
12608                break;
12609            }
12610        }
12611        Ok(())
12612    }
12613
12614    /// Collect all WAL frames from surviving archives (oldest-first) then the
12615    /// live WAL into one flat list, and return the total along with the number
12616    /// of archive frames at the front of the list.
12617    ///
12618    /// Commit indices into the returned list are LOCAL (0 = first frame of
12619    /// oldest surviving archive).  To obtain the GLOBAL index add
12620    /// `self.wal_horizon_floor`.
12621    /// How many frames the surviving archives hold, without materialising them.
12622    ///
12623    /// The same count `all_frames` puts at the front of its list. Used to seed
12624    /// [`wal_frames_written`](GraphDb::wal_frames_written) at open without
12625    /// decoding the live WAL a second time; free on a store with no archives,
12626    /// which is most of them.
12627    fn archive_frame_count(&self) -> Result<u64> {
12628        let mut n = 0u64;
12629        for a in self.fs.list_archives()? {
12630            let bytes = self.fs.read_archive(a)?;
12631            let (frames, _) = decode_all(&bytes);
12632            n += frames.len() as u64;
12633        }
12634        Ok(n)
12635    }
12636
12637    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
12638        let archive_ns = self.fs.list_archives()?;
12639        let mut all: Vec<WalRecord> = Vec::new();
12640        for n in archive_ns {
12641            let bytes = self.fs.read_archive(n)?;
12642            let (frames, _) = decode_all(&bytes);
12643            all.extend(frames);
12644        }
12645        let archive_count = all.len() as u64;
12646        let live_bytes = self.fs.read(FileId::Wal)?;
12647        let (live_frames, _) = decode_all(&live_bytes);
12648        all.extend(live_frames);
12649        Ok((all, archive_count))
12650    }
12651
12652    /// Return the total number of committed WAL frames visible in the current
12653    /// horizon window, including frames in surviving WAL archives.
12654    ///
12655    /// This is the exclusive upper bound for valid `at_commit` indices in
12656    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
12657    ///
12658    /// Returns the horizon floor when all surviving history is empty.
12659    pub fn wal_total_commits(&self) -> Result<u64> {
12660        // The floor that belongs with these frames: both are read from disk
12661        // here. A handle that has not refreshed since another process moved
12662        // the floor would otherwise number new frames from its old one (row 81).
12663        let floor = self.fs.read_horizon_floor()?;
12664        let (frames, _) = self.all_frames()?;
12665        Ok(floor + frames.len() as u64)
12666    }
12667
12668    /// The global frame index of the first commit reachable through surviving
12669    /// archives (0 when no archives have been pruned).
12670    pub fn wal_horizon_floor(&self) -> u64 {
12671        self.wal_horizon_floor
12672    }
12673
12674    /// Return the per-node change history for `key` by scanning the on-disk WAL.
12675    ///
12676    /// ## Horizon
12677    ///
12678    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
12679    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
12680    /// zero-cost contract; a durable history log is out of scope.
12681    ///
12682    /// ## Derived edges
12683    ///
12684    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
12685    /// history. Only edges written directly by the application are recorded.
12686    ///
12687    /// ## Deleted nodes
12688    ///
12689    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
12690    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
12691    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
12692    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
12693    ///
12694    /// ## Dense-id edge entries and tombstoned partners
12695    ///
12696    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
12697    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
12698    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
12699    /// Build commit-bounded alias intervals for `queried_key`.
12700    ///
12701    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
12702    /// A record written under `key` at commit `c` matches the queried identity iff
12703    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
12704    ///
12705    /// Each alias entry carries both a lower and an upper bound so that key-reuse
12706    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
12707    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
12708    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
12709    /// only identity-2's events (commits 7–9 under "a") are in scope.
12710    ///
12711    /// Only **forward aliasing**: querying the *new* key surfaces events written
12712    /// under the *old* key.  The reverse direction is not supported.
12713    fn build_key_alias_intervals(
12714        &self,
12715        frames: &[core_storage::wal::WalRecord],
12716        queried_key: &str,
12717        floor: u64,
12718    ) -> Vec<(String, u64, Option<u64>)> {
12719        self.key_alias_intervals(frames, queried_key, floor, false)
12720    }
12721
12722    /// [`Self::build_key_alias_intervals`], and with `current_holder_only`
12723    /// the queried key itself is bounded too: it counts from the commit the
12724    /// node that has it now acquired it. An earlier node that held the same
12725    /// key and was deleted is then no part of the answer — neither its edges
12726    /// nor its deletion.
12727    fn key_alias_intervals(
12728        &self,
12729        frames: &[core_storage::wal::WalRecord],
12730        queried_key: &str,
12731        floor: u64,
12732        current_holder_only: bool,
12733    ) -> Vec<(String, u64, Option<u64>)> {
12734        use core_storage::wal::WalRecord;
12735
12736        // Pre-pass: build reverse_rename and key_starts maps.
12737        // Every rename into a key, in commit order. A key can be renamed into
12738        // more than once — `a → b`, `b → a`, `a → b` — and which rename led
12739        // to the occupancy being followed depends on when.
12740        let mut reverse_rename: HashMap<String, Vec<(String, u64)>> = HashMap::new();
12741        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
12742
12743        for (local_i, frame) in frames.iter().enumerate() {
12744            let commit = floor + local_i as u64;
12745            let records: &[WalRecord] = match frame {
12746                WalRecord::Batch(inner) => inner.as_slice(),
12747                single => std::slice::from_ref(single),
12748            };
12749            for rec in records {
12750                match rec {
12751                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
12752                        key_starts.entry(key.clone()).or_default().push(commit);
12753                    }
12754                    WalRecord::RenameNode { old_key, new_key } => {
12755                        // new_key came into existence at this commit.
12756                        key_starts.entry(new_key.clone()).or_default().push(commit);
12757                        // Record the reverse rename: new_key was introduced by renaming old_key.
12758                        reverse_rename
12759                            .entry(new_key.clone())
12760                            .or_default()
12761                            .push((old_key.clone(), commit));
12762                    }
12763                    _ => {}
12764                }
12765            }
12766        }
12767
12768        // Build alias intervals by following the reverse rename chain.
12769        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
12770        let mut current_key = queried_key.to_string();
12771        let mut current_valid_until: Option<u64> = None;
12772
12773        loop {
12774            // valid_from: the most recent commit where current_key was assigned to this
12775            // identity.  For aliases (valid_until = Some(vu)), find the last start event
12776            // for the key strictly before vu — this is where the alias's occupancy by
12777            // this identity began, correctly excluding prior identities that reused the key.
12778            let valid_from = if let Some(vu) = current_valid_until {
12779                key_starts
12780                    .get(&current_key)
12781                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
12782                    .unwrap_or(floor)
12783            } else if current_holder_only {
12784                key_starts
12785                    .get(&current_key)
12786                    .and_then(|starts| starts.last().copied())
12787                    .unwrap_or(floor)
12788            } else {
12789                // Queried key — no upper bound; may have been introduced at any commit.
12790                floor
12791            };
12792
12793            result.push((current_key.clone(), valid_from, current_valid_until));
12794
12795            // The rename that led here: the latest into this key, and for an
12796            // alias the latest before the alias stopped being this node's.
12797            // Each step goes strictly back in time, so the walk ends — taking
12798            // the last rename whatever its commit sent `a → b → a` round for
12799            // ever.
12800            let led_here = reverse_rename
12801                .get(&current_key)
12802                .and_then(|renames| {
12803                    renames
12804                        .iter()
12805                        .rev()
12806                        .find(|(_, at)| current_valid_until.is_none_or(|until| *at < until))
12807                })
12808                // And only if that rename is how this occupancy of the key
12809                // began. A key renamed into, deleted and inserted fresh
12810                // starts a new node at the insert; the older rename led to
12811                // the node that was deleted.
12812                .filter(|(_, at)| {
12813                    (current_valid_until.is_none() && !current_holder_only) || *at == valid_from
12814                });
12815            match led_here {
12816                Some((old_key, rename_commit)) => {
12817                    current_valid_until = Some(*rename_commit);
12818                    current_key = old_key.clone();
12819                }
12820                None => break,
12821            }
12822        }
12823
12824        result
12825    }
12826
12827    /// Returns true if `record_key` matches any alias interval that covers `commit`.
12828    fn aliases_match(
12829        intervals: &[(String, u64, Option<u64>)],
12830        record_key: &str,
12831        commit: u64,
12832    ) -> bool {
12833        intervals
12834            .iter()
12835            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
12836    }
12837
12838    /// Return the change history of node `key` by scanning the on-disk WAL.
12839    ///
12840    /// ## Horizon
12841    ///
12842    /// History reaches back only as far as the retained WAL. The returned
12843    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
12844    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
12845    /// oldest commit still reachable). When `horizon > 0`, older events were
12846    /// pruned and are not in `items`.
12847    pub fn node_history(
12848        &self,
12849        key: &str,
12850    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
12851        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
12852        use core_storage::wal::WalRecord;
12853
12854        // The floor that belongs with these frames: both are read from disk
12855        // here. A handle that has not refreshed since another process moved
12856        // the floor would otherwise number new frames from its old one (row 81).
12857        let floor = self.fs.read_horizon_floor()?;
12858        let (frames, _) = self.all_frames()?;
12859        let total_commits = floor + frames.len() as u64;
12860
12861        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
12862        let alias_intervals = self.build_key_alias_intervals(&frames, key, floor);
12863
12864        let mut out: Vec<HistoryEntry> = Vec::new();
12865
12866        for (local_i, frame) in frames.iter().enumerate() {
12867            let commit = floor + local_i as u64;
12868            // Collect the inner records to process — Batch is one commit, single records are one commit.
12869            let records: &[WalRecord] = match frame {
12870                WalRecord::Batch(inner) => inner.as_slice(),
12871                single => std::slice::from_ref(single),
12872            };
12873
12874            for rec in records {
12875                let change = match rec {
12876                    WalRecord::InsertNode { label, key: k, .. }
12877                        if Self::aliases_match(&alias_intervals, k, commit) =>
12878                    {
12879                        Some(HistoryChange::NodeInserted {
12880                            label: label.clone(),
12881                        })
12882                    }
12883                    WalRecord::InsertNodeId { label, key: k, .. }
12884                        if Self::aliases_match(&alias_intervals, k, commit) =>
12885                    {
12886                        let label_str = match self.syms.resolve(*label) {
12887                            Some(s) => s.to_string(),
12888                            None => continue,
12889                        };
12890                        Some(HistoryChange::NodeInserted { label: label_str })
12891                    }
12892                    WalRecord::SetProp {
12893                        key: k,
12894                        field,
12895                        value,
12896                    } if Self::aliases_match(&alias_intervals, k, commit) => {
12897                        Some(HistoryChange::PropSet {
12898                            field: field.clone(),
12899                            value: value.clone(),
12900                        })
12901                    }
12902                    WalRecord::SetPropId { id, field, value } => {
12903                        // Use key_of_historical (not key_of) so a node's prop_set
12904                        // events remain visible after the node is later deleted:
12905                        // key_of returns None for a tombstoned id, which would
12906                        // silently drop every PropSet between insert and delete.
12907                        // Mirrors the InsertEdgeId arm below and edge_history's
12908                        // own id-keyed arms.
12909                        match self.ids.key_of_historical(*id) {
12910                            // key_of_historical returns the last-known (possibly
12911                            // post-rename, possibly post-delete) key; compare to queried key.
12912                            Some(resolved) if resolved == key => {
12913                                let field_str = match self.syms.resolve(*field) {
12914                                    Some(s) => s.to_string(),
12915                                    None => continue,
12916                                };
12917                                Some(HistoryChange::PropSet {
12918                                    field: field_str,
12919                                    value: value.clone(),
12920                                })
12921                            }
12922                            _ => None,
12923                        }
12924                    }
12925                    WalRecord::RemoveProp { key: k, field }
12926                        if Self::aliases_match(&alias_intervals, k, commit) =>
12927                    {
12928                        Some(HistoryChange::PropRemoved {
12929                            field: field.clone(),
12930                        })
12931                    }
12932                    WalRecord::InsertEdge {
12933                        edge_type,
12934                        src_key,
12935                        dst_key,
12936                    } => {
12937                        if Self::aliases_match(&alias_intervals, src_key, commit) {
12938                            Some(HistoryChange::EdgeAdded {
12939                                edge_type: edge_type.clone(),
12940                                other: dst_key.clone(),
12941                                outgoing: true,
12942                            })
12943                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
12944                            Some(HistoryChange::EdgeAdded {
12945                                edge_type: edge_type.clone(),
12946                                other: src_key.clone(),
12947                                outgoing: false,
12948                            })
12949                        } else {
12950                            None
12951                        }
12952                    }
12953                    WalRecord::InsertEdgeId { etype, src, dst } => {
12954                        let etype_str = match self.syms.resolve(*etype) {
12955                            Some(s) => s.to_string(),
12956                            None => continue,
12957                        };
12958                        // key_of_historical (not key_of): an edge added before
12959                        // either endpoint was later deleted must still resolve —
12960                        // see the SetPropId arm above and edge_history's
12961                        // InsertEdgeId arm, which use the same lookup for the
12962                        // same reason.
12963                        let src_key = self.ids.key_of_historical(*src);
12964                        let dst_key = self.ids.key_of_historical(*dst);
12965                        if src_key == Some(key) {
12966                            let other = match dst_key {
12967                                Some(s) => s.to_string(),
12968                                None => continue,
12969                            };
12970                            Some(HistoryChange::EdgeAdded {
12971                                edge_type: etype_str,
12972                                other,
12973                                outgoing: true,
12974                            })
12975                        } else if dst_key == Some(key) {
12976                            let other = match src_key {
12977                                Some(s) => s.to_string(),
12978                                None => continue,
12979                            };
12980                            Some(HistoryChange::EdgeAdded {
12981                                edge_type: etype_str,
12982                                other,
12983                                outgoing: false,
12984                            })
12985                        } else {
12986                            None
12987                        }
12988                    }
12989                    WalRecord::DeleteEdge {
12990                        edge_type,
12991                        src_key,
12992                        dst_key,
12993                    } => {
12994                        if Self::aliases_match(&alias_intervals, src_key, commit) {
12995                            Some(HistoryChange::EdgeRemoved {
12996                                edge_type: edge_type.clone(),
12997                                other: dst_key.clone(),
12998                                outgoing: true,
12999                            })
13000                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13001                            Some(HistoryChange::EdgeRemoved {
13002                                edge_type: edge_type.clone(),
13003                                other: src_key.clone(),
13004                                outgoing: false,
13005                            })
13006                        } else {
13007                            None
13008                        }
13009                    }
13010                    WalRecord::DeleteNode { key: k }
13011                        if Self::aliases_match(&alias_intervals, k, commit) =>
13012                    {
13013                        Some(HistoryChange::NodeDeleted)
13014                    }
13015                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
13016                    _ => None,
13017                };
13018
13019                if let Some(change) = change {
13020                    out.push(HistoryEntry { commit, change });
13021                }
13022            }
13023        }
13024
13025        Ok(HistoryResult {
13026            items: out,
13027            total_commits,
13028            horizon: floor,
13029        })
13030    }
13031
13032    /// Return the per-edge change history between nodes `a` and `b` by scanning
13033    /// the on-disk WAL.
13034    ///
13035    /// ## Horizon
13036    ///
13037    /// History reaches back only to the last WAL-truncating snapshot, exactly
13038    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
13039    /// `total_commits` (= number of WAL frames), which is the exclusive upper
13040    /// bound for valid commit indices.
13041    ///
13042    /// ## Derived edges
13043    ///
13044    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13045    /// WAL markers written by `log_then_apply_with` after each rule-firing
13046    /// mutation. The `rule` field of those events carries the rule name.
13047    ///
13048    /// ## DeleteNode
13049    ///
13050    /// When a node is deleted, its manual incident edges are swept inline without
13051    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
13052    /// events for either endpoint and synthesises `Retracted(rule:None)` events
13053    /// for each manual edge that was active at that point. Derived edges active at
13054    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
13055    /// the engine appends immediately after the `DeleteNode` record; those events
13056    /// carry correct rule attribution and are emitted by the marker arm, not the
13057    /// synthetic sweep.
13058    ///
13059    /// ## Masks
13060    ///
13061    /// Like `node_history`, this method has no mask parameter and returns WAL
13062    /// history regardless of any role mask. For masked history semantics, apply
13063    /// the mask at the caller level.
13064    pub fn edge_history(
13065        &self,
13066        a: &str,
13067        b: &str,
13068    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
13069        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
13070        use core_storage::wal::WalRecord;
13071
13072        // The floor that belongs with these frames: both are read from disk
13073        // here. A handle that has not refreshed since another process moved
13074        // the floor would otherwise number new frames from its old one (row 81).
13075        let floor = self.fs.read_horizon_floor()?;
13076        let (frames, _) = self.all_frames()?;
13077        let total_commits = floor + frames.len() as u64;
13078
13079        // Resolve all historical names for a and b (handles RenameNode in the WAL).
13080        // Intervals are commit-bounded so recycled keys don't contaminate histories.
13081        let alias_a = self.build_key_alias_intervals(&frames, a, floor);
13082        let alias_b = self.build_key_alias_intervals(&frames, b, floor);
13083
13084        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
13085        // The is_derived flag is used by the DeleteNode sweep: manual edges are
13086        // swept with a synthetic Retracted(rule:None); derived edges are skipped
13087        // because the engine writes a DerivedEdgeRetracted marker immediately after
13088        // the DeleteNode record, which carries the correct rule attribution.
13089        let mut active: Vec<(String, String, String, bool)> = Vec::new();
13090        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
13091
13092        for (local_i, frame) in frames.iter().enumerate() {
13093            let commit = floor + local_i as u64;
13094            let records: &[WalRecord] = match frame {
13095                WalRecord::Batch(inner) => inner.as_slice(),
13096                single => std::slice::from_ref(single),
13097            };
13098
13099            for rec in records {
13100                match rec {
13101                    WalRecord::InsertEdge {
13102                        edge_type,
13103                        src_key,
13104                        dst_key,
13105                    } => {
13106                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13107                            && Self::aliases_match(&alias_b, dst_key, commit);
13108                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13109                            && Self::aliases_match(&alias_a, dst_key, commit);
13110                        if is_ab || is_ba {
13111                            active.push((
13112                                edge_type.clone(),
13113                                src_key.clone(),
13114                                dst_key.clone(),
13115                                false,
13116                            ));
13117                            out.push(EdgeHistoryEvent {
13118                                edge_type: edge_type.clone(),
13119                                commit,
13120                                event: EdgeEvent::Added,
13121                                rule: None,
13122                            });
13123                        }
13124                    }
13125                    WalRecord::InsertEdgeId { etype, src, dst } => {
13126                        let etype_str = match self.syms.resolve(*etype) {
13127                            Some(s) => s.to_string(),
13128                            None => continue,
13129                        };
13130                        // Use key_of_historical so tombstoned nodes (deleted
13131                        // later in the WAL) still resolve during the scan.
13132                        let src_key = self.ids.key_of_historical(*src);
13133                        let dst_key = self.ids.key_of_historical(*dst);
13134                        let is_ab = src_key == Some(a) && dst_key == Some(b);
13135                        let is_ba = src_key == Some(b) && dst_key == Some(a);
13136                        if is_ab || is_ba {
13137                            let src_str = src_key.unwrap().to_string();
13138                            let dst_str = dst_key.unwrap().to_string();
13139                            active.push((etype_str.clone(), src_str, dst_str, false));
13140                            out.push(EdgeHistoryEvent {
13141                                edge_type: etype_str,
13142                                commit,
13143                                event: EdgeEvent::Added,
13144                                rule: None,
13145                            });
13146                        }
13147                    }
13148                    WalRecord::DeleteEdge {
13149                        edge_type,
13150                        src_key,
13151                        dst_key,
13152                    } => {
13153                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13154                            && Self::aliases_match(&alias_b, dst_key, commit);
13155                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13156                            && Self::aliases_match(&alias_a, dst_key, commit);
13157                        if is_ab || is_ba {
13158                            // Remove the first matching active entry (flag ignored).
13159                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
13160                                et == edge_type && s == src_key && d == dst_key
13161                            }) {
13162                                active.remove(pos);
13163                            }
13164                            out.push(EdgeHistoryEvent {
13165                                edge_type: edge_type.clone(),
13166                                commit,
13167                                event: EdgeEvent::Retracted,
13168                                rule: None,
13169                            });
13170                        }
13171                    }
13172                    WalRecord::DeleteNode { key: k }
13173                        if Self::aliases_match(&alias_a, k, commit)
13174                            || Self::aliases_match(&alias_b, k, commit) =>
13175                    {
13176                        // Sweep: implicitly retract only MANUAL active edges.
13177                        // Derived active edges are skipped here because the rule
13178                        // engine appends a DerivedEdgeRetracted marker immediately
13179                        // after this DeleteNode record; that marker produces the
13180                        // single correctly-attributed Retracted event.  Derived
13181                        // entries are dropped from `active` (the marker arm's
13182                        // idempotent retain finds nothing to remove).
13183                        for (et, _, _, is_derived) in active.drain(..) {
13184                            if !is_derived {
13185                                out.push(EdgeHistoryEvent {
13186                                    edge_type: et,
13187                                    commit,
13188                                    event: EdgeEvent::Retracted,
13189                                    rule: None,
13190                                });
13191                            }
13192                            // Derived: drop silently; marker carries the Retracted event.
13193                        }
13194                    }
13195                    WalRecord::DerivedEdgeAdded {
13196                        rule,
13197                        edge_type: et,
13198                        src_key,
13199                        dst_key,
13200                    } => {
13201                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13202                            && Self::aliases_match(&alias_b, dst_key, commit);
13203                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13204                            && Self::aliases_match(&alias_a, dst_key, commit);
13205                        if is_ab || is_ba {
13206                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
13207                            out.push(EdgeHistoryEvent {
13208                                edge_type: et.clone(),
13209                                commit,
13210                                event: EdgeEvent::Added,
13211                                rule: Some(rule.clone()),
13212                            });
13213                        }
13214                    }
13215                    WalRecord::DerivedEdgeRetracted {
13216                        rule,
13217                        edge_type: et,
13218                        src_key,
13219                        dst_key,
13220                    } => {
13221                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13222                            && Self::aliases_match(&alias_b, dst_key, commit);
13223                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13224                            && Self::aliases_match(&alias_a, dst_key, commit);
13225                        if is_ab || is_ba {
13226                            // Push unconditionally: a derived edge whose Added marker
13227                            // predates the history horizon has no `active` entry, but
13228                            // the retraction is still a real in-window event.
13229                            // Remove from active idempotently if present.
13230                            active.retain(|(aet, s, d, _)| {
13231                                !(aet == et && s == src_key && d == dst_key)
13232                            });
13233                            out.push(EdgeHistoryEvent {
13234                                edge_type: et.clone(),
13235                                commit,
13236                                event: EdgeEvent::Retracted,
13237                                rule: Some(rule.clone()),
13238                            });
13239                        }
13240                    }
13241                    // All other records (InsertNode, SetProp, CreateRule, etc.)
13242                    // do not affect edges between a and b.
13243                    _ => {}
13244                }
13245            }
13246        }
13247
13248        Ok(HistoryResult {
13249            items: out,
13250            total_commits,
13251            horizon: floor,
13252        })
13253    }
13254
13255    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
13256    /// (in either direction) at the WAL commit `at_commit`.
13257    ///
13258    /// ## Horizon
13259    ///
13260    /// Valid commit indices are `0..total_commits` where `total_commits` is the
13261    /// number of WAL frames. An `at_commit >= total_commits` is outside the
13262    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
13263    ///
13264    /// ## Derived edges
13265    ///
13266    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13267    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
13268    /// and therefore includes derived edges in its point-in-time evaluation,
13269    /// matching `edge_history`'s fidelity.
13270    ///
13271    /// ## Which nodes
13272    ///
13273    /// `a` and `b` name the nodes that hold those keys now, and the answer
13274    /// follows each of them back through its renames. A node that held one of
13275    /// the keys earlier and was deleted is another node: for a commit at
13276    /// which that earlier node was linked, the answer is `false`.
13277    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
13278        use core_storage::wal::WalRecord;
13279
13280        // The floor that belongs with these frames: both are read from disk
13281        // here. A handle that has not refreshed since another process moved
13282        // the floor would otherwise number new frames from its old one (row 81).
13283        let floor = self.fs.read_horizon_floor()?;
13284        let (frames, _) = self.all_frames()?;
13285        let total_commits = floor + frames.len() as u64;
13286
13287        // Horizon floor: commits in pruned archives are unreachable.
13288        if at_commit < floor {
13289            return Err(GraphError::CommitOutOfRange {
13290                commit: at_commit,
13291                total: total_commits,
13292                floor,
13293            });
13294        }
13295        if at_commit >= total_commits {
13296            return Err(GraphError::CommitOutOfRange {
13297                commit: at_commit,
13298                total: total_commits,
13299                floor,
13300            });
13301        }
13302
13303        // Resolve all historical names for a and b (handles RenameNode in the WAL).
13304        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
13305        let alias_a = self.key_alias_intervals(&frames, a, floor, true);
13306        let alias_b = self.key_alias_intervals(&frames, b, floor, true);
13307        // The two nodes themselves, when both are live. A record that names
13308        // its endpoints by dense id is then matched by id: a retired id keeps
13309        // the key it last had, and matching on that string took an earlier,
13310        // deleted holder of the key for the node that has it now.
13311        let live_ids = self.ids.get(a).zip(self.ids.get(b));
13312
13313        // Local index into surviving frames (0 = first frame of oldest archive).
13314        let local_commit = at_commit - floor;
13315
13316        // Replay local frames 0..=local_commit, tracking active edges between
13317        // the two nodes: the edge type and whether it runs from `a` to `b`.
13318        // Tracked by which node, not by the key each record names it with: an
13319        // edge written while a node was called one thing and deleted while it
13320        // was called another is the same edge.
13321        let mut active: BTreeSet<(String, bool)> = BTreeSet::new();
13322
13323        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13324            let commit = floor + local_i as u64;
13325            let records: &[WalRecord] = match frame {
13326                WalRecord::Batch(inner) => inner.as_slice(),
13327                single => std::slice::from_ref(single),
13328            };
13329
13330            for rec in records {
13331                match rec {
13332                    WalRecord::InsertEdge {
13333                        edge_type: et,
13334                        src_key,
13335                        dst_key,
13336                    } => {
13337                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13338                            && Self::aliases_match(&alias_b, dst_key, commit);
13339                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13340                            && Self::aliases_match(&alias_a, dst_key, commit);
13341                        if is_ab {
13342                            active.insert((et.clone(), true));
13343                        }
13344                        if is_ba {
13345                            active.insert((et.clone(), false));
13346                        }
13347                    }
13348                    WalRecord::InsertEdgeId { etype, src, dst } => {
13349                        let etype_str = match self.syms.resolve(*etype) {
13350                            Some(s) => s.to_string(),
13351                            None => continue,
13352                        };
13353                        // Use key_of_historical so tombstoned nodes resolve.
13354                        let src_key = self.ids.key_of_historical(*src);
13355                        let dst_key = self.ids.key_of_historical(*dst);
13356                        let (is_ab, is_ba) = match live_ids {
13357                            Some((id_a, id_b)) => {
13358                                (*src == id_a && *dst == id_b, *src == id_b && *dst == id_a)
13359                            }
13360                            None => (
13361                                src_key == Some(a) && dst_key == Some(b),
13362                                src_key == Some(b) && dst_key == Some(a),
13363                            ),
13364                        };
13365                        if is_ab {
13366                            active.insert((etype_str.clone(), true));
13367                        }
13368                        if is_ba {
13369                            active.insert((etype_str, false));
13370                        }
13371                    }
13372                    WalRecord::DeleteEdge {
13373                        edge_type: et,
13374                        src_key,
13375                        dst_key,
13376                    } => {
13377                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13378                            && Self::aliases_match(&alias_b, dst_key, commit);
13379                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13380                            && Self::aliases_match(&alias_a, dst_key, commit);
13381                        if is_ab {
13382                            active.remove(&(et.clone(), true));
13383                        }
13384                        if is_ba {
13385                            active.remove(&(et.clone(), false));
13386                        }
13387                    }
13388                    WalRecord::DeleteNode { key: k }
13389                        if Self::aliases_match(&alias_a, k, commit)
13390                            || Self::aliases_match(&alias_b, k, commit) =>
13391                    {
13392                        // One of the two nodes is gone, and every edge
13393                        // between them with it.
13394                        active.clear();
13395                    }
13396                    WalRecord::DerivedEdgeAdded {
13397                        edge_type: et,
13398                        src_key,
13399                        dst_key,
13400                        ..
13401                    } => {
13402                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13403                            && Self::aliases_match(&alias_b, dst_key, commit);
13404                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13405                            && Self::aliases_match(&alias_a, dst_key, commit);
13406                        if is_ab {
13407                            active.insert((et.clone(), true));
13408                        }
13409                        if is_ba {
13410                            active.insert((et.clone(), false));
13411                        }
13412                    }
13413                    WalRecord::DerivedEdgeRetracted {
13414                        edge_type: et,
13415                        src_key,
13416                        dst_key,
13417                        ..
13418                    } => {
13419                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13420                            && Self::aliases_match(&alias_b, dst_key, commit);
13421                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13422                            && Self::aliases_match(&alias_a, dst_key, commit);
13423                        if is_ab {
13424                            active.remove(&(et.clone(), true));
13425                        }
13426                        if is_ba {
13427                            active.remove(&(et.clone(), false));
13428                        }
13429                    }
13430                    _ => {}
13431                }
13432            }
13433        }
13434
13435        Ok(active.iter().any(|(et, _)| et == edge_type))
13436    }
13437
13438    /// Every edge incident to `key` — either endpoint — that existed at WAL
13439    /// commit `commit`, from ONE scan of the WAL.
13440    ///
13441    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
13442    /// "what did K's relationships look like at commit C" with one call instead
13443    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
13444    /// The two agree edge for edge.
13445    ///
13446    /// Results are sorted by `(edge_type, src_key, dst_key)`.
13447    ///
13448    /// ## Horizon
13449    ///
13450    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
13451    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
13452    /// `was_linked`. An unknown key is not an error — it simply had no edges.
13453    ///
13454    /// ## Derived edges
13455    ///
13456    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
13457    /// attribution, so a rule-owned edge comes back with `derived: true` and
13458    /// `rule: Some(name)`.
13459    ///
13460    /// ## Renames
13461    ///
13462    /// `key` is matched through the same commit-bounded alias intervals
13463    /// `edge_history` uses, so querying a node's *current* key surfaces edges
13464    /// written under an earlier name. Endpoint keys in the result are reported
13465    /// under the name the node carries today, so they can be fed straight back
13466    /// into `node_info`, `explain` or another `edges_at`.
13467    ///
13468    /// ## Masks
13469    ///
13470    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
13471    /// any role mask. Apply masking at the caller level.
13472    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
13473        use core_storage::wal::WalRecord;
13474
13475        // Floor and frames from the same read of the disk (row 81).
13476        let floor = self.fs.read_horizon_floor()?;
13477        let (frames, _) = self.all_frames()?;
13478        let total_commits = floor + frames.len() as u64;
13479
13480        // Horizon floor: commits in pruned archives are unreachable.
13481        if commit < floor || commit >= total_commits {
13482            return Err(GraphError::CommitOutOfRange {
13483                commit,
13484                total: total_commits,
13485                floor,
13486            });
13487        }
13488
13489        // Commit-bounded historical names of `key` (handles RenameNode).
13490        let alias = self.build_key_alias_intervals(&frames, key, floor);
13491
13492        // Forward rename chain, for reporting endpoints under their current
13493        // names: old key → [(commit, new key)] in ascending commit order.
13494        // Built over the whole WAL, not just the prefix up to `commit`, because
13495        // a rename after `commit` still changes what the node is called today.
13496        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
13497        for (local_i, frame) in frames.iter().enumerate() {
13498            let c = floor + local_i as u64;
13499            let records: &[WalRecord] = match frame {
13500                WalRecord::Batch(inner) => inner.as_slice(),
13501                single => std::slice::from_ref(single),
13502            };
13503            for rec in records {
13504                if let WalRecord::RenameNode { old_key, new_key } = rec {
13505                    renames
13506                        .entry(old_key.clone())
13507                        .or_default()
13508                        .push((c, new_key.clone()));
13509                }
13510            }
13511        }
13512
13513        // The name a node written as `k` at commit `from` carries today.
13514        // Follows the first rename at or after `from`, then keeps going. The
13515        // iteration cap bounds a rename cycle inside a single batch.
13516        let canon = |k: &str, from: u64| -> String {
13517            if renames.is_empty() {
13518                return k.to_string();
13519            }
13520            let mut cur = k.to_string();
13521            let mut at = from;
13522            for _ in 0..64 {
13523                match renames
13524                    .get(&cur)
13525                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
13526                {
13527                    Some((c, new)) => {
13528                        at = *c;
13529                        cur = new.clone();
13530                    }
13531                    None => break,
13532                }
13533            }
13534            cur
13535        };
13536
13537        let local_commit = commit - floor;
13538        // (edge_type, src_key, dst_key) → (derived, rule)
13539        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
13540            BTreeMap::new();
13541
13542        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13543            let c = floor + local_i as u64;
13544            let records: &[WalRecord] = match frame {
13545                WalRecord::Batch(inner) => inner.as_slice(),
13546                single => std::slice::from_ref(single),
13547            };
13548
13549            for rec in records {
13550                match rec {
13551                    WalRecord::InsertEdge {
13552                        edge_type,
13553                        src_key,
13554                        dst_key,
13555                    } => {
13556                        if Self::aliases_match(&alias, src_key, c)
13557                            || Self::aliases_match(&alias, dst_key, c)
13558                        {
13559                            active.insert(
13560                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
13561                                (false, None),
13562                            );
13563                        }
13564                    }
13565                    WalRecord::InsertEdgeId { etype, src, dst } => {
13566                        let Some(etype_str) = self.syms.resolve(*etype) else {
13567                            continue;
13568                        };
13569                        // `key_of_historical` resolves tombstoned ids too, and
13570                        // already returns the node's current key — no rename
13571                        // canonicalisation needed on this arm.
13572                        let (Some(src_key), Some(dst_key)) = (
13573                            self.ids.key_of_historical(*src),
13574                            self.ids.key_of_historical(*dst),
13575                        ) else {
13576                            continue;
13577                        };
13578                        if src_key == key || dst_key == key {
13579                            active.insert(
13580                                (
13581                                    etype_str.to_string(),
13582                                    src_key.to_string(),
13583                                    dst_key.to_string(),
13584                                ),
13585                                (false, None),
13586                            );
13587                        }
13588                    }
13589                    WalRecord::DeleteEdge {
13590                        edge_type,
13591                        src_key,
13592                        dst_key,
13593                    } => {
13594                        if Self::aliases_match(&alias, src_key, c)
13595                            || Self::aliases_match(&alias, dst_key, c)
13596                        {
13597                            active.remove(&(
13598                                edge_type.clone(),
13599                                canon(src_key, c),
13600                                canon(dst_key, c),
13601                            ));
13602                        }
13603                    }
13604                    WalRecord::DeleteNode { key: k } => {
13605                        if active.is_empty() {
13606                            continue;
13607                        }
13608                        if Self::aliases_match(&alias, k, c) {
13609                            // Our node is gone; every incident edge goes with it.
13610                            active.clear();
13611                        } else {
13612                            // A partner is gone; its edges to us go with it.
13613                            let ck = canon(k, c);
13614                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
13615                        }
13616                    }
13617                    WalRecord::DerivedEdgeAdded {
13618                        rule,
13619                        edge_type,
13620                        src_key,
13621                        dst_key,
13622                    } => {
13623                        if Self::aliases_match(&alias, src_key, c)
13624                            || Self::aliases_match(&alias, dst_key, c)
13625                        {
13626                            active.insert(
13627                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
13628                                (true, Some(rule.clone())),
13629                            );
13630                        }
13631                    }
13632                    WalRecord::DerivedEdgeRetracted {
13633                        edge_type,
13634                        src_key,
13635                        dst_key,
13636                        ..
13637                    } => {
13638                        if Self::aliases_match(&alias, src_key, c)
13639                            || Self::aliases_match(&alias, dst_key, c)
13640                        {
13641                            active.remove(&(
13642                                edge_type.clone(),
13643                                canon(src_key, c),
13644                                canon(dst_key, c),
13645                            ));
13646                        }
13647                    }
13648                    // InsertNode, SetProp, CreateRule, … do not move edges.
13649                    _ => {}
13650                }
13651            }
13652        }
13653
13654        // BTreeMap iteration is already (edge_type, src, dst) order.
13655        Ok(active
13656            .into_iter()
13657            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
13658                edge_type,
13659                src_key,
13660                dst_key,
13661                derived,
13662                rule,
13663            })
13664            .collect())
13665    }
13666
13667    /// The derived edges that would be retracted and derived if `key.field`
13668    /// were set to `value` — computed WITHOUT writing anything.
13669    ///
13670    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
13671    /// provenance, its candidate indexes, the topology and the property columns
13672    /// are all cloned first, the change is applied to the clone, and the real
13673    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
13674    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
13675    /// it emits are the answer, so rule semantics — predicates, top-k,
13676    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
13677    ///
13678    /// Works on a read-only handle.
13679    ///
13680    /// **While a rule's vector index is still building** (`RuleStats::building`)
13681    /// the clone carries no pending-build state, so this reports the edges that
13682    /// rule would derive — which the live store will not derive until its
13683    /// backfill runs. Right about the end state, early about the timing.
13684    ///
13685    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
13686    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
13687    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
13688    /// (the node already holds `value`, or no rule watches `field`) returns
13689    /// empty lists.
13690    ///
13691    /// ## Cost
13692    ///
13693    /// One clone of the property columns, the topology overlay, the symbol
13694    /// interner, the edge properties and the provenance map, plus one candidate
13695    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
13696    /// directory, but it is not free — this is an interactive "what if", not a
13697    /// hot path.
13698    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
13699        // The engine's provenance, HNSW and IVF state live in the mmap'd base
13700        // until something asks for them. On a store opened cold from a snapshot
13701        // this is the first ask, and without it the clone below starts from an
13702        // empty provenance map: nothing to retract, so `lost` comes back empty.
13703        self.ensure_v8_base_sections_loaded();
13704
13705        let empty = WhatIf {
13706            lost: Vec::new(),
13707            gained: Vec::new(),
13708        };
13709
13710        if let Some(view_name) = self.view_store.view_for_prop(field) {
13711            return Err(GraphError::ViewPropReadOnly {
13712                view_name: view_name.to_string(),
13713            });
13714        }
13715        MutPreview::new(self).check_live_key(key)?;
13716        let id = self
13717            .ids
13718            .get(key)
13719            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
13720
13721        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
13722        if rules.is_empty() {
13723            return Ok(empty);
13724        }
13725
13726        // No rule watches this field → no derivation can change.
13727        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
13728            return Ok(empty);
13729        }
13730
13731        let old_value = build_props_view(&self.props, &self.base)
13732            .get(id, field)
13733            .map(|vr| vr.into_value());
13734        if old_value.as_ref() == Some(&value) {
13735            return Ok(empty);
13736        }
13737
13738        // --- Clone every piece of state the re-derivation writes to. ---
13739        // `what_if` mutates its own throwaway copies and writes nothing, so it
13740        // takes owned clones rather than sharing the `Arc`s. Deref-clone, not
13741        // `Arc::clone`: sharing here would make `make_mut` copy on first touch
13742        // anyway, and an owned local keeps the rest of this function unchanged.
13743        let mut props = (*self.props).clone();
13744        let mut topo = (*self.topo).clone();
13745        let mut syms = (*self.syms).clone();
13746        let mut edge_props = (*self.edge_props).clone();
13747
13748        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
13749        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
13750        for r in &rules {
13751            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
13752            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
13753        }
13754        // `provenance()` decodes retained snapshot bytes on first use; the
13755        // engine clone needs the real map, not an empty one.
13756        let provenance = self.engine.provenance().clone();
13757        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
13758
13759        // Build the candidate indexes from the state BEFORE the change, exactly
13760        // as apply() sees them: `on_node_changed` withdraws the node under its
13761        // old value and refiles it under the new one, so the index must not
13762        // already reflect the change.
13763        engine.reindex_all_load_state(
13764            &self.ids,
13765            &syms,
13766            &self.labels,
13767            build_props_view(&self.props, &self.base),
13768            self.engine.export_ivf_state(),
13769            self.engine.export_hnsw_state_passthrough(),
13770        );
13771        engine.set_emit_deltas(true);
13772
13773        // --- Apply the hypothetical change and re-derive. ---
13774        props.set(id, field, value);
13775        {
13776            let mut gm = make_graph_mut(
13777                &self.ids,
13778                &mut syms,
13779                &self.labels,
13780                build_props_view(&props, &self.base),
13781                &mut topo,
13782                &self.base,
13783                &mut edge_props,
13784            );
13785            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
13786        }
13787
13788        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
13789        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
13790        for d in engine.drain_deltas() {
13791            let edge = EdgeAt {
13792                edge_type: d.edge_type,
13793                src_key: d.src_key,
13794                dst_key: d.dst_key,
13795                derived: true,
13796                rule: Some(d.rule),
13797            };
13798            if d.fired {
13799                gained.insert(edge);
13800            } else {
13801                lost.insert(edge);
13802            }
13803        }
13804        // An edge retracted and re-derived within the same re-derivation (top-k
13805        // churn) is not a change the caller would see.
13806        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
13807        for e in churn {
13808            lost.remove(&e);
13809            gained.remove(&e);
13810        }
13811
13812        Ok(WhatIf {
13813            lost: lost.into_iter().collect(),
13814            gained: gained.into_iter().collect(),
13815        })
13816    }
13817
13818    pub fn edge_count(&self) -> u64 {
13819        self.topo_view().edge_count()
13820    }
13821
13822    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
13823    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
13824    pub fn stats(&self) -> Stats {
13825        self.ensure_v8_base_sections_loaded();
13826        let building = self.engine.builds_in_progress();
13827        let rules: Vec<RuleStats> = self
13828            .engine
13829            .rules()
13830            .map(|r| RuleStats {
13831                name: r.name.clone(),
13832                edges: self
13833                    .engine
13834                    .provenance()
13835                    .get(&r.name)
13836                    .map(|s| s.len() as u64)
13837                    .unwrap_or(0),
13838                tripped: self.engine.is_tripped(&r.name),
13839                fires: self.engine.fire_count(&r.name),
13840                approximate: r.approximate,
13841                building: building.iter().find(|b| b.rule == r.name).cloned(),
13842            })
13843            .collect();
13844        Stats {
13845            nodes_live: self.ids.live_len(),
13846            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
13847            edges: self.topo_view().edge_count(),
13848            rules,
13849            chain_truncations: self.engine.chain_truncations(),
13850            history_floor: self.wal_horizon_floor,
13851            namespaces: self.namespace_stats(),
13852        }
13853    }
13854
13855    /// On-disk size of the WAL file in bytes.
13856    ///
13857    /// Reads file metadata without loading WAL contents.  Returns `Err` for
13858    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
13859    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
13860        let path = self.fs.wal_path().ok_or_else(|| {
13861            std::io::Error::new(
13862                std::io::ErrorKind::Unsupported,
13863                "wal_path not available for this Fs implementation",
13864            )
13865        })?;
13866        Ok(std::fs::metadata(path)?.len())
13867    }
13868
13869    /// Set the slow-query threshold.  Queries whose execution time equals or
13870    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
13871    ///
13872    /// Use this setter in tests — the environment variable
13873    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
13874    /// threads.
13875    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
13876        self.slow_query_threshold_ms = ms;
13877    }
13878
13879    /// Snapshot of the slow-query ring buffer and lifetime counter.
13880    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
13881        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
13882        SlowQuerySnapshot {
13883            threshold_ms: self.slow_query_threshold_ms,
13884            count: log.total,
13885            last: log.entries.iter().cloned().collect(),
13886        }
13887    }
13888
13889    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
13890    /// to compute uptime.
13891    pub fn started_at(&self) -> std::time::Instant {
13892        self.started_at
13893    }
13894
13895    /// The on-disk snapshot version a store that has opted in to nothing
13896    /// writes — the **floor**, not the whole answer.
13897    ///
13898    /// It is not "the version this binary writes", and it is not "the version
13899    /// this binary reads". Since v0.6.10 this binary writes 9 **or** 10
13900    /// depending on the store — [`snapshot::version_for`] decides, and a store
13901    /// that has called [`enable_multiplicity`](Self::enable_multiplicity)
13902    /// writes 10 — and it reads 5 through 10. A caller comparing a store's
13903    /// stamp against this value must use `>=`, not `==`, or it will report an
13904    /// opted-in store as needing a migration *down*; `cli::run_migrate` is the
13905    /// worked example.
13906    ///
13907    /// The name is kept for compatibility: it is public API reachable from the
13908    /// CLI and from any embedder, and respelling it would break them for a
13909    /// doc-level clarification.
13910    ///
13911    /// [`snapshot::version_for`]: core_storage::snapshot::version_for
13912    pub fn format_version() -> u16 {
13913        core_storage::snapshot::VERSION
13914    }
13915
13916    /// Test-support: total bytes appended (SimFs only usage).
13917    pub fn fs_total_appended(&self) -> usize
13918    where
13919        F: FsIntrospect,
13920    {
13921        self.fs.total_appended()
13922    }
13923
13924    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
13925    pub fn fs_sync_count(&self) -> usize
13926    where
13927        F: FsIntrospect,
13928    {
13929        self.fs.sync_count()
13930    }
13931
13932    /// Consume the db, returning its fs (for crash simulation).
13933    pub fn into_fs(self) -> F {
13934        self.fs
13935    }
13936
13937    pub fn snapshot(&mut self) -> Result<()> {
13938        self.snapshot_with(SnapshotOptions::default())
13939    }
13940
13941    /// Snapshot with explicit options.
13942    ///
13943    /// # `keep_wal`
13944    ///
13945    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
13946    ///   - The WAL is replaced with a minimal baseline containing one
13947    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
13948    ///     history is discarded; `open_at` can only reach post-snapshot commits.
13949    ///
13950    /// When `keep_wal` is `true`:
13951    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
13952    ///     via `open_at`.  The existing WAL already contains the original
13953    ///     `EnableFulltext` records, so no baseline re-write is needed; the
13954    ///     recovery guards in `apply()` silently skip any duplicate records on
13955    ///     replay.
13956    ///   - Crash window: a crash after the snapshot write but before the next
13957    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
13958    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
13959    ///     because every `apply()` arm is idempotent when replayed over an
13960    ///     already-current snapshot.
13961    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
13962        if self.read_only {
13963            return Err(GraphError::ReadOnly);
13964        }
13965        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
13966        // appending ends up holding a descriptor on an unlinked inode and loses
13967        // commits it believes durable. Snapshotting therefore requires the
13968        // cross-process write lock, exactly as appending does. Unlike the WAL
13969        // append path this does not go through `log_then_apply_with`, so both
13970        // guards are repeated here.
13971        if self.degraded {
13972            return Err(GraphError::Io(std::io::Error::other(
13973                "database degraded after group-commit fsync failure; reopen required",
13974            )));
13975        }
13976        if self.lock_denied {
13977            return Err(GraphError::Busy { holder: None });
13978        }
13979        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
13980        // Used by the archive path's conservative genesis-chain check: if a prior
13981        // snapshot exists but wal.truncated does not, we cannot distinguish a
13982        // legacy store (may have been truncated in an older code version) from a
13983        // new store that only used keep_wal=true.  Conservative: refuse genesis in
13984        // both cases.  Must be sampled here, before the snapshot write below.
13985        //
13986        // `snapshot_preserved_history` is the one case where the answer is not a
13987        // guess: a snapshot *this handle* took, on a store that had none when it
13988        // opened, and that kept the WAL. The proxy defers to it, because
13989        // otherwise `enable_multiplicity` — whose forced snapshot is exactly
13990        // that — would permanently disqualify the store from a genesis chain it
13991        // is fully entitled to (defect #23).
13992        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false)
13993            && !self.snapshot_preserved_history;
13994        // Which version this store writes. V9 unless it has opted in to
13995        // multiplicity, in which case V10 — the stamp that makes a reader which
13996        // does not know WAL discriminant 23 refuse the open instead of
13997        // truncating the WAL at the first such frame. The container is
13998        // identical either way; only these two header bytes move.
13999        let snapshot_version = core_storage::snapshot::version_for(self.multiplicity);
14000        // A snapshot that leaves the WAL where it is records how much of it
14001        // it holds, so the next open does not apply those writes again on top
14002        // of the state they produced. This handle holds the write lock, so
14003        // every byte in the WAL now is a write it has applied.
14004        let wal_mark = if opts.keep_wal && !opts.archive_wal {
14005            core_storage::v8::WalMark::of(&self.fs.read(FileId::Wal)?).encode()
14006        } else {
14007            Vec::new()
14008        };
14009        self.ensure_v8_base_sections_loaded();
14010        // Ensure provenance is decoded before to_persist() clones it.
14011        self.engine.ensure_provenance_loaded_mut();
14012        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
14013        let rule_defs = rule_defs_typed
14014            .iter()
14015            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
14016            .collect();
14017        // Collect HNSW state and IVF state.  When indexes are not yet
14018        // populated (clean open, no mutation since open), pass the retained
14019        // raw bytes through directly so that migrate/snapshot does not
14020        // silently discard fitted approximate-rule indexes.
14021        let hnsw_state = self.engine.export_hnsw_state_passthrough();
14022        let ivf_bytes = if !self.engine.indexes_populated() {
14023            // Pass retained IVF bytes through unchanged (no re-encode).
14024            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
14025        } else {
14026            // Indexes live: encode from current state.
14027            let raw_ivf = self.engine.export_ivf_state();
14028            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
14029                .into_iter()
14030                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
14031                    (
14032                        name,
14033                        core_storage::snapshot::PerRuleIvfState {
14034                            src: core_storage::snapshot::SideIvfState {
14035                                centroids: sc,
14036                                clusters: sa,
14037                                drift: sd,
14038                            },
14039                            dst: core_storage::snapshot::SideIvfState {
14040                                centroids: dc,
14041                                clusters: da,
14042                                drift: dd,
14043                            },
14044                        },
14045                    )
14046                })
14047                .collect();
14048            if ivf_state_map.is_empty() {
14049                Vec::new()
14050            } else {
14051                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
14052            }
14053        };
14054        let view_defs: Vec<Vec<u8>> = self
14055            .view_store
14056            .views()
14057            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
14058            .collect();
14059        if self.base.is_some() {
14060            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
14061            // write it atomically, remap it as the new base, then clear the overlay.
14062            let meta = V8Meta {
14063                labels: (*self.labels).clone(),
14064                edge_props: (*self.edge_props).clone(),
14065                rule_defs,
14066                provenance,
14067                rule_tripped,
14068                rule_fires,
14069                ivf_bytes,
14070                view_defs,
14071                wal_truncated: !opts.keep_wal,
14072                hnsw: hnsw_state,
14073                last_change: self.last_change.clone(),
14074                wal_mark: wal_mark.clone(),
14075            };
14076            let mut buf: Vec<u8> = Vec::new();
14077            {
14078                // Clone the Arc so the old base stays alive while we encode.
14079                // The borrow of archived_csr (into old_base's mmap) is released
14080                // at the end of this block, before we replace self.base.
14081                let old_base = self.base.clone().expect("is_some checked above");
14082                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
14083                    detail: format!("v8 snapshot: topology section: {e:?}"),
14084                })?;
14085                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
14086                    detail: format!("v8 snapshot: columns section: {e:?}"),
14087                })?;
14088                // `None` when the base predates V9 — the migration path: its
14089                // string columns still carry their own tables and this snapshot
14090                // is the rewrite that collapses them into section 12.
14091                let archived_strings =
14092                    old_base
14093                        .string_table()
14094                        .transpose()
14095                        .map_err(|e| GraphError::Corrupt {
14096                            detail: format!("v8 snapshot: strings section: {e:?}"),
14097                        })?;
14098                let archived_edge_props =
14099                    old_base
14100                        .edge_props_section()
14101                        .map_err(|e| GraphError::Corrupt {
14102                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
14103                        })?;
14104                let edge_props_raw =
14105                    old_base
14106                        .edge_props_raw_bytes()
14107                        .map_err(|e| GraphError::Corrupt {
14108                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
14109                        })?;
14110                encode_v8(
14111                    Some(archived_csr),
14112                    Some(archived_cols),
14113                    archived_strings,
14114                    Some((archived_edge_props, edge_props_raw)),
14115                    // Never the old snapshot's provenance bytes. The encoder
14116                    // passes them through when the live map is empty, taking
14117                    // empty for "never loaded" — but it is loaded above, so
14118                    // empty means every rule's edges are gone, and passing
14119                    // the old section through brought them back as owned by
14120                    // rules that no longer derive them (ledger row 95).
14121                    None,
14122                    &self.topo,
14123                    &self.props,
14124                    &self.ids,
14125                    &self.syms,
14126                    &meta,
14127                    &mut buf,
14128                )?;
14129            }
14130            core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14131            self.fs.write_atomic(FileId::Snapshot, &buf)?;
14132            // Remap the freshly-written snapshot as the new base.
14133            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
14134            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14135                core_storage::v8::MappedBase::map(&snap_path)
14136            } else {
14137                core_storage::v8::MappedBase::from_bytes(buf)
14138            }
14139            .map_err(|e| GraphError::Corrupt {
14140                detail: format!("v8 snapshot: remap new base: {e:?}"),
14141            })?;
14142            self.base = Some(Arc::new(new_base));
14143            // Clear the overlay and prop tombstones — all data is now in the new base.
14144            self.topo = Arc::new(Topology::new());
14145            self.props = Arc::new(core_storage::columns::ColumnStore::new());
14146        } else {
14147            // Legacy path (V5–V7 stores without a V8 base).
14148            //
14149            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
14150            // clone and no encode_v8_from_state intermediate clones.  The big
14151            // structures (self.topo, self.props) are borrowed, not cloned.
14152            // self.edge_props is moved (not cloned) because we immediately clear it
14153            // when we remap the new V8 snapshot as self.base (see below).
14154            //
14155            // Eliminates from peak RSS vs. the old SnapshotState path:
14156            //   • self.topo.clone()      (~topology HashMap footprint)
14157            //   • self.props.clone()     (~column-store footprint)
14158            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
14159            let meta = V8Meta {
14160                labels: (*self.labels).clone(),
14161                wal_truncated: !opts.keep_wal,
14162                // Move edge_props out so the large overlay is freed when meta
14163                // drops at end of this block (self.edge_props is now empty; reads
14164                // after base assignment go through the mmap'd base section).
14165                edge_props: std::mem::take(Arc::make_mut(&mut self.edge_props)),
14166                rule_defs,
14167                provenance,
14168                rule_tripped,
14169                rule_fires,
14170                ivf_bytes,
14171                view_defs,
14172                hnsw: hnsw_state,
14173                last_change: self.last_change.clone(),
14174                wal_mark: wal_mark.clone(),
14175            };
14176            let mut buf = Vec::new();
14177            encode_v8(
14178                None,
14179                None,
14180                None,
14181                None,
14182                None,
14183                &self.topo,
14184                &self.props,
14185                &self.ids,
14186                &self.syms,
14187                &meta,
14188                &mut buf,
14189            )?;
14190            // meta (and the moved edge_props inside it) is no longer needed;
14191            // drop it before the write to keep the peak window narrow.
14192            drop(meta);
14193            core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14194            self.fs.write_atomic(FileId::Snapshot, &buf)?;
14195            // Remap the freshly-written V8 snapshot as self.base.
14196            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
14197            // On SimFs (tests): pass buf to from_bytes.
14198            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14199                drop(buf);
14200                core_storage::v8::MappedBase::map(&snap_path)
14201            } else {
14202                core_storage::v8::MappedBase::from_bytes(buf)
14203            }
14204            .map_err(|e| GraphError::Corrupt {
14205                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
14206            })?;
14207            self.base = Some(Arc::new(new_base));
14208            // Free the large heap-allocated decoded state — all data is now in the
14209            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
14210            // self.edge_props was already moved into meta and is effectively empty.
14211            self.topo = Arc::new(Topology::new());
14212            self.props = Arc::new(core_storage::columns::ColumnStore::new());
14213        }
14214
14215        if opts.archive_wal {
14216            // History-preserving snapshot (Task 4):
14217            //   1. Snapshot already written above (write_atomic → fsynced).
14218            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
14219            //      Crash window B: crash here leaves archive present, WAL
14220            //      absent.  Reopen: snapshot loaded (full state), no WAL
14221            //      replay.  Archive is NOT replayed into live state — it is
14222            //      pre-snapshot by construction.  Safe.
14223            //   3. Optionally write genesis marker (first archive only, no
14224            //      prior WAL truncation).
14225            //   4. Prune old archives (retention), update horizon floor.
14226            //      Pruning invalidates the genesis chain; delete marker.
14227            //   5. Write new minimal baseline WAL (write_atomic).
14228            //      Crash window C: crash here leaves new archive plus no live
14229            //      WAL.  Same as window B — handled above.
14230            //
14231            // Sample existing archives BEFORE the rename so we can detect
14232            // whether this is the first archive.
14233            let existing_archives = self.fs.list_archives()?;
14234            let is_first_archive = existing_archives.is_empty();
14235
14236            // Compute a globally-monotonic archive name: the name equals the
14237            // cumulative end-frame index of the archive in global commit space.
14238            //
14239            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
14240            // commit_seq is seeded from max(last_change), which underestimates
14241            // the WAL depth when trailing commits (e.g. insert_edge) do not
14242            // update last_change.  A session-2 archive could then receive a name
14243            // ≤ the session-1 archive, causing incorrect sort order or collision.
14244            //
14245            // Instead: read and decode the live WAL here (before the rename) to
14246            // get its exact frame count, then add it to the last known global
14247            // end-frame index (the name of the most recent existing archive, or
14248            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
14249            // snapshot is already serialising the full graph state, so the cost
14250            // is dominated.
14251            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
14252            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
14253            let archive_n = existing_archives
14254                .last()
14255                .copied()
14256                .unwrap_or(self.wal_horizon_floor)
14257                + live_frames_for_name.len() as u64;
14258            self.fs.archive_wal(archive_n)?;
14259
14260            // The replacement WAL goes in **immediately**, with no fallible call
14261            // between it and the rename above.
14262            //
14263            // The rename is what removes the store's live declarations — the
14264            // multiplicity opt-in, and every `EnableFulltext` / `EnableIndex` —
14265            // and this write is what puts them back. Every call that used to sit
14266            // in between (the genesis marker, the retention sweep's reads, the
14267            // floor write, the archive deletes) was a `?` that could leave the
14268            // store with neither, so a single transient `Err` was enough to lose
14269            // a declaration that no rebuild can recover (defect #22).
14270            //
14271            // Ordering alone cannot close the crash window between two
14272            // filesystem calls; for the multiplicity declaration the V10 stamp
14273            // does that on the open path. What ordering does close is the much
14274            // wider window in which an ordinary I/O error did it — and that half
14275            // covers all three declarations, not just the one with a stamp.
14276            let mut baseline_wal: Vec<u8> = Vec::new();
14277            // The multiplicity opt-in is a declaration like the two below it,
14278            // and it is re-emitted for the same reason: truncation must not
14279            // silently opt the store back out and stop counting.
14280            if self.multiplicity {
14281                baseline_wal
14282                    .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14283            }
14284            for (label, field) in self.fulltext.enabled_pairs() {
14285                let rec = WalRecord::EnableFulltext {
14286                    label: label.clone(),
14287                    field: field.clone(),
14288                };
14289                baseline_wal.extend_from_slice(&encode_record(&rec));
14290            }
14291            for (label, field) in self.prop_index.enabled_pairs() {
14292                let rec = WalRecord::EnableIndex {
14293                    label: label.clone(),
14294                    field: field.clone(),
14295                };
14296                baseline_wal.extend_from_slice(&encode_record(&rec));
14297            }
14298            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14299
14300            // Genesis marker: written once when the first archive is taken
14301            // from a store that has never undergone a WAL-truncating snapshot.
14302            // When present, `open_at` may replay archive-resident commits from
14303            // empty state (the archive chain covers from global index 0).
14304            //
14305            // Two conditions must ALL hold:
14306            //   1. This is the first archive (existing_archives was empty).
14307            //   2. No snapshot.bin existed before this operation (had_prior_snapshot=false).
14308            //      A WAL-truncating snapshot (keep_wal=false) always writes snapshot.bin
14309            //      before truncating the WAL, so if any prior truncating snapshot was taken
14310            //      — even in a previous session — snapshot.bin is present and this condition
14311            //      is false.  This subsumes the cross-session truncation case without
14312            //      requiring a separate wal.truncated sidecar file.
14313            //      For legacy stores (snapshot.bin written by an older code version that
14314            //      may have truncated the WAL), the same conservative refusal applies:
14315            //      we cannot prove the chain is complete, so we refuse genesis (cost =
14316            //      no as-of-through-archives; never silent wrong data).
14317            //      The one exception is a snapshot this handle took itself, on a store
14318            //      that had none when it opened, with the WAL kept: there the answer is
14319            //      known rather than guessed, and `snapshot_preserved_history` says so.
14320            //      Without that exception `enable_multiplicity`'s forced keep_wal
14321            //      snapshot would disqualify the store forever (defect #23).
14322            //      On SimFs (snapshot_path() == None) had_prior_snapshot is always false,
14323            //      so SimFs always passes this check.
14324            if is_first_archive && !had_prior_snapshot {
14325                self.fs.write_genesis_marker()?;
14326                self.archive_genesis_chain = true;
14327            }
14328
14329            // Retention pruning: keep newest `keep` archives; delete oldest.
14330            // Pruning is the ONLY deletion site for archives.
14331            //
14332            // Crash-safety ordering (C1 fix):
14333            //   1. Count frames in surplus archives (reads only — no mutation).
14334            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
14335            //      rename (atomic).  A crash after this point leaves orphaned
14336            //      archives on disk, but the floor is correct.  The opening
14337            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
14338            //      the next open, so the store is always safe to reopen.
14339            //   3. Delete the genesis marker (floor > 0 already blocks open_at
14340            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
14341            //   4. Delete surplus archives.  A crash between any two deletes
14342            //      leaves the floor committed and orphaned archives cleaned at
14343            //      next open — never a stale floor with a missing archive prefix.
14344            if let Some(keep) = self.wal_archive_retention {
14345                if keep > 0 {
14346                    let archives = self.fs.list_archives()?;
14347                    // archives is sorted ascending (oldest first)
14348                    if archives.len() as u32 > keep {
14349                        let surplus = archives.len() - keep as usize;
14350                        // Step 1: count pruned frames (reads, no mutation).
14351                        let mut pruned_frames = 0u64;
14352                        for &n in &archives[..surplus] {
14353                            let bytes = self.fs.read_archive(n)?;
14354                            let (frames, _) = decode_all(&bytes);
14355                            pruned_frames += frames.len() as u64;
14356                        }
14357                        // Step 2: advance and persist floor FIRST.
14358                        self.wal_horizon_floor += pruned_frames;
14359                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
14360                        // The time map must not outlive the commits it
14361                        // describes: an entry below the new floor would resolve
14362                        // a date to a commit the engine can no longer replay,
14363                        // which is worse than having no entry at all.
14364                        self.commit_times.truncate_below(self.wal_horizon_floor);
14365                        self.rewrite_commit_times();
14366                        // Step 3: delete genesis marker (floor > 0 already
14367                        // blocks open_at; this is belt-and-suspenders cleanup).
14368                        if pruned_frames > 0 && self.archive_genesis_chain {
14369                            self.fs.delete_genesis_marker()?;
14370                            self.archive_genesis_chain = false;
14371                        }
14372                        // Step 4: delete surplus archives.  Crash here →
14373                        // orphaned archives; cleaned at next open.
14374                        for &n in &archives[..surplus] {
14375                            self.fs.delete_archive(n)?;
14376                        }
14377                    }
14378                }
14379            }
14380        } else if opts.keep_wal {
14381            // keep_wal=true: WAL is left untouched.  The existing WAL already
14382            // contains the EnableFulltext records from the original enable calls;
14383            // replay is idempotent (guards in apply() skip already-live entries).
14384            // No baseline re-write is needed or safe here — the full WAL history
14385            // must remain intact for open_at to reach pre-snapshot commits.
14386        } else {
14387            // keep_wal=false (default): truncate by replacing the WAL with a
14388            // minimal baseline of one EnableFulltext record per active pair.
14389            //
14390            // Crash-ordering: write_atomic is atomic.
14391            //   • Crash before snapshot write  → WAL unchanged.  Safe.
14392            //   • Crash after snapshot write but before this WAL write → full
14393            //     pre-snapshot WAL still present; open_with replays idempotently.
14394            //   • Crash after both writes → normal post-snapshot state.
14395            //
14396            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
14397            // for any archives taken AFTER this point (their WAL slices would
14398            // not start at genesis).  Delete any existing genesis marker so that
14399            // open_at refuses archive-resident commits.  Future sessions are
14400            // covered by had_prior_snapshot: snapshot.bin written here persists
14401            // across sessions and prevents a later archiving session from
14402            // incorrectly claiming a complete genesis chain.
14403            if self.archive_genesis_chain {
14404                self.fs.delete_genesis_marker()?;
14405                self.archive_genesis_chain = false;
14406            }
14407            // And this handle can no longer prove the WAL is whole: it is about
14408            // to truncate it itself. Same-session archives after this point get
14409            // the conservative answer, exactly as cross-session ones do.
14410            self.snapshot_preserved_history = false;
14411            // The frames this discards keep their numbers. Everything the store
14412            // could still address — its archives and the live WAL — ends at
14413            // `wal_total_commits()`. That becomes the floor, and the replacement
14414            // WAL continues from it, exactly as the archive path's does after a
14415            // retention sweep.
14416            //
14417            // Leaving the floor where it was renumbered the new WAL over the
14418            // old one: `stats` reported `history_floor: 0`, which every reader
14419            // prints as "complete, nothing pruned", and a commit index a caller
14420            // already held resolved to an unrelated frame — a plausible graph,
14421            // not an error (ledger row 80).
14422            //
14423            // The floor is persisted FIRST, for the reason the retention sweep
14424            // gives: a crash after it leaves the old WAL and any archives on
14425            // disk under a floor that already disowns them. The next open
14426            // removes the archives as orphans and replays the WAL over the
14427            // snapshot idempotently. No number from before the snapshot is
14428            // answered in that state: all of them are below the floor.
14429            //
14430            // What that state does do is count the old WAL's frames again,
14431            // above the floor, until the next truncating snapshot — the same
14432            // history under new numbers, and a total inflated by its length.
14433            // Never a reused number, but not the original ones either. Closing
14434            // that needs the floor to land with the snapshot in one write,
14435            // which is a format change (ledger row 80, "what is left").
14436            let discarded_through = self.wal_total_commits()?;
14437            if discarded_through > self.wal_horizon_floor {
14438                self.fs.write_horizon_floor(discarded_through)?;
14439                self.wal_horizon_floor = discarded_through;
14440            }
14441            let mut baseline_wal: Vec<u8> = Vec::new();
14442            // The multiplicity opt-in is a declaration like the two below it,
14443            // and it is re-emitted for the same reason: truncation must not
14444            // silently opt the store back out and stop counting.
14445            if self.multiplicity {
14446                baseline_wal
14447                    .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14448            }
14449            for (label, field) in self.fulltext.enabled_pairs() {
14450                let rec = WalRecord::EnableFulltext {
14451                    label: label.clone(),
14452                    field: field.clone(),
14453                };
14454                baseline_wal.extend_from_slice(&encode_record(&rec));
14455            }
14456            for (label, field) in self.prop_index.enabled_pairs() {
14457                let rec = WalRecord::EnableIndex {
14458                    label: label.clone(),
14459                    field: field.clone(),
14460                };
14461                baseline_wal.extend_from_slice(&encode_record(&rec));
14462            }
14463            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14464            // Every archive now ends at or below the floor. They are the
14465            // history this snapshot was asked to discard; removing them here is
14466            // what `cleanup_orphaned_archives` would do at the next open.
14467            for n in self.fs.list_archives()? {
14468                if n <= self.wal_horizon_floor {
14469                    self.fs.delete_archive(n)?;
14470                }
14471            }
14472            // This branch discards history rather than archiving it: the frames
14473            // the map describes are gone, and every entry it holds is below the
14474            // floor. A date before the floor has no commit to resolve to.
14475            //
14476            // A store that cannot answer a date says so by name
14477            // (`NoRecordedTime`). That is the honest state after discarding the
14478            // history the dates addressed.
14479            self.commit_times = core_storage::commit_times::CommitTimes::default();
14480            self.rewrite_commit_times();
14481        }
14482        // After snapshot the overlay may have changed (V8 merge path clears
14483        // self.topo and self.props). Refresh the MVCC fold so future readers
14484        // see the post-snapshot state rather than stale overlay data.
14485        self.fold_now();
14486        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
14487        // markers this handle uses to detect other processes' work must be
14488        // re-taken from disk. Skipping this would make our own snapshot look
14489        // like a peer's on the next staleness check and force a needless
14490        // reload.
14491        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
14492        // A snapshot can replace the live WAL with a baseline, which has its
14493        // own frames. Re-derive the frame cursor from what the store now holds
14494        // (the floor, plus what is reachable above it) rather than carrying
14495        // the pre-snapshot count forward. `wal_total_commits` is the same
14496        // sequence the history surfaces index, and the snapshot has already
14497        // paid a far larger cost than one decode.
14498        self.wal_frames_written = self.wal_total_commits()?;
14499        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
14500        Ok(())
14501    }
14502}
14503
14504/// What a batch node insert does when its key is already taken.
14505///
14506/// A mirror rebuild writes a frame onto a store that already has content, so
14507/// "the key exists" is a routine answer rather than a failure. The decision is
14508/// made during the batch's existing validate pass, from one id-map lookup per
14509/// row, so the frame stays atomic and re-ingest stays O(n).
14510#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14511pub enum OnConflict {
14512    /// Refuse the whole frame with [`GraphError::DuplicateKey`]. The default,
14513    /// and the only behaviour before v0.6.10.
14514    #[default]
14515    Error,
14516    /// Leave the stored node exactly as it is — properties, label and edges —
14517    /// and count it in [`BatchOutcome::skipped`].
14518    Skip,
14519    /// Keep the key and make the node's properties **exactly** the supplied
14520    /// props: supplied fields are set, fields absent from the supplied props
14521    /// are removed. A supplied label that differs from the stored one, and a
14522    /// supplied `ns` that would move the node, are row errors — relabelling is
14523    /// [`GraphDb::rename_node`], not a side effect of a rebuild.
14524    ///
14525    /// Two properties are outside "exactly", both because they are not the
14526    /// caller's to supply:
14527    ///
14528    /// - `ns` is immutable, so an omitted `ns` leaves the node where it is
14529    ///   rather than moving it to `default`;
14530    /// - a property a **view** owns is kept, not removed. Supplying one is a
14531    ///   row error, so omitting it cannot be a request to delete it, and
14532    ///   refusing the row instead would make `Replace` impossible for the whole
14533    ///   population a view has written to. Each such field kept is counted in
14534    ///   [`BatchOutcome::kept_view_owned`] — the row is still `replaced` and
14535    ///   still raises no row error, so that count is the only signal a caller
14536    ///   gets that the stored node carries a field its frame did not describe.
14537    Replace,
14538}
14539
14540/// What one [`OnConflict::Replace`] row resolves to.
14541///
14542/// The property writes that make the node exactly the supplied props —
14543/// `Some(value)` is a set, `None` a removal — paired with how many view-owned
14544/// fields the row kept instead of removing, which is the one way the result is
14545/// not exactly the supplied props. See [`MutPreview::plan_replace`].
14546type ReplacePlan = (Vec<(String, Option<Value>)>, usize);
14547
14548/// What one committed batch did.
14549///
14550/// [`BatchBuilder::commit`] returns the first two fields as a tuple; the rest
14551/// exist for [`OnConflict`] and are always zero / empty without it.
14552#[derive(Clone, Debug, Default, PartialEq, Eq)]
14553pub struct BatchOutcome {
14554    /// Node records actually written.
14555    pub nodes_inserted: usize,
14556    /// Edge records actually written. A duplicate edge is a silent no-op under
14557    /// every policy — adjacency is a set — and is not counted.
14558    pub edges_inserted: usize,
14559    /// Rows whose key was taken and whose policy was [`OnConflict::Skip`].
14560    pub skipped: usize,
14561    /// Rows whose key was taken and whose policy was [`OnConflict::Replace`].
14562    pub replaced: usize,
14563    /// View-owned properties an [`OnConflict::Replace`] row **kept** although
14564    /// the caller did not supply them — counted per field, so one row that
14565    /// keeps two contributes two.
14566    ///
14567    /// This is the one respect in which `Replace` does not make a node's props
14568    /// exactly the supplied ones (see [`OnConflict::Replace`]). Those rows
14569    /// still count in `replaced` and still raise no `row_errors`, because
14570    /// nothing went wrong: a view's property is not the caller's to supply or
14571    /// to remove. A mirror rebuild that needs its copy to be byte-exact reads
14572    /// this to learn that the store kept fields its frame did not describe.
14573    pub kept_view_owned: usize,
14574    /// `(row, why)` for rows an [`OnConflict::Replace`] refused. `row` counts
14575    /// node-insert ops in this batch from zero, which for a caller that queues
14576    /// its nodes in order is the index of the offending node. The rest of the
14577    /// frame still commits; the refused row changes nothing.
14578    pub row_errors: Vec<(usize, String)>,
14579}
14580
14581/// The `(nodes_inserted, edges_inserted)` pair every pre-0.6.10 commit entry
14582/// point returns. Keeps those signatures unchanged now that the validate pass
14583/// produces a [`BatchOutcome`].
14584fn inserted_pair(outcome: BatchOutcome) -> (usize, usize) {
14585    (outcome.nodes_inserted, outcome.edges_inserted)
14586}
14587
14588/// One entry of a frame the validate pass has decided on, before
14589/// [`GraphDb::rewrite_wal_dense_planned`] turns it into dense-id records.
14590///
14591/// Almost every entry is already a finished [`WalRecord`]. The exception is a
14592/// duplicate edge insert: its count names a dense triple, and on the batch path
14593/// the endpoints and the edge type may all be created by earlier records in the
14594/// *same* frame, so no id for them exists until the dense rewrite allocates it.
14595/// Carrying the keys this far and resolving them there is what lets the count
14596/// survive the shape a mirror rebuild writes (defect #24).
14597enum PlannedRec {
14598    Rec(WalRecord),
14599    DuplicateCount {
14600        edge_type: String,
14601        src_key: String,
14602        dst_key: String,
14603    },
14604}
14605
14606/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
14607///
14608/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
14609/// callers can build a set of mutations without holding `&mut GraphDb` and
14610/// hand them off to the group-committing writer for durable, batched I/O.
14611pub enum BatchOp {
14612    InsertNode {
14613        label: String,
14614        key: String,
14615        props: Vec<(String, Value)>,
14616    },
14617    InsertEdge {
14618        edge_type: String,
14619        src_key: String,
14620        dst_key: String,
14621    },
14622    SetProp {
14623        key: String,
14624        field: String,
14625        value: Value,
14626    },
14627    RemoveProp {
14628        key: String,
14629        field: String,
14630    },
14631    DeleteEdge {
14632        edge_type: String,
14633        src_key: String,
14634        dst_key: String,
14635    },
14636    DeleteNode {
14637        key: String,
14638    },
14639    CreateRule(RuleDef),
14640    DeleteRule {
14641        name: String,
14642    },
14643    /// Rename a node's key. Validated: old must exist, new must not.
14644    RenameNode {
14645        old_key: String,
14646        new_key: String,
14647    },
14648    /// Insert an edge, auto-creating any missing endpoint as a plain node with
14649    /// `placeholder_label` and no props. Rules fire and last-change is updated
14650    /// for each created endpoint (normal InsertNode semantics in the batch frame).
14651    InsertEdgeUpsert {
14652        edge_type: String,
14653        src_key: String,
14654        dst_key: String,
14655        placeholder_label: String,
14656    },
14657    /// Insert `key`, or — when the key is already taken — do what `on_conflict`
14658    /// says. Queued by [`BatchBuilder::insert_node_on_conflict`]; `Error`
14659    /// queues a plain [`BatchOp::InsertNode`] instead, so this variant only
14660    /// ever carries `Skip` or `Replace`.
14661    InsertNodeOnConflict {
14662        label: String,
14663        key: String,
14664        props: Vec<(String, Value)>,
14665        on_conflict: OnConflict,
14666    },
14667}
14668
14669/// Three-way node visibility status used by `check_single_op_authz`.
14670enum NodeAuthzStatus {
14671    /// Node exists in the store and is in the role's read mask.
14672    Visible(String), // carries the node's label
14673    /// Node exists in the store but is NOT in the role's read mask.
14674    Hidden,
14675    /// Node does not exist in the store.
14676    Absent,
14677}
14678
14679/// Overlay of ops already accepted earlier in the same batch. Never written
14680/// back to the database — validation only.
14681#[derive(Default)]
14682struct Overlay {
14683    extra_keys: BTreeSet<String>,
14684    /// Label of each node inserted earlier in this batch. The store does not
14685    /// have these keys yet, so `label_of` cannot answer for them, and
14686    /// `OnConflict::Replace` has to compare labels.
14687    extra_labels: BTreeMap<String, String>,
14688    deleted_keys: BTreeSet<String>,
14689    /// `new key → store key` for every node an earlier op in this batch
14690    /// renamed. Such a key is in `extra_keys`, but unlike one a batch inserts
14691    /// it has a stored node behind it — under the old key until the batch
14692    /// commits — and its properties, label and edges are read through this.
14693    renamed_from: BTreeMap<String, String>,
14694    extra_props: BTreeMap<(String, String), Value>,
14695    removed_props: BTreeSet<(String, String)>,
14696    extra_edges: BTreeSet<(String, String, String)>,
14697    deleted_edges: BTreeSet<(String, String, String)>,
14698    extra_rules: BTreeSet<String>,
14699    deleted_rules: BTreeSet<String>,
14700    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
14701    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
14702    /// sees only the rules already committed to the engine. Keyed by name so a
14703    /// later `DeleteRule` in the same batch drops the arc with the rule.
14704    extra_rule_arcs: BTreeMap<String, (String, String)>,
14705}
14706
14707/// Read-only view of live db state plus a batch overlay. Shared by single-op
14708/// public methods (empty overlay) and `commit_batch`.
14709struct MutPreview<'a, F: Fs> {
14710    db: &'a GraphDb<F>,
14711    overlay: Overlay,
14712}
14713
14714/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
14715/// `None` if `target` is unreachable.
14716///
14717/// Used for rule-chain cycle detection, where an arc is "a rule hops over
14718/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
14719/// reported path is stable for a given rule set, and iterative so a pathological
14720/// rule graph cannot overflow the stack.
14721fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
14722    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
14723    for (from, to) in arcs {
14724        adj.entry(from.as_str()).or_default().insert(to.as_str());
14725    }
14726    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
14727    let mut visited: BTreeSet<&str> = BTreeSet::new();
14728    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
14729    visited.insert(start);
14730    queue.push_back(start);
14731    while let Some(node) = queue.pop_front() {
14732        if node == target {
14733            let mut path = vec![node.to_string()];
14734            let mut cur = node;
14735            while let Some(&p) = parent.get(cur) {
14736                path.push(p.to_string());
14737                cur = p;
14738            }
14739            path.reverse();
14740            return Some(path);
14741        }
14742        for &next in adj.get(node).into_iter().flatten() {
14743            if visited.insert(next) {
14744                parent.insert(next, node);
14745                queue.push_back(next);
14746            }
14747        }
14748    }
14749    None
14750}
14751
14752impl<'a, F: Fs> MutPreview<'a, F> {
14753    fn new(db: &'a GraphDb<F>) -> Self {
14754        Self {
14755            db,
14756            overlay: Overlay::default(),
14757        }
14758    }
14759
14760    fn has_key(&self, key: &str) -> bool {
14761        if self.overlay.extra_keys.contains(key) {
14762            return true;
14763        }
14764        if self.overlay.deleted_keys.contains(key) {
14765            return false;
14766        }
14767        self.db.ids.get(key).is_some()
14768    }
14769
14770    /// The key the store holds `key`'s node under, or `None` when the batch
14771    /// sees no stored node there: `key` is deleted, or was first inserted in
14772    /// this batch. A key an earlier op renamed stands for the node still
14773    /// stored under its old key.
14774    fn store_key<'k>(&'k self, key: &'k str) -> Option<&'k str> {
14775        if self.overlay.deleted_keys.contains(key) {
14776            return None;
14777        }
14778        if let Some(origin) = self.overlay.renamed_from.get(key) {
14779            return Some(origin.as_str());
14780        }
14781        if self.overlay.extra_keys.contains(key) {
14782            return None;
14783        }
14784        Some(key)
14785    }
14786
14787    fn has_prop(&self, key: &str, field: &str) -> bool {
14788        if !self.has_key(key) {
14789            return false;
14790        }
14791        let k = (key.to_string(), field.to_string());
14792        if self.overlay.removed_props.contains(&k) {
14793            return false;
14794        }
14795        if self.overlay.extra_props.contains_key(&k) {
14796            return true;
14797        }
14798        // Fresh identity (first insert in this batch, or delete+reinsert):
14799        // ignore props still sitting on the soon-to-be-tombstoned slot.
14800        let Some(stored) = self.store_key(key) else {
14801            return false;
14802        };
14803        self.db.get_prop(stored, field).is_some()
14804    }
14805
14806    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
14807        let k = (
14808            edge_type.to_string(),
14809            src_key.to_string(),
14810            dst_key.to_string(),
14811        );
14812        if self.overlay.deleted_edges.contains(&k) {
14813            return false;
14814        }
14815        if self.overlay.extra_edges.contains(&k) {
14816            return true;
14817        }
14818        // A key created in this batch (including reinsert) has no db edges.
14819        let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
14820        else {
14821            return false;
14822        };
14823        let Some(src) = self.db.ids.get(src_key) else {
14824            return false;
14825        };
14826        let Some(dst) = self.db.ids.get(dst_key) else {
14827            return false;
14828        };
14829        let Some(sym) = self.db.syms.get(edge_type) else {
14830            return false;
14831        };
14832        self.db
14833            .topo_view()
14834            .neighbors(sym, Direction::Out, src)
14835            .binary_search(&dst)
14836            .is_ok()
14837    }
14838
14839    fn has_rule(&self, name: &str) -> bool {
14840        if self.overlay.extra_rules.contains(name) {
14841            return true;
14842        }
14843        if self.overlay.deleted_rules.contains(name) {
14844            return false;
14845        }
14846        self.db.engine.rules().any(|r| r.name == name)
14847    }
14848
14849    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
14850        let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
14851        else {
14852            return false;
14853        };
14854        let Some(src) = self.db.ids.get(src_key) else {
14855            return false;
14856        };
14857        let Some(dst) = self.db.ids.get(dst_key) else {
14858            return false;
14859        };
14860        let Some(et) = self.db.syms.get(edge_type) else {
14861            return false;
14862        };
14863        // extra_rules is deliberately not consulted: a CreateRule earlier in
14864        // this batch has not fired, so it contributes no provenance. That is
14865        // the documented rule-window gap (see GraphDb::batch).
14866        if self.overlay.deleted_rules.is_empty() {
14867            return self.db.engine.is_owned(et, src, dst);
14868        }
14869        for (rule, triples) in self.db.engine.provenance() {
14870            if self.overlay.deleted_rules.contains(rule) {
14871                continue;
14872            }
14873            if triples.contains(&(et, src, dst)) {
14874                return true;
14875            }
14876        }
14877        false
14878    }
14879
14880    /// The refusals a node creation makes, in the order it makes them.
14881    ///
14882    /// A view owns its property, and creating a node that carries one is a
14883    /// write to it exactly as `set_prop` is — so it is refused here, at the one
14884    /// choke-point `GraphDb::insert_node`, `BatchOp::InsertNode` and the
14885    /// no-conflict arm of `BatchOp::InsertNodeOnConflict` all pass through.
14886    ///
14887    /// Leaving creation exempt was not harmless. The value was stored and
14888    /// served: a created node the view has no reason to revisit keeps the
14889    /// caller's number for the life of the handle, and the backfill at the next
14890    /// open overwrites it — so the store answered `deg = 777` before a restart
14891    /// and `deg = 0` after, for a property every other surface calls read-only.
14892    /// It also split one op two ways: supplying a view-owned field under
14893    /// `OnConflict::Replace` was already a row error on a taken key while the
14894    /// same field on a fresh key was accepted.
14895    ///
14896    /// Checked before the key, like [`MutPreview::prepare_remove_prop`], so the
14897    /// answer does not depend on whether the key exists.
14898    fn check_insert_node(&self, key: &str, props: &[(String, Value)]) -> Result<()> {
14899        for (field, _) in props {
14900            if let Some(view_name) = self.db.view_store.view_for_prop(field) {
14901                return Err(GraphError::ViewPropReadOnly {
14902                    view_name: view_name.to_string(),
14903                });
14904            }
14905        }
14906        if self.has_key(key) {
14907            Err(GraphError::DuplicateKey { key: key.into() })
14908        } else {
14909            Ok(())
14910        }
14911    }
14912
14913    fn check_live_key(&self, key: &str) -> Result<()> {
14914        if self.has_key(key) {
14915            Ok(())
14916        } else {
14917            Err(GraphError::KeyNotFound { key: key.into() })
14918        }
14919    }
14920
14921    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
14922        for k in [src_key, dst_key] {
14923            if !self.has_key(k) {
14924                return Err(GraphError::KeyNotFound { key: k.into() });
14925            }
14926        }
14927        if self.is_rule_owned(edge_type, src_key, dst_key) {
14928            return Err(GraphError::RuleOwned {
14929                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
14930            });
14931        }
14932        // A user-written edge stays inside one namespace. Derived edges do not
14933        // come through here — the engine adds them directly — and the rule
14934        // scoping check is what keeps those pure.
14935        let src_ns = self.namespace_in_batch(src_key);
14936        let dst_ns = self.namespace_in_batch(dst_key);
14937        if src_ns != dst_ns {
14938            return Err(GraphError::CrossNamespace {
14939                src: src_key.to_string(),
14940                src_ns,
14941                dst: dst_key.to_string(),
14942                dst_ns,
14943            });
14944        }
14945        Ok(!self.has_edge(edge_type, src_key, dst_key))
14946    }
14947
14948    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
14949        // A view owns its property, and the refusal has to live here rather
14950        // than on `GraphDb::remove_prop`: `BatchOp::RemoveProp` never meets
14951        // that one, and it is what the HTTP `DELETE /node/{key}/prop/{field}`
14952        // route, `Batch::remove_prop` and the CLI all submit. This is the one
14953        // choke-point every removal passes, exactly as it is for `ns` below.
14954        // Checked before the key, so the answer does not depend on whether the
14955        // key exists — which is also what `GraphDb::remove_prop` answered when
14956        // it carried the only copy of this guard.
14957        if let Some(view_name) = self.db.view_store.view_for_prop(field) {
14958            return Err(GraphError::ViewPropReadOnly {
14959                view_name: view_name.to_string(),
14960            });
14961        }
14962        self.check_live_key(key)?;
14963        // Removing `ns` is changing the namespace — to `default`, the namespace
14964        // an absent property names. It goes through this one choke-point and NOT
14965        // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
14966        // the immutability rule has to be stated here as well. Without it the
14967        // node silently lands in `default` on the next open: the cross-namespace
14968        // edge guard is defeated and a default-bound role reads a tenant's node.
14969        if field == NS_PROP {
14970            let from = self.namespace_in_batch(key);
14971            if from != NS_DEFAULT {
14972                return Err(GraphError::NamespaceImmutable {
14973                    key: key.to_string(),
14974                    from,
14975                    to: NS_DEFAULT.to_string(),
14976                });
14977            }
14978            // Already in `default`: the removal changes no namespace. It is the
14979            // no-op `set_prop` to the current namespace is, not an error.
14980            return Ok(false);
14981        }
14982        Ok(self.has_prop(key, field))
14983    }
14984
14985    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
14986        for k in [src_key, dst_key] {
14987            if !self.has_key(k) {
14988                return Err(GraphError::KeyNotFound { key: k.into() });
14989            }
14990        }
14991        // Provenance-owned OR a live rule would derive this pair. User-first
14992        // edges that a later rule matches are not in `owned`, but deleting
14993        // them would leave a hole `rebuild_rule` immediately fills.
14994        if self.is_rule_owned(edge_type, src_key, dst_key) {
14995            return Err(GraphError::RuleOwned {
14996                detail: format!(
14997                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
14998                     delete or change the owning rule"
14999                ),
15000            });
15001        }
15002        if self.would_derive(edge_type, src_key, dst_key) {
15003            return Err(GraphError::RuleOwned {
15004                detail: format!(
15005                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15006                     delete or change the owning rule, or a live rule would re-derive it"
15007                ),
15008            });
15009        }
15010        Ok(self.has_edge(edge_type, src_key, dst_key))
15011    }
15012
15013    /// True if any live rule (minus overlay-deleted names) would derive
15014    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
15015    /// CreateRule names in `extra_rules` are ignored — same documented
15016    /// same-batch rule-window as [`Self::is_rule_owned`].
15017    ///
15018    /// Mirrored by `guard_matches` in `memory/forget.rs`, which names the
15019    /// rules this refused for: change the two together (ledger row 67).
15020    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15021        if src_key == dst_key {
15022            return false;
15023        }
15024        let Some(src_label) = self.label_of(src_key) else {
15025            return false;
15026        };
15027        let Some(dst_label) = self.label_of(dst_key) else {
15028            return false;
15029        };
15030        for rule in self.db.engine.rules() {
15031            if self.overlay.deleted_rules.contains(&rule.name) {
15032                continue;
15033            }
15034            if rule.edge_type != edge_type {
15035                continue;
15036            }
15037            if rule.src_label != src_label || rule.dst_label != dst_label {
15038                continue;
15039            }
15040            let src_props = |f: &str| self.prop_value(src_key, f);
15041            let dst_props = |f: &str| self.prop_value(dst_key, f);
15042            let src_view = NodeView {
15043                key: src_key,
15044                props: &src_props,
15045            };
15046            let dst_view = NodeView {
15047                key: dst_key,
15048                props: &dst_props,
15049            };
15050            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
15051                return true;
15052            }
15053        }
15054        false
15055    }
15056
15057    fn label_of(&self, key: &str) -> Option<String> {
15058        if self.overlay.deleted_keys.contains(key) {
15059            return None;
15060        }
15061        // Fresh identities created in this batch have no stored label in the
15062        // overlay; they cannot be provenance-owned yet either. A key this
15063        // batch renamed has its label on the node stored under the old key.
15064        let stored = self
15065            .overlay
15066            .renamed_from
15067            .get(key)
15068            .map_or(key, String::as_str);
15069        let id = self.db.ids.get(stored)?;
15070        let sym = self.db.labels.get(id as usize).copied()?;
15071        if sym == u32::MAX {
15072            return None;
15073        }
15074        self.db.syms.resolve(sym).map(str::to_string)
15075    }
15076
15077    /// The label `key` carries as this batch sees it — including a node
15078    /// inserted earlier in the same batch, which the store does not have yet.
15079    fn label_in_batch(&self, key: &str) -> Option<String> {
15080        if self.overlay.deleted_keys.contains(key) {
15081            return None;
15082        }
15083        if let Some(label) = self.overlay.extra_labels.get(key) {
15084            return Some(label.clone());
15085        }
15086        self.label_of(key)
15087    }
15088
15089    /// The property writes that make `key`'s props exactly `props`, or why the
15090    /// row is refused.
15091    ///
15092    /// `Some(value)` is a set and `None` is a removal. `store_fields` is every
15093    /// field name the store knows, hoisted by the caller so a frame of N
15094    /// replaces reads the field list once rather than N times.
15095    ///
15096    /// The second half of the pair is how many view-owned fields this row kept
15097    /// rather than removed — the one part of "exactly the supplied props" that
15098    /// does not hold, and the caller's only signal that it did not.
15099    ///
15100    /// The refusals are row errors, not frame errors: a mirror rebuild should
15101    /// learn which of its rows disagree with the store without losing the rows
15102    /// that agree.
15103    fn plan_replace(
15104        &self,
15105        label: &str,
15106        key: &str,
15107        props: &[(String, Value)],
15108        store_fields: &[String],
15109    ) -> std::result::Result<ReplacePlan, String> {
15110        // A different label is a relabel, and a rebuild does not relabel: that
15111        // is `rename_node` or an explicit write, never a side effect here.
15112        let stored = self.label_in_batch(key).unwrap_or_default();
15113        if stored != label {
15114            return Err(format!(
15115                "node {key}: on_conflict=\"replace\" will not relabel {stored:?} to {label:?}; \
15116                 relabelling is rename_node or an explicit write"
15117            ));
15118        }
15119        // `ns` is immutable. Replace removes what the supplied props omit, so
15120        // an omitted `ns` is a move to `default` exactly as a different `ns` is
15121        // a move to that one; both are the same refusal.
15122        let from = self.namespace_in_batch(key);
15123        let to = match props.iter().find(|(field, _)| field == NS_PROP) {
15124            Some((_, Value::Str(ns))) => ns.clone(),
15125            Some((_, value)) => {
15126                return Err(format!(
15127                    "node {key}: {NS_PROP} must be a string naming a namespace, got {value:?}"
15128                ));
15129            }
15130            None => NS_DEFAULT.to_string(),
15131        };
15132        if to != from {
15133            return Err(format!(
15134                "node {key}: {NS_PROP} is immutable; on_conflict=\"replace\" cannot move it \
15135                 from {from:?} to {to:?}"
15136            ));
15137        }
15138
15139        if let Some(why) = self.supplied_view_owned_prop(key, props) {
15140            return Err(why);
15141        }
15142
15143        let supplied: BTreeSet<&str> = props.iter().map(|(field, _)| field.as_str()).collect();
15144        let mut writes = Vec::new();
15145        for (field, value) in props {
15146            // `ns` names the namespace the node is already in, so the write is
15147            // the no-op the dense-rewrite seam would drop anyway.
15148            if field == NS_PROP {
15149                continue;
15150            }
15151            // Already exactly this value: a rebuild of an unchanged row should
15152            // cost no WAL record.
15153            if self.prop_value(key, field).as_ref() == Some(value) {
15154                continue;
15155            }
15156            writes.push((field.clone(), Some(value.clone())));
15157        }
15158        // Everything the node still carries that the supplied props do not.
15159        // `ns` is never removed: it is immutable, and the check above has
15160        // already established the node stays where it is.
15161        let overlay_fields = self
15162            .overlay
15163            .extra_props
15164            .keys()
15165            .filter(|(k, _)| k == key)
15166            .map(|(_, field)| field.as_str());
15167        //
15168        // A view-owned field is filtered out rather than refused. It is not the
15169        // caller's to supply (supplying one is still the row error above) and
15170        // so it is not part of what "exactly the supplied ones" ranges over:
15171        // omitting it is not a request to delete it. Refusing here instead
15172        // would make `replace` impossible for every node a view has written to
15173        // — which on a store carrying a view is the whole population a mirror
15174        // rebuild has to cover.
15175        let omitted: BTreeSet<&str> = store_fields
15176            .iter()
15177            .map(String::as_str)
15178            .chain(overlay_fields)
15179            .filter(|field| {
15180                *field != NS_PROP && !supplied.contains(field) && self.has_prop(key, field)
15181            })
15182            .collect();
15183        // The view-owned half is kept, and counted: the row still commits and
15184        // still reports no error, so without this number a mirror rebuild is
15185        // told it got exactly what it asked for when it did not (defect #18).
15186        let (stale, kept): (Vec<&str>, Vec<&str>) = omitted
15187            .into_iter()
15188            .partition(|field| self.db.view_store.view_for_prop(field).is_none());
15189        writes.extend(stale.into_iter().map(|field| (field.to_string(), None)));
15190        Ok((writes, kept.len()))
15191    }
15192
15193    /// The row error a supplied view-owned field earns, or `None`.
15194    ///
15195    /// Shared by [`MutPreview::plan_replace`] and the no-conflict arm of
15196    /// `BatchOp::InsertNodeOnConflict` so that one op answers a supplied
15197    /// view-owned field the same way whether or not the key was already taken.
15198    fn supplied_view_owned_prop(&self, key: &str, props: &[(String, Value)]) -> Option<String> {
15199        props.iter().find_map(|(field, _)| {
15200            self.db.view_store.view_for_prop(field).map(|view_name| {
15201                format!(
15202                    "node {key}: property {field:?} is owned by view {view_name:?} and is \
15203                     read-only"
15204                )
15205            })
15206        })
15207    }
15208
15209    /// The namespace `key` is in as this batch sees it — including a node
15210    /// inserted earlier in the same batch, which the store does not have yet.
15211    fn namespace_in_batch(&self, key: &str) -> String {
15212        namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
15213    }
15214
15215    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
15216        if !self.has_key(key) {
15217            return None;
15218        }
15219        let k = (key.to_string(), field.to_string());
15220        if self.overlay.removed_props.contains(&k) {
15221            return None;
15222        }
15223        if let Some(v) = self.overlay.extra_props.get(&k) {
15224            return Some(v.clone());
15225        }
15226        self.db.get_prop(self.store_key(key)?, field)
15227    }
15228
15229    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
15230        def.validate()
15231            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
15232        if self.has_rule(&def.name) {
15233            return Err(GraphError::RuleInvalid {
15234                detail: format!("rule {:?} already exists", def.name),
15235            });
15236        }
15237        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
15238        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
15239        // `edge_type`". A cycle in that graph is a rule set that would re-fire
15240        // itself forever; the engine's depth cap would silently truncate it
15241        // instead, leaving an arbitrary partial result. Reject it here, the one
15242        // place that sees the whole rule set.
15243        //
15244        // Rules accepted earlier in the same batch count too: the overlay
15245        // carries their arcs, so a cycle cannot be assembled one op at a time.
15246        if let Some(via) = def.via_edge.as_deref() {
15247            if via == def.edge_type {
15248                return Err(GraphError::RuleInvalid {
15249                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
15250                });
15251            }
15252            let mut arcs: Vec<(String, String)> = self
15253                .db
15254                .engine
15255                .rules()
15256                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
15257                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
15258                .collect();
15259            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
15260            arcs.push((via.to_string(), def.edge_type.clone()));
15261            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
15262                return Err(GraphError::RuleInvalid {
15263                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
15264                });
15265            }
15266        }
15267        Ok(())
15268    }
15269
15270    fn check_delete_rule(&self, name: &str) -> Result<()> {
15271        if self.has_rule(name) {
15272            Ok(())
15273        } else {
15274            Err(GraphError::RuleNotFound { name: name.into() })
15275        }
15276    }
15277
15278    fn note_insert_node(&mut self, label: &str, key: &str, props: &[(String, Value)]) {
15279        self.overlay.deleted_keys.remove(key);
15280        self.overlay.renamed_from.remove(key);
15281        self.overlay.extra_keys.insert(key.to_string());
15282        self.overlay
15283            .extra_labels
15284            .insert(key.to_string(), label.to_string());
15285        self.overlay.extra_props.retain(|(k, _), _| k != key);
15286        self.overlay.removed_props.retain(|(k, _)| k != key);
15287        for (field, value) in props {
15288            self.overlay
15289                .extra_props
15290                .insert((key.to_string(), field.clone()), value.clone());
15291        }
15292    }
15293
15294    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15295        let k = (
15296            edge_type.to_string(),
15297            src_key.to_string(),
15298            dst_key.to_string(),
15299        );
15300        self.overlay.deleted_edges.remove(&k);
15301        self.overlay.extra_edges.insert(k);
15302    }
15303
15304    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
15305        let k = (key.to_string(), field.to_string());
15306        self.overlay.removed_props.remove(&k);
15307        self.overlay.extra_props.insert(k, value.clone());
15308    }
15309
15310    fn note_remove_prop(&mut self, key: &str, field: &str) {
15311        let k = (key.to_string(), field.to_string());
15312        self.overlay.extra_props.remove(&k);
15313        self.overlay.removed_props.insert(k);
15314    }
15315
15316    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15317        let k = (
15318            edge_type.to_string(),
15319            src_key.to_string(),
15320            dst_key.to_string(),
15321        );
15322        self.overlay.extra_edges.remove(&k);
15323        self.overlay.deleted_edges.insert(k);
15324    }
15325
15326    fn note_delete_node(&mut self, key: &str) {
15327        self.overlay.extra_keys.remove(key);
15328        self.overlay.renamed_from.remove(key);
15329        self.overlay.deleted_keys.insert(key.to_string());
15330        self.overlay.extra_props.retain(|(k, _), _| k != key);
15331        self.overlay.removed_props.retain(|(k, _)| k != key);
15332        self.overlay
15333            .extra_edges
15334            .retain(|(_, s, d)| s != key && d != key);
15335        self.overlay
15336            .deleted_edges
15337            .retain(|(_, s, d)| s != key && d != key);
15338    }
15339
15340    fn note_create_rule(&mut self, def: &RuleDef) {
15341        self.overlay.deleted_rules.remove(&def.name);
15342        self.overlay.extra_rules.insert(def.name.clone());
15343        // Rules accepted earlier in this batch are not in the engine yet, so
15344        // the cycle check would not see their arcs. Keep the arc, not just the
15345        // name, so a batch cannot smuggle in a cycle one op at a time.
15346        if let Some(via) = def.via_edge.clone() {
15347            self.overlay
15348                .extra_rule_arcs
15349                .insert(def.name.clone(), (via, def.edge_type.clone()));
15350        }
15351    }
15352
15353    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
15354        if !self.has_key(old) {
15355            return Err(GraphError::KeyNotFound { key: old.into() });
15356        }
15357        if self.has_key(new) {
15358            return Err(GraphError::DuplicateKey { key: new.into() });
15359        }
15360        Ok(())
15361    }
15362
15363    fn note_rename_node(&mut self, old: &str, new: &str) {
15364        // The stored node `new` will stand for: the one `old` stood for if an
15365        // earlier op renamed it here, `old` itself if the store has it, and
15366        // none if `old` was first inserted in this batch.
15367        let origin = match self.overlay.renamed_from.remove(old) {
15368            Some(origin) => Some(origin),
15369            None if self.overlay.extra_keys.contains(old) => None,
15370            None => Some(old.to_string()),
15371        };
15372        // Mark old as deleted so subsequent batch ops cannot reference it.
15373        self.overlay.extra_keys.remove(old);
15374        self.overlay.deleted_keys.insert(old.to_string());
15375        // Mark new as extra so subsequent batch ops can reference it.
15376        self.overlay.deleted_keys.remove(new);
15377        self.overlay.extra_keys.insert(new.to_string());
15378        // Migrate any overlay props from old key to new key.
15379        let new_str = new.to_string();
15380        let transferred: Vec<((String, String), Value)> = self
15381            .overlay
15382            .extra_props
15383            .iter()
15384            .filter(|((k, _), _)| k.as_str() == old)
15385            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
15386            .collect();
15387        self.overlay
15388            .extra_props
15389            .retain(|(k, _), _| k.as_str() != old);
15390        for (k, v) in transferred {
15391            self.overlay.extra_props.insert(k, v);
15392        }
15393        // Migrate removed_props.
15394        let transferred_removed: Vec<(String, String)> = self
15395            .overlay
15396            .removed_props
15397            .iter()
15398            .filter(|(k, _)| k.as_str() == old)
15399            .map(|(_, f)| (new_str.clone(), f.clone()))
15400            .collect();
15401        self.overlay
15402            .removed_props
15403            .retain(|(k, _)| k.as_str() != old);
15404        for k in transferred_removed {
15405            self.overlay.removed_props.insert(k);
15406        }
15407        if let Some(label) = self.overlay.extra_labels.remove(old) {
15408            self.overlay.extra_labels.insert(new_str.clone(), label);
15409        }
15410        // Edges the batch added or deleted follow the node to its new key.
15411        let follow = |k: String| if k == old { new_str.clone() } else { k };
15412        self.overlay.extra_edges = std::mem::take(&mut self.overlay.extra_edges)
15413            .into_iter()
15414            .map(|(t, s, d)| (t, follow(s), follow(d)))
15415            .collect();
15416        self.overlay.deleted_edges = std::mem::take(&mut self.overlay.deleted_edges)
15417            .into_iter()
15418            .map(|(t, s, d)| (t, follow(s), follow(d)))
15419            .collect();
15420        if let Some(origin) = origin {
15421            self.overlay.renamed_from.insert(new_str, origin);
15422        }
15423    }
15424
15425    fn note_delete_rule(&mut self, name: &str) {
15426        self.overlay.extra_rules.remove(name);
15427        // Drop its chain arc too: a rule created and then deleted in the same
15428        // batch must not make a later, legal rule look like a cycle.
15429        self.overlay.extra_rule_arcs.remove(name);
15430        self.overlay.deleted_rules.insert(name.to_string());
15431        // Treat the deleted rule's current provenance as gone so a later
15432        // delete_edge of those triples is a no-op (matches sequential).
15433        //
15434        // Provenance names nodes by the key the store holds them under; the
15435        // overlay names them as the batch does. A node an earlier op renamed
15436        // is recorded under its new key, or a later read of that key would go
15437        // through to the store and find the rule's edge still there.
15438        let now: BTreeMap<&str, &str> = self
15439            .overlay
15440            .renamed_from
15441            .iter()
15442            .map(|(new, stored)| (stored.as_str(), new.as_str()))
15443            .collect();
15444        if let Some(triples) = self.db.engine.provenance().get(name) {
15445            for &(et, s, d) in triples {
15446                let Some(etype) = self.db.syms.resolve(et) else {
15447                    continue;
15448                };
15449                let Some(src) = self.db.ids.key_of(s) else {
15450                    continue;
15451                };
15452                let Some(dst) = self.db.ids.key_of(d) else {
15453                    continue;
15454                };
15455                let src = now.get(src).copied().unwrap_or(src);
15456                let dst = now.get(dst).copied().unwrap_or(dst);
15457                let k = (etype.to_string(), src.to_string(), dst.to_string());
15458                self.overlay.extra_edges.remove(&k);
15459                self.overlay.deleted_edges.insert(k);
15460            }
15461        }
15462    }
15463}
15464
15465/// Collects mutations and commits them as one WAL `Batch` frame.
15466///
15467/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
15468/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
15469/// See [`GraphDb::batch`] for validation and atomicity rules.
15470pub struct BatchBuilder<'a, F: Fs> {
15471    db: &'a mut GraphDb<F>,
15472    ops: Vec<BatchOp>,
15473}
15474
15475impl<'a, F: Fs> BatchBuilder<'a, F> {
15476    pub fn insert_node(
15477        &mut self,
15478        label: &str,
15479        key: &str,
15480        props: Vec<(String, Value)>,
15481    ) -> &mut Self {
15482        self.ops.push(BatchOp::InsertNode {
15483            label: label.into(),
15484            key: key.into(),
15485            props,
15486        });
15487        self
15488    }
15489
15490    /// Queue a node insert whose answer to a taken key is `on_conflict`.
15491    ///
15492    /// [`OnConflict::Error`] queues exactly the op [`insert_node`](Self::insert_node)
15493    /// does, so the default path is unchanged.
15494    pub fn insert_node_on_conflict(
15495        &mut self,
15496        label: &str,
15497        key: &str,
15498        props: Vec<(String, Value)>,
15499        on_conflict: OnConflict,
15500    ) -> &mut Self {
15501        self.ops.push(match on_conflict {
15502            OnConflict::Error => BatchOp::InsertNode {
15503                label: label.into(),
15504                key: key.into(),
15505                props,
15506            },
15507            on_conflict => BatchOp::InsertNodeOnConflict {
15508                label: label.into(),
15509                key: key.into(),
15510                props,
15511                on_conflict,
15512            },
15513        });
15514        self
15515    }
15516
15517    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
15518        self.ops.push(BatchOp::InsertEdge {
15519            edge_type: edge_type.into(),
15520            src_key: src_key.into(),
15521            dst_key: dst_key.into(),
15522        });
15523        self
15524    }
15525
15526    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
15527        self.ops.push(BatchOp::SetProp {
15528            key: key.into(),
15529            field: field.into(),
15530            value,
15531        });
15532        self
15533    }
15534
15535    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
15536        self.ops.push(BatchOp::RemoveProp {
15537            key: key.into(),
15538            field: field.into(),
15539        });
15540        self
15541    }
15542
15543    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
15544        self.ops.push(BatchOp::DeleteEdge {
15545            edge_type: edge_type.into(),
15546            src_key: src_key.into(),
15547            dst_key: dst_key.into(),
15548        });
15549        self
15550    }
15551
15552    pub fn delete_node(&mut self, key: &str) -> &mut Self {
15553        self.ops.push(BatchOp::DeleteNode { key: key.into() });
15554        self
15555    }
15556
15557    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
15558        self.ops.push(BatchOp::CreateRule(def));
15559        self
15560    }
15561
15562    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
15563        self.ops.push(BatchOp::DeleteRule { name: name.into() });
15564        self
15565    }
15566
15567    /// Queue a node-rename in this batch.
15568    ///
15569    /// Validation (old exists, new not taken) runs at commit time.
15570    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
15571        self.ops.push(BatchOp::RenameNode {
15572            old_key: old_key.into(),
15573            new_key: new_key.into(),
15574        });
15575        self
15576    }
15577
15578    /// Queue an edge insert with endpoint auto-creation.
15579    ///
15580    /// Any missing endpoint is created as a plain node `{key, label:
15581    /// placeholder_label, no props}` inside this batch frame. Rules fire and
15582    /// last-change is updated for each auto-created node.
15583    pub fn insert_edge_upsert(
15584        &mut self,
15585        edge_type: &str,
15586        src_key: &str,
15587        dst_key: &str,
15588        placeholder_label: &str,
15589    ) -> &mut Self {
15590        self.ops.push(BatchOp::InsertEdgeUpsert {
15591            edge_type: edge_type.into(),
15592            src_key: src_key.into(),
15593            dst_key: dst_key.into(),
15594            placeholder_label: placeholder_label.into(),
15595        });
15596        self
15597    }
15598
15599    /// Validate every queued op, then log one `Batch` frame and apply.
15600    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
15601    /// A second `commit()` after a successful one is an empty-batch no-op
15602    /// (queued ops were taken).
15603    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
15604    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
15605    ///
15606    /// **Rule-window limitation:** batch validation cannot see edges that a
15607    /// rule created earlier in the *same* batch will derive at apply time, so
15608    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
15609    /// where sequential calls would return `Err(RuleOwned)`. State integrity
15610    /// is unaffected (idempotent apply, provenance intact). Create rules in
15611    /// their own batch, or sequentially, when later ops may touch derived
15612    /// edges.
15613    /// Validate every queued op and commit atomically.
15614    ///
15615    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
15616    /// WAL records actually written (duplicate edges are silent no-ops and are
15617    /// NOT counted). Both are 0 when the batch is empty or all-noop.
15618    pub fn commit(&mut self) -> Result<(usize, usize)> {
15619        let ops = std::mem::take(&mut self.ops);
15620        self.db.commit_batch(ops)
15621    }
15622
15623    /// [`commit`](Self::commit) with the full [`BatchOutcome`] — the counts a
15624    /// caller needs when its rows carry an [`OnConflict`] policy.
15625    pub fn commit_outcome(&mut self) -> Result<BatchOutcome> {
15626        let ops = std::mem::take(&mut self.ops);
15627        self.db.commit_logged_batch(ops, None, None)
15628    }
15629
15630    /// Same as [`commit`](Self::commit) but tail the inner events with
15631    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
15632    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
15633        let ops = std::mem::take(&mut self.ops);
15634        self.db
15635            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
15636            .map(inserted_pair)
15637    }
15638}
15639
15640pub struct NodeRef<'a, F: Fs> {
15641    db: &'a GraphDb<F>,
15642    id: u32,
15643}
15644
15645impl<'a, F: Fs> NodeRef<'a, F> {
15646    pub fn key(&self) -> &str {
15647        self.db.ids.key_of(self.id).expect("dense ids")
15648    }
15649
15650    pub fn label(&self) -> &str {
15651        let sym = self
15652            .db
15653            .labels
15654            .get(self.id as usize)
15655            .copied()
15656            .filter(|&s| s != u32::MAX)
15657            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
15658        self.db.syms.resolve(sym).expect("interned label symbol")
15659    }
15660
15661    pub fn prop(&self, field: &str) -> Option<Value> {
15662        self.db
15663            .props_view()
15664            .get(self.id, field)
15665            .map(|vr| vr.into_value())
15666    }
15667
15668    /// All stored fields for this node, sorted by field name.
15669    ///
15670    /// Reads from the full base+overlay view so that props stored only in the
15671    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
15672    pub fn props(&self) -> BTreeMap<String, Value> {
15673        let mut out = BTreeMap::new();
15674        let pv = self.db.props_view();
15675        for field in pv.field_names() {
15676            if let Some(vr) = pv.get(self.id, &field) {
15677                out.insert(field, vr.into_value());
15678            }
15679        }
15680        out
15681    }
15682
15683    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
15684    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
15685        let view = self.db.view();
15686        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
15687            names
15688                .iter()
15689                .filter_map(|name| view.syms.get(name))
15690                .collect()
15691        });
15692        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
15693        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
15694        for (nid, d) in nb.nodes {
15695            let key = view.key_of(nid);
15696            let label = view
15697                .label_of(nid)
15698                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
15699            rs.push_row(vec![
15700                Some(Value::Str(key.to_string())),
15701                Some(Value::Str(label.to_string())),
15702                Some(Value::Int(d as i64)),
15703            ]);
15704        }
15705        rs
15706    }
15707
15708    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
15709    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
15710        let view = self.db.view();
15711        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
15712        for e in expand(&view, self.id, None, Dir::Both) {
15713            // Skip edges with unknown etypes (only possible from corrupt large
15714            // TOPOLOGY section; function returns BTreeMap not Result).
15715            let Some(etype) = view.syms.resolve(e.etype) else {
15716                continue;
15717            };
15718            let etype = etype.to_string();
15719            let nbr = if e.src == self.id { e.dst } else { e.src };
15720            groups
15721                .entry(etype)
15722                .or_default()
15723                .insert(view.key_of(nbr).to_string());
15724        }
15725        groups
15726            .into_iter()
15727            .map(|(k, v)| (k, v.into_iter().collect()))
15728            .collect()
15729    }
15730}
15731
15732#[cfg(test)]
15733mod tests {
15734    use super::*;
15735    use core_rules::Predicate;
15736
15737    fn tmp_dir(name: &str) -> std::path::PathBuf {
15738        let d =
15739            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
15740        let _ = std::fs::remove_dir_all(&d);
15741        d
15742    }
15743
15744    fn fk_rule() -> RuleDef {
15745        RuleDef {
15746            name: "works_at".into(),
15747            src_label: "Person".into(),
15748            dst_label: "Org".into(),
15749            predicate: Predicate::KeyMatch {
15750                field: "org_id".into(),
15751            },
15752            edge_type: "WORKS_AT".into(),
15753            weight_prop: None,
15754            max_edges: None,
15755            approximate: false,
15756            via_label: None,
15757            via_edge: None,
15758            via_dir: None,
15759            namespace: None,
15760        }
15761    }
15762
15763    /// Regression guard for the no-views delta-copy fast path.
15764    ///
15765    /// When no views are defined, `pending_deltas_since().to_vec()` must never
15766    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
15767    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
15768    /// a count of 0 after the entire sequence proves the guard fires correctly.
15769    #[test]
15770    fn no_delta_copy_when_no_views() {
15771        DELTA_COPY_COUNT.with(|c| c.set(0));
15772        let dir = tmp_dir("no-delta-copy");
15773        {
15774            let mut db = GraphDb::open(&dir).unwrap();
15775            // Insert 50 Org + 50 Person nodes with FK links.
15776            for i in 0..50u32 {
15777                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
15778            }
15779            for i in 0..50u32 {
15780                db.insert_node(
15781                    "Person",
15782                    &format!("p{i}"),
15783                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
15784                )
15785                .unwrap();
15786            }
15787            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
15788            db.create_rule(fk_rule()).unwrap();
15789
15790            // Counter must stay 0 — no views, no copies.
15791            let copies = DELTA_COPY_COUNT.with(|c| c.get());
15792            assert_eq!(
15793                copies, 0,
15794                "pending_deltas_since().to_vec() called despite no views"
15795            );
15796
15797            // Derived edges must still be correct (the guard skips only the
15798            // empty delta propagation loop, not the rule application itself).
15799            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
15800            assert_eq!(
15801                nbrs,
15802                vec!["o0"],
15803                "rule must derive edges even with no views"
15804            );
15805        }
15806        let _ = std::fs::remove_dir_all(&dir);
15807    }
15808
15809    /// Gating regression: subscribe AFTER a backfill must see no stale events.
15810    /// subscribe BEFORE a backfill must see every edge-fire event.
15811    #[test]
15812    fn subscribe_after_backfill_no_stale_events() {
15813        let dir = tmp_dir("sub-after-backfill");
15814        {
15815            let mut db = GraphDb::open(&dir).unwrap();
15816            for i in 0..10u32 {
15817                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
15818                db.insert_node(
15819                    "Person",
15820                    &format!("p{i}"),
15821                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
15822                )
15823                .unwrap();
15824            }
15825            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
15826            db.create_rule(fk_rule()).unwrap();
15827
15828            // Subscribe AFTER the backfill — queue must be empty (no stale events).
15829            let sub = db.subscribe_all_rules().unwrap();
15830            // No events should have queued for the prior backfill.
15831            assert!(
15832                sub.try_recv().is_none(),
15833                "subscribe after backfill must see no stale events"
15834            );
15835
15836            // Inserting a new node now should fire an event (emit_deltas is now true).
15837            db.insert_node("Org", "o_new", vec![]).unwrap();
15838            db.insert_node(
15839                "Person",
15840                "p_new",
15841                vec![("org_id".into(), Value::Str("o_new".into()))],
15842            )
15843            .unwrap();
15844            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
15845            assert!(
15846                ev.is_some(),
15847                "edge-fire event must arrive after subscribe (emit_deltas=true)"
15848            );
15849        }
15850        let _ = std::fs::remove_dir_all(&dir);
15851    }
15852
15853    /// Gating regression: subscribe BEFORE a backfill → events flow.
15854    #[test]
15855    fn subscribe_before_backfill_events_flow() {
15856        let dir = tmp_dir("sub-before-backfill");
15857        {
15858            let mut db = GraphDb::open(&dir).unwrap();
15859            // Subscribe FIRST — emit_deltas becomes true.
15860            let sub = db.subscribe_all_rules().unwrap();
15861
15862            for i in 0..5u32 {
15863                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
15864                db.insert_node(
15865                    "Person",
15866                    &format!("p{i}"),
15867                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
15868                )
15869                .unwrap();
15870            }
15871            // Backfill fires with emit_deltas=true → events queued.
15872            db.create_rule(fk_rule()).unwrap();
15873
15874            // Should receive at least one edge-fired event from the backfill.
15875            let mut received = 0usize;
15876            while sub.try_recv().is_some() {
15877                received += 1;
15878            }
15879            assert!(
15880                received > 0,
15881                "subscribe before backfill must receive edge-fire events (got 0)"
15882            );
15883        }
15884        let _ = std::fs::remove_dir_all(&dir);
15885    }
15886
15887    /// Companion: when a view IS defined, the delta path fires and view values update.
15888    #[test]
15889    fn delta_copy_fires_when_view_exists() {
15890        use core_rules::ViewSource;
15891        DELTA_COPY_COUNT.with(|c| c.set(0));
15892        let dir = tmp_dir("delta-copy-with-view");
15893        {
15894            let mut db = GraphDb::open(&dir).unwrap();
15895            db.insert_node("Org", "o1", vec![]).unwrap();
15896            db.insert_node(
15897                "Person",
15898                "p1",
15899                vec![("org_id".into(), Value::Str("o1".into()))],
15900            )
15901            .unwrap();
15902            // Declare a Degree view so is_empty() returns false.
15903            db.create_view(ViewDef {
15904                name: "degree_out".into(),
15905                label: "Person".into(),
15906                view_prop: "degree_out".into(),
15907                source: ViewSource::Degree {
15908                    edge_type: "WORKS_AT".into(),
15909                    direction: Direction::Out,
15910                },
15911            })
15912            .unwrap();
15913            db.create_rule(fk_rule()).unwrap();
15914
15915            // At least one delta copy should have happened (CreateRule backfill).
15916            let copies = DELTA_COPY_COUNT.with(|c| c.get());
15917            assert!(
15918                copies > 0,
15919                "expected delta copy to fire when a view is defined"
15920            );
15921
15922            // View value should be computed: p1 has one WORKS_AT out-edge.
15923            let info = db.node_info("p1").unwrap();
15924            let degree = info.props.get("degree_out");
15925            assert!(
15926                degree.is_some(),
15927                "view prop should be written to node props"
15928            );
15929        }
15930        let _ = std::fs::remove_dir_all(&dir);
15931    }
15932
15933    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
15934    /// derived-edge-driven view values reflect the as-of state rather than just
15935    /// the initial backfill written at `CreateView` time.
15936    ///
15937    /// Base WAL frames (indices 0..=5 before history markers):
15938    ///   0: insert Org "o1"
15939    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
15940    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
15941    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
15942    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
15943    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
15944    ///
15945    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
15946    /// no-op), so the total commit count is higher than the base frame count.
15947    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
15948    ///
15949    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
15950    /// initial backfill value (0) instead of reflecting the replayed derived edges.
15951    #[test]
15952    fn open_at_derived_edge_view_values_correct() {
15953        use core_rules::ViewSource;
15954        let dir = tmp_dir("open-at-view-rebuild");
15955        {
15956            let mut db = GraphDb::open(&dir).unwrap();
15957            // frame 0
15958            db.insert_node("Org", "o1", vec![]).unwrap();
15959            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
15960            db.create_view(ViewDef {
15961                name: "employee_count".into(),
15962                label: "Org".into(),
15963                view_prop: "emp".into(),
15964                source: ViewSource::Degree {
15965                    edge_type: "WORKS_AT".into(),
15966                    direction: Direction::In,
15967                },
15968            })
15969            .unwrap();
15970            // frame 2: create rule — no Persons yet; backfill is a no-op
15971            db.create_rule(fk_rule()).unwrap();
15972            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
15973            db.insert_node(
15974                "Person",
15975                "p1",
15976                vec![("org_id".into(), Value::Str("o1".into()))],
15977            )
15978            .unwrap();
15979            // frame 4: p2 — degree = 2
15980            db.insert_node(
15981                "Person",
15982                "p2",
15983                vec![("org_id".into(), Value::Str("o1".into()))],
15984            )
15985            .unwrap();
15986            // frame 5: p3 — degree = 3
15987            db.insert_node(
15988                "Person",
15989                "p3",
15990                vec![("org_id".into(), Value::Str("o1".into()))],
15991            )
15992            .unwrap();
15993            // Sanity: normal open sees degree = 3.
15994            assert_eq!(
15995                db.get_view_prop("o1", "emp"),
15996                Some(Value::Int(3)),
15997                "normal db must show degree 3 after 3 derived edges"
15998            );
15999        } // WAL flushed
16000
16001        // Re-open normally to get the authoritative reference value.
16002        let normal_db = GraphDb::open(&dir).unwrap();
16003        let normal_emp = normal_db.get_view_prop("o1", "emp");
16004        assert_eq!(
16005            normal_emp,
16006            Some(Value::Int(3)),
16007            "re-opened normal db must show degree 3"
16008        );
16009
16010        // Latest as-of (last WAL commit): must match the normal open.
16011        // History-marker frames are appended after each rule-fire, so the total
16012        // commit count is computed dynamically rather than hardcoded.
16013        let total = crate::wal_commit_count_at(&dir).unwrap();
16014        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
16015        assert_eq!(
16016            aof_latest.get_view_prop("o1", "emp"),
16017            normal_emp,
16018            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
16019        );
16020
16021        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
16022        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
16023        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
16024        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
16025        assert_eq!(
16026            aof_mid.get_view_prop("o1", "emp"),
16027            Some(Value::Int(1)),
16028            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
16029        );
16030
16031        let _ = std::fs::remove_dir_all(&dir);
16032    }
16033
16034    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
16035    /// as-of instances never commit, so distribute_events never runs and any
16036    /// subscription would wait forever.
16037    #[test]
16038    fn subscribe_on_as_of_returns_read_only_error() {
16039        let dir = tmp_dir("sub-as-of-read-only");
16040        {
16041            let mut db = GraphDb::open(&dir).unwrap();
16042            db.insert_node("Org", "o1", vec![]).unwrap();
16043            db.create_rule(fk_rule()).unwrap();
16044        }
16045        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
16046
16047        assert!(
16048            matches!(
16049                aof.subscribe_all_rules(),
16050                Err(core_storage::GraphError::ReadOnly)
16051            ),
16052            "subscribe_all_rules on as-of must return ReadOnly"
16053        );
16054        assert!(
16055            matches!(
16056                aof.subscribe_writes(),
16057                Err(core_storage::GraphError::ReadOnly)
16058            ),
16059            "subscribe_writes on as-of must return ReadOnly"
16060        );
16061        assert!(
16062            matches!(
16063                aof.subscribe_rule("works_at"),
16064                Err(core_storage::GraphError::ReadOnly)
16065            ),
16066            "subscribe_rule on as-of must return ReadOnly"
16067        );
16068        let _ = std::fs::remove_dir_all(&dir);
16069    }
16070
16071    /// Regression: a failed dense WAL rewrite must not leave speculative
16072    /// interns in `syms`. If it does, the next successful mutation logs an
16073    /// `Intern` record with an inflated id; replay (which never saw the
16074    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
16075    #[test]
16076    fn dense_rewrite_error_rolls_back_speculative_interns() {
16077        let dir = tmp_dir("dense-rewrite-rollback");
16078        {
16079            let mut db = GraphDb::open(&dir).unwrap();
16080            db.insert_node("Person", "a", vec![]).unwrap();
16081
16082            // Bypass MutPreview validation to hit the rewrite's own error path
16083            // (same shape as an id-exhaustion failure mid-rewrite). The
16084            // InsertEdge arm interns the edge type before it resolves keys.
16085            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
16086                edge_type: "ORPHAN_TYPE".into(),
16087                src_key: "missing".into(),
16088                dst_key: "a".into(),
16089            }]);
16090            assert!(err.is_err(), "rewrite of a missing src key must fail");
16091            assert_eq!(
16092                db.syms.get("ORPHAN_TYPE"),
16093                None,
16094                "failed rewrite must roll back speculative interns"
16095            );
16096
16097            // A later successful mutation must produce a replayable WAL.
16098            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
16099        }
16100        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
16101        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
16102        let _ = std::fs::remove_dir_all(&dir);
16103    }
16104}