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. These are the records a truncating snapshot
400/// re-emits into its baseline WAL.
401fn is_declaration(rec: &WalRecord) -> bool {
402    matches!(
403        rec,
404        WalRecord::EnableFulltext { .. }
405            | WalRecord::DisableFulltext { .. }
406            | WalRecord::EnableIndex { .. }
407            | WalRecord::DisableIndex { .. }
408    ) || *rec == core_storage::wal::MULTIPLICITY_ENABLED
409}
410
411/// Database-wide counters plus per-rule budget/fire stats.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
413pub struct Stats {
414    pub nodes_live: usize,
415    pub nodes_tombstoned: usize,
416    pub edges: u64,
417    pub rules: Vec<RuleStats>,
418    /// How many writes hit the rule-chaining depth cap with work still pending,
419    /// since this handle was opened. Non-zero means some derived edges beyond
420    /// the cap are stale and no single later write will repair them: split the
421    /// rule chain or shorten it. Never persisted, so it resets on reopen.
422    #[serde(default)]
423    pub chain_truncations: u64,
424    /// The oldest commit index history still reaches (the WAL horizon floor).
425    /// `0` means nothing has been pruned and history is complete; a non-zero
426    /// value means events before that commit were pruned and are gone.
427    #[serde(default)]
428    pub history_floor: u64,
429    /// Live node counts per namespace, in name order. Always carries
430    /// `default` — a store is at least its default namespace — so a
431    /// single-tenant store reads `[{"name":"default", …}]` and a reader can
432    /// tell "no namespaces in use" from one entry.
433    #[serde(default)]
434    pub namespaces: Vec<NamespaceStats>,
435}
436
437/// Live node count for one namespace; one entry of [`Stats::namespaces`].
438#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
439pub struct NamespaceStats {
440    pub name: String,
441    pub nodes_live: usize,
442}
443
444/// One rule's provenance size, trip latch, and fire counter.
445///
446/// `tripped` is a one-way latch: once set, the engine adds no new edges for
447/// that rule until [`GraphDb::rebuild_rule`] (and only if the full desired
448/// set then fits). `fires` counts `on_node_changed` evaluations plus
449/// backfill/rebuild participant ticks (rebuild counts even when it is a
450/// provenance no-op).
451#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
452pub struct RuleStats {
453    pub name: String,
454    pub edges: u64,
455    pub tripped: bool,
456    pub fires: u64,
457    /// Whether this rule uses the approximate IVF-Flat candidate path.
458    pub approximate: bool,
459    /// `Some` while this rule's vector index is still being built.
460    ///
461    /// The rule derives **no** edges until it is `None`: the backfill is one
462    /// commit that runs after the index is whole, so a caller never sees a
463    /// partial edge set. Absent from the JSON when the rule is not building,
464    /// which is every rule created over a corpus at or below
465    /// [`core_rules::HNSW_BUILD_BATCH`] vectors.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub building: Option<BuildProgress>,
468}
469
470/// One entry in the slow-query ring buffer.
471#[derive(Debug, Clone, Serialize)]
472pub struct SlowQueryEntry {
473    /// Execution time in whole milliseconds.
474    pub ms: u64,
475    /// The Cypher query string that was slow.
476    pub query: String,
477    /// The commit sequence number at the time the query ran.
478    pub at_commit: u64,
479}
480
481/// Snapshot of the slow-query log returned by [`GraphDb::slow_query_snapshot`].
482#[derive(Debug, Clone, Serialize)]
483pub struct SlowQuerySnapshot {
484    /// Current threshold in milliseconds (0 = disabled).
485    pub threshold_ms: u64,
486    /// Total number of slow queries ever recorded (not capped by ring size).
487    pub count: u64,
488    /// Most-recent slow queries (up to 16), oldest first.
489    pub last: Vec<SlowQueryEntry>,
490}
491
492/// Internal ring-buffer state protected by a `Mutex` so `query(&self)` can
493/// write to it without a mutable borrow.
494struct SlowQueryLog {
495    entries: std::collections::VecDeque<SlowQueryEntry>,
496    total: u64,
497}
498
499/// Maximum number of entries kept in the slow-query ring buffer.
500const SLOW_QUERY_RING_CAP: usize = 16;
501
502/// Wire summary of a [`Predicate`]. JSON only — `Explanation` is never
503/// bincode-persisted (WAL/snapshots store `RuleDef` bytes, not this type).
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505pub struct PredicateSummary {
506    pub kind: String,
507    pub fields: Vec<String>,
508    pub min: Option<f64>,
509    pub tolerance: Option<f64>,
510    pub km: Option<f64>,
511    pub parts: Option<Vec<PredicateSummary>>,
512    /// True when the owning rule has `approximate=true` (IVF-Flat candidate path).
513    /// Always false for predicates reported without rule context (sub-predicates in `parts`).
514    #[serde(default)]
515    pub approximate: bool,
516}
517
518impl From<&Predicate> for PredicateSummary {
519    fn from(p: &Predicate) -> Self {
520        match p {
521            Predicate::KeyMatch { field } => PredicateSummary {
522                kind: "key_match".into(),
523                fields: vec![field.clone()],
524                min: None,
525                tolerance: None,
526                km: None,
527                parts: None,
528                approximate: false,
529            },
530            Predicate::FieldEqual { field } => PredicateSummary {
531                kind: "field_equal".into(),
532                fields: vec![field.clone()],
533                min: None,
534                tolerance: None,
535                km: None,
536                parts: None,
537                approximate: false,
538            },
539            Predicate::Overlap { field, min } => PredicateSummary {
540                kind: "overlap".into(),
541                fields: vec![field.clone()],
542                min: Some(*min),
543                tolerance: None,
544                km: None,
545                parts: None,
546                approximate: false,
547            },
548            Predicate::NumericWithin { field, tolerance } => PredicateSummary {
549                kind: "numeric_within".into(),
550                fields: vec![field.clone()],
551                min: None,
552                tolerance: Some(*tolerance),
553                km: None,
554                parts: None,
555                approximate: false,
556            },
557            Predicate::GeoRadius { field, km } => PredicateSummary {
558                kind: "geo_radius".into(),
559                fields: vec![field.clone()],
560                min: None,
561                tolerance: None,
562                km: Some(*km),
563                parts: None,
564                approximate: false,
565            },
566            Predicate::VectorSimilar { field, min } => PredicateSummary {
567                kind: "vector_similar".into(),
568                fields: vec![field.clone()],
569                min: Some(*min),
570                tolerance: None,
571                km: None,
572                parts: None,
573                approximate: false,
574            },
575            Predicate::All(inner) => {
576                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
577                let mut fields = Vec::new();
578                for part in &parts {
579                    for f in &part.fields {
580                        if !fields.contains(f) {
581                            fields.push(f.clone());
582                        }
583                    }
584                }
585                PredicateSummary {
586                    kind: "all".into(),
587                    fields,
588                    min: None,
589                    tolerance: None,
590                    km: None,
591                    parts: Some(parts),
592                    approximate: false,
593                }
594            }
595            Predicate::Any(inner) => {
596                let parts: Vec<PredicateSummary> = inner.iter().map(Self::from).collect();
597                let mut fields = Vec::new();
598                for part in &parts {
599                    for f in &part.fields {
600                        if !fields.contains(f) {
601                            fields.push(f.clone());
602                        }
603                    }
604                }
605                PredicateSummary {
606                    kind: "any".into(),
607                    fields,
608                    min: None,
609                    tolerance: None,
610                    km: None,
611                    parts: Some(parts),
612                    approximate: false,
613                }
614            }
615        }
616    }
617}
618
619/// Snapshot of a live node's key, label, and columnar properties.
620///
621/// `props` is a [`BTreeMap`] so field order is deterministic (sorted by name)
622/// regardless of insert order or the columnar store's `HashMap` iteration.
623///
624/// Deliberately does not derive `Serialize`: `Value`'s serde form is
625/// internally tagged. Wire JSON is built by `value_to_json` in the server.
626#[derive(Debug, Clone, PartialEq)]
627pub struct NodeInfo {
628    pub key: String,
629    pub label: String,
630    pub props: BTreeMap<String, Value>,
631}
632
633/// Counts returned by [`GraphDb::delete_node`].
634#[derive(Debug, Clone, PartialEq, Eq, Default)]
635pub struct DeleteReport {
636    /// Number of manual (user-inserted) edges removed.
637    pub manual_edges: u64,
638    /// Number of derived (rule-owned) edges retracted.
639    pub derived_edges: u64,
640}
641
642/// One directed edge incident on a node, with provenance membership.
643///
644/// `derived` is true iff `(edge_type, src, dst)` is in the rule engine's
645/// Plan-8 `by_node` provenance index.
646#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
647pub struct EdgeInfo {
648    pub edge_type: String,
649    pub src_key: String,
650    pub dst_key: String,
651    pub derived: bool,
652}
653
654/// One directed edge incident on a node at a point in WAL history, with the
655/// rule that derived it when it is rule-owned.
656///
657/// Returned by [`GraphDb::edges_at`] (sorted by `(edge_type, src_key, dst_key)`)
658/// and by [`GraphDb::what_if_set_prop`].
659#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
660pub struct EdgeAt {
661    pub edge_type: String,
662    pub src_key: String,
663    pub dst_key: String,
664    /// `true` when a rule wrote the edge (`DerivedEdgeAdded` in the WAL, or a
665    /// live provenance entry).
666    pub derived: bool,
667    /// The rule that derived the edge. `None` for a manual edge.
668    pub rule: Option<String>,
669}
670
671/// The derived edges a hypothetical property change would retract and derive.
672///
673/// Returned by [`GraphDb::what_if_set_prop`]. Both lists are sorted by
674/// `(edge_type, src_key, dst_key)` and every entry is rule-derived.
675#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
676pub struct WhatIf {
677    /// Derived edges that exist now and would be retracted.
678    pub lost: Vec<EdgeAt>,
679    /// Derived edges that do not exist now and would be derived.
680    pub gained: Vec<EdgeAt>,
681}
682
683/// An edge with mask-aware endpoint visibility.
684///
685/// Returned by [`GraphDb::node_edges_masked`] in [`crate::mask::MaskMode::Stub`]
686/// mode — hidden endpoints carry `*_restricted: true`.
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct MaskedEdge {
689    pub edge_type: String,
690    pub src_key: String,
691    /// `true` when `src_key` is in the DB but hidden from the mask.
692    pub src_restricted: bool,
693    pub dst_key: String,
694    /// `true` when `dst_key` is in the DB but hidden from the mask.
695    pub dst_restricted: bool,
696    pub derived: bool,
697}
698
699/// Result of a mask-aware node lookup via [`GraphDb::node_info_masked`].
700///
701/// `None` from that method means the key does not exist (→ 404).
702/// `Some(Restricted)` is only produced when `mask.mode() == MaskMode::Stub`.
703#[derive(Debug, PartialEq)]
704pub enum MaskedNodeResult {
705    Visible(NodeInfo),
706    /// Node exists in the DB but is hidden from this mask.
707    Restricted,
708}
709
710/// One rule-owned edge between two nodes, with the rule name, edge type,
711/// direction (src_key → dst_key), and weight if the rule stores one.
712#[derive(Debug, Clone, PartialEq, Serialize)]
713pub struct Explanation {
714    pub rule: String,
715    pub edge_type: String,
716    pub src_key: String,
717    pub dst_key: String,
718    pub weight: Option<f64>,
719    pub predicate: PredicateSummary,
720    /// For a via-hop rule, the edge type the rule hops over to reach its
721    /// candidates. `None` for a plain two-node rule. A via-hop rule whose
722    /// `via_edge` is itself rule-derived is the chaining case: the hop edge
723    /// was written by another rule in the same commit.
724    #[serde(default)]
725    pub via_edge: Option<String>,
726}
727
728/// Report returned by [`GraphDb::backup_to`].
729#[derive(Debug, Clone)]
730pub struct BackupReport {
731    /// Filenames copied into the destination directory (sorted ascending).
732    pub files: Vec<String>,
733    /// Total bytes written across all copied files.
734    pub bytes: u64,
735    /// `true` when the destination opened cleanly and passed post-copy checks.
736    ///
737    /// For stores that have a `snapshot.bin` this means: all V8 section CRCs
738    /// matched **and** the destination opened without error.
739    ///
740    /// For WAL-only stores (no `snapshot.bin`) there is no snapshot to
741    /// CRC-check; `verified` is `true` when the destination opened and
742    /// replayed the WAL without error (record-level checksums in the WAL
743    /// provide the integrity signal, not section CRCs).
744    pub verified: bool,
745}
746
747/// One directed edge in export form, with optional rule attribution for derived edges.
748///
749/// Returned by [`GraphDb::all_edges_for_export`].
750///
751/// Does not derive `Eq`/`Ord`: `weight` is an `f64` and NaN breaks a total
752/// order. Callers that need a stable edge ordering already sort by
753/// `(edge_type, src, dst)` explicitly (see `all_edges_for_export`).
754#[derive(Debug, Clone, PartialEq, PartialOrd)]
755pub struct ExportEdge {
756    pub edge_type: String,
757    pub src: String,
758    pub dst: String,
759    pub derived: bool,
760    /// Rule name that created this edge, if derived. `None` for manual edges.
761    pub rule: Option<String>,
762    /// The creating rule's declared `weight_prop`, read off this edge, when
763    /// derived and numeric (`Int`/`Float`). `None` for manual edges, derived
764    /// edges whose rule declares no `weight_prop`, or a non-numeric value.
765    pub weight: Option<f64>,
766}
767
768/// One edge type's shape, as [`GraphDb::edge_type_census`] counts it.
769///
770/// Deliberately per *type* and not per edge: everything here is a summary a
771/// caller can print in one line, and none of it costs a record per edge.
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct EdgeTypeCensus {
774    pub edge_type: String,
775    /// Directed edges of this type. Counted the way
776    /// [`GraphDb::edge_count`] counts: each edge once, from its source.
777    pub edges: u64,
778    /// Every label seen on a source of this type, sorted.
779    pub src_labels: Vec<String>,
780    /// Every label seen on a destination of this type, sorted.
781    pub dst_labels: Vec<String>,
782    /// The rules that declare this `edge_type`, sorted. Empty for a type
783    /// written by hand.
784    pub rules: Vec<String>,
785    /// `(src key, dst key)` of the first edge of this type in the store's own
786    /// id order — a real pair to quote in an example.
787    pub sample: Option<(String, String)>,
788}
789
790/// Construct the standard write-query result set (columns: created, properties_set, deleted).
791fn write_result_set() -> ResultSet {
792    ResultSet::new(vec![
793        "created".into(),
794        "properties_set".into(),
795        "deleted".into(),
796    ])
797}
798
799fn resolve_merge_set_value(op: &Operand, params: &BTreeMap<String, Value>) -> Result<Value> {
800    match op {
801        Operand::Lit(v) => Ok(v.clone()),
802        Operand::Param(name) => params
803            .get(name)
804            .cloned()
805            .ok_or_else(|| GraphError::QueryError {
806                detail: format!("missing parameter `{name}`"),
807            }),
808        _ => Err(GraphError::QueryError {
809            detail: "ON CREATE/ON MATCH SET value must be a literal or $parameter".into(),
810        }),
811    }
812}
813
814fn operand_node_vars(op: &Operand, out: &mut Vec<String>) {
815    match op {
816        Operand::Prop { var, .. } | Operand::Var(var) => {
817            if !out.contains(var) {
818                out.push(var.clone());
819            }
820        }
821        Operand::FuncCall { args, .. } => {
822            for arg in args {
823                operand_node_vars(arg, out);
824            }
825        }
826        Operand::BinArith { left, right, .. } => {
827            operand_node_vars(left, out);
828            operand_node_vars(right, out);
829        }
830        Operand::Case { branches, default } => {
831            // Branch conditions reference vars already bound (and mask-filtered)
832            // by the MATCH phase, so collecting from the value operands + ELSE
833            // is sufficient for RETURN-projection var discovery.
834            for (_, value) in branches {
835                operand_node_vars(value, out);
836            }
837            if let Some(d) = default {
838                operand_node_vars(d, out);
839            }
840        }
841        Operand::Index { base, index } => {
842            operand_node_vars(base, out);
843            operand_node_vars(index, out);
844        }
845        Operand::Lit(_) | Operand::Param(_) => {}
846    }
847}
848
849fn ret_node_vars(items: &[RetItem]) -> Vec<String> {
850    let mut out = Vec::new();
851    for item in items {
852        match &item.value {
853            RetVal::Var(v) | RetVal::Prop { var: v, .. } => {
854                if !out.contains(v) {
855                    out.push(v.clone());
856                }
857            }
858            RetVal::FuncCall { args, .. } => {
859                for arg in args {
860                    operand_node_vars(arg, &mut out);
861                }
862            }
863            RetVal::ScalarExpr(op) => operand_node_vars(op, &mut out),
864            RetVal::Agg { .. } => {}
865        }
866    }
867    out
868}
869
870fn add_var(out: &mut Vec<String>, v: &str) {
871    if !out.iter().any(|x| x == v) {
872        out.push(v.to_string());
873    }
874}
875
876fn pattern_node_vars(pats: &[Pattern]) -> Vec<String> {
877    let mut out = Vec::new();
878    for p in pats {
879        if let Some(v) = &p.start.var {
880            add_var(&mut out, v);
881        }
882        for (_, dest) in &p.chain {
883            if let Some(v) = &dest.var {
884                add_var(&mut out, v);
885            }
886        }
887    }
888    out
889}
890
891fn pattern_rel_vars(pats: &[Pattern]) -> Vec<String> {
892    let mut out = Vec::new();
893    for p in pats {
894        for (rel, _) in &p.chain {
895            if rel.hops.is_none() {
896                if let Some(v) = &rel.var {
897                    add_var(&mut out, v);
898                }
899            }
900        }
901    }
902    out
903}
904
905fn rel_type_alias(var: &str) -> String {
906    format!("__rt_{var}")
907}
908
909fn ret_column_name(item: &RetItem) -> String {
910    if let Some(alias) = &item.alias {
911        return alias.clone();
912    }
913    // The same naming rule the planner and the executor use, so a
914    // write-statement RETURN names its columns exactly as a read query does.
915    // An aggregate is not legal in a write-statement RETURN; it keeps the
916    // placeholder it always had.
917    ret_val_label(&item.value).unwrap_or_else(|| "<agg>".to_string())
918}
919
920fn eval_set_return_operand<F: Fs>(
921    db: &GraphDb<F>,
922    match_rs: &ResultSet,
923    row: usize,
924    rel_vars: &[String],
925    op: &Operand,
926    params: &BTreeMap<String, Value>,
927) -> Result<Option<Value>> {
928    match op {
929        Operand::Lit(v) => Ok(Some(v.clone())),
930        Operand::Param(name) => params.get(name).cloned().ok_or_else(|| GraphError::QueryError {
931            detail: format!("missing parameter `{name}`"),
932        }).map(Some),
933        Operand::Var(name) if rel_vars.iter().any(|r| r == name) => Err(GraphError::QueryError {
934            detail: format!(
935                "cannot return relationship variable '{name}' bare; return its properties ({name}.field) instead"
936            ),
937        }),
938        Operand::Var(name) => Ok(match_rs.get(row, name).cloned()),
939        Operand::Prop { var, field } => {
940            if rel_vars.iter().any(|r| r == var) {
941                return Ok(None);
942            }
943            let Some(Value::Str(key)) = match_rs.get(row, var) else {
944                return Ok(None);
945            };
946            if let Some(v) = db.get_prop(key, field) {
947                return Ok(Some(v));
948            }
949            // Same stored-wins identity fallback as the read path:
950            // n.key / n.id / n.label, not only get_prop.
951            Ok(match field.as_str() {
952                "key" | "id" => Some(Value::Str(key.clone())),
953                "label" => db
954                    .node_ref(key)
955                    .map(|n| Value::Str(n.label().to_owned())),
956                _ => None,
957            })
958        }
959        Operand::FuncCall { name, args } => {
960            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
961        }
962        Operand::BinArith { op, left, right } => {
963            let lv = eval_set_return_operand(db, match_rs, row, rel_vars, left, params)?;
964            let rv = eval_set_return_operand(db, match_rs, row, rel_vars, right, params)?;
965            eval_set_return_arith(op, lv, rv)
966        }
967        Operand::Case { branches, default } => {
968            for (cond, value) in branches {
969                if eval_set_return_expr(db, match_rs, row, rel_vars, cond, params, 0)? {
970                    return eval_set_return_operand(db, match_rs, row, rel_vars, value, params);
971                }
972            }
973            match default {
974                Some(d) => eval_set_return_operand(db, match_rs, row, rel_vars, d, params),
975                None => Ok(None),
976            }
977        }
978        Operand::Index { base, index } => {
979            let base_val = eval_set_return_operand(db, match_rs, row, rel_vars, base, params)?;
980            let idx_val = eval_set_return_operand(db, match_rs, row, rel_vars, index, params)?;
981            Ok(core_query::value_ops::index_list(base_val, idx_val))
982        }
983    }
984}
985
986fn eval_set_return_expr<F: Fs>(
987    db: &GraphDb<F>,
988    match_rs: &ResultSet,
989    row: usize,
990    rel_vars: &[String],
991    expr: &Expr,
992    params: &BTreeMap<String, Value>,
993    depth: u32,
994) -> Result<bool> {
995    if depth > 256 {
996        return Err(GraphError::QueryError {
997            detail: "expression nesting too deep".into(),
998        });
999    }
1000    match expr {
1001        Expr::And(lhs, rhs) => {
1002            let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
1003            let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
1004            Ok(l && r)
1005        }
1006        Expr::Or(lhs, rhs) => {
1007            let l = eval_set_return_expr(db, match_rs, row, rel_vars, lhs, params, depth + 1)?;
1008            let r = eval_set_return_expr(db, match_rs, row, rel_vars, rhs, params, depth + 1)?;
1009            Ok(l || r)
1010        }
1011        Expr::Not(inner) => Ok(!eval_set_return_expr(
1012            db,
1013            match_rs,
1014            row,
1015            rel_vars,
1016            inner,
1017            params,
1018            depth + 1,
1019        )?),
1020        Expr::Cmp { lhs, op, rhs } => {
1021            let l = eval_set_return_operand(db, match_rs, row, rel_vars, lhs, params)?;
1022            let r = eval_set_return_operand(db, match_rs, row, rel_vars, rhs, params)?;
1023            match (l, r) {
1024                (Some(a), Some(b)) => Ok(eval_cmp(op, &a, &b)),
1025                _ => Ok(false),
1026            }
1027        }
1028        Expr::Truthy(op) => {
1029            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1030            Ok(match val {
1031                None => false,
1032                Some(Value::Bool(b)) => b,
1033                Some(Value::Int(n)) => n != 0,
1034                Some(Value::Float(f)) => f != 0.0,
1035                Some(Value::Str(s)) => !s.is_empty(),
1036                Some(Value::List(v)) => !v.is_empty(),
1037                Some(Value::Map(m)) => !m.is_empty(),
1038            })
1039        }
1040        Expr::IsNull(op) => {
1041            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1042            Ok(val.is_none())
1043        }
1044        Expr::IsNotNull(op) => {
1045            let val = eval_set_return_operand(db, match_rs, row, rel_vars, op, params)?;
1046            Ok(val.is_some())
1047        }
1048        Expr::In { expr, list } => {
1049            let Some(needle) = eval_set_return_operand(db, match_rs, row, rel_vars, expr, params)?
1050            else {
1051                return Ok(false);
1052            };
1053            for item_op in list {
1054                match eval_set_return_operand(db, match_rs, row, rel_vars, item_op, params)? {
1055                    None => {}
1056                    Some(Value::List(items)) => {
1057                        for item in items {
1058                            if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) {
1059                                return Ok(true);
1060                            }
1061                        }
1062                    }
1063                    Some(item) if eval_cmp(&core_query::CmpOp::Eq, &needle, &item) => {
1064                        return Ok(true);
1065                    }
1066                    Some(_) => {}
1067                }
1068            }
1069            Ok(false)
1070        }
1071    }
1072}
1073
1074fn eval_set_return_arith(
1075    op: &ArithOp,
1076    lv: Option<Value>,
1077    rv: Option<Value>,
1078) -> Result<Option<Value>> {
1079    match (lv, rv) {
1080        (None, _) | (_, None) => Ok(None),
1081        (Some(Value::Int(a)), Some(Value::Int(b))) => {
1082            let result = match op {
1083                ArithOp::Sub => a.saturating_sub(b),
1084                ArithOp::Mul => a.saturating_mul(b),
1085                ArithOp::Add => a.saturating_add(b),
1086                ArithOp::Div => {
1087                    if b == 0 {
1088                        return Err(GraphError::QueryError {
1089                            detail: "division by zero".into(),
1090                        });
1091                    }
1092                    a.checked_div(b).unwrap_or(i64::MAX)
1093                }
1094            };
1095            Ok(Some(Value::Int(result)))
1096        }
1097        (Some(lv), Some(rv)) => {
1098            let a = match &lv {
1099                Value::Float(f) => *f,
1100                Value::Int(i) => *i as f64,
1101                _ => {
1102                    return Err(GraphError::QueryError {
1103                        detail: format!("arithmetic operand must be numeric, got {lv:?}"),
1104                    })
1105                }
1106            };
1107            let b = match &rv {
1108                Value::Float(f) => *f,
1109                Value::Int(i) => *i as f64,
1110                _ => {
1111                    return Err(GraphError::QueryError {
1112                        detail: format!("arithmetic operand must be numeric, got {rv:?}"),
1113                    })
1114                }
1115            };
1116            let result = match op {
1117                ArithOp::Sub => a - b,
1118                ArithOp::Mul => a * b,
1119                ArithOp::Add => a + b,
1120                ArithOp::Div => {
1121                    if b == 0.0 {
1122                        return Err(GraphError::QueryError {
1123                            detail: "division by zero".into(),
1124                        });
1125                    }
1126                    a / b
1127                }
1128            };
1129            Ok(Some(Value::Float(result)))
1130        }
1131    }
1132}
1133
1134fn eval_set_return_func<F: Fs>(
1135    db: &GraphDb<F>,
1136    match_rs: &ResultSet,
1137    row: usize,
1138    rel_vars: &[String],
1139    name: &str,
1140    args: &[Operand],
1141    params: &BTreeMap<String, Value>,
1142) -> Result<Option<Value>> {
1143    let norm = name.to_ascii_lowercase();
1144    if norm == "type" {
1145        if args.len() != 1 {
1146            return Err(GraphError::QueryError {
1147                detail: format!("type() requires exactly 1 argument, got {}", args.len()),
1148            });
1149        }
1150        let Operand::Var(rel) = &args[0] else {
1151            return Err(GraphError::QueryError {
1152                detail: "type() argument must be a relationship variable (e.g. type(r))".into(),
1153            });
1154        };
1155        return Ok(match_rs.get(row, &rel_type_alias(rel)).cloned());
1156    }
1157    if norm == "key" || norm == "id" {
1158        let fname = if norm == "id" { "id" } else { "key" };
1159        if args.len() != 1 {
1160            return Err(GraphError::QueryError {
1161                detail: format!("{fname}() requires exactly 1 argument, got {}", args.len()),
1162            });
1163        }
1164        let Operand::Var(var) = &args[0] else {
1165            return Err(GraphError::QueryError {
1166                detail: format!("{fname}() argument must be a node variable (e.g. {fname}(n))"),
1167            });
1168        };
1169        if rel_vars.iter().any(|r| r == var) {
1170            return Err(GraphError::QueryError {
1171                detail: format!("{fname}() argument `{var}` is a relationship, not a node"),
1172            });
1173        }
1174        // MATCH rows bind node variables to their key string, so the column
1175        // value *is* the key. `id()` aliases `key()`.
1176        return Ok(match_rs.get(row, var).cloned());
1177    }
1178    let mut vals = Vec::with_capacity(args.len());
1179    for arg in args {
1180        vals.push(eval_set_return_operand(
1181            db, match_rs, row, rel_vars, arg, params,
1182        )?);
1183    }
1184    match norm.as_str() {
1185        "tolower" => {
1186            if vals.len() != 1 {
1187                return Err(GraphError::QueryError {
1188                    detail: format!("toLower() requires exactly 1 argument, got {}", vals.len()),
1189                });
1190            }
1191            Ok(vals[0].clone().map(|val| match val {
1192                Value::Str(s) => Value::Str(s.to_ascii_lowercase()),
1193                other => other,
1194            }))
1195        }
1196        "toupper" => {
1197            if vals.len() != 1 {
1198                return Err(GraphError::QueryError {
1199                    detail: format!("toUpper() requires exactly 1 argument, got {}", vals.len()),
1200                });
1201            }
1202            Ok(vals[0].clone().map(|val| match val {
1203                Value::Str(s) => Value::Str(s.to_ascii_uppercase()),
1204                other => other,
1205            }))
1206        }
1207        "size" => match vals.first().cloned().flatten() {
1208            None => Ok(None),
1209            Some(Value::Str(s)) => Ok(Some(Value::Int(s.len() as i64))),
1210            Some(Value::List(items)) => Ok(Some(Value::Int(items.len() as i64))),
1211            Some(_) => Ok(None),
1212        },
1213        "coalesce" => Ok(vals.into_iter().flatten().next()),
1214        "abs" => match vals.first().cloned().flatten() {
1215            None => Ok(None),
1216            Some(Value::Int(n)) => Ok(Some(Value::Int(n.saturating_abs()))),
1217            Some(Value::Float(f)) => Ok(Some(Value::Float(f.abs()))),
1218            Some(_) => Ok(None),
1219        },
1220        "round" => match vals.first().cloned().flatten() {
1221            None => Ok(None),
1222            Some(Value::Float(f)) => Ok(Some(Value::Float(f.round()))),
1223            Some(Value::Int(n)) => Ok(Some(Value::Int(n))),
1224            Some(_) => Ok(None),
1225        },
1226        "decay" => {
1227            if vals.len() != 3 {
1228                return Err(GraphError::QueryError {
1229                    detail: format!("decay() requires exactly 3 arguments, got {}", vals.len()),
1230                });
1231            }
1232            match (vals[0].clone(), vals[1].clone(), vals[2].clone()) {
1233                (None, _, _) | (_, None, _) | (_, _, None) => Ok(None),
1234                (Some(b), Some(a), Some(h)) => {
1235                    let numeric = |v: Value| -> Result<f64> {
1236                        match v {
1237                            Value::Int(n) => Ok(n as f64),
1238                            Value::Float(f) => Ok(f),
1239                            other => Err(GraphError::QueryError {
1240                                detail: format!(
1241                                    "decay() requires numeric arguments, got {other:?}"
1242                                ),
1243                            }),
1244                        }
1245                    };
1246                    let b = numeric(b)?;
1247                    let a = numeric(a)?;
1248                    let h = numeric(h)?;
1249                    if h <= 0.0 {
1250                        return Err(GraphError::QueryError {
1251                            detail: "decay() requires halflife > 0".into(),
1252                        });
1253                    }
1254                    Ok(Some(Value::Float(b * 0.5f64.powf(a / h))))
1255                }
1256            }
1257        }
1258        _ => Err(GraphError::QueryError {
1259            detail: format!(
1260                "unknown function `{name}`; supported: toLower, toUpper, size, coalesce, type, abs, round, decay, key, id"
1261            ),
1262        }),
1263    }
1264}
1265
1266fn eval_set_return_item<F: Fs>(
1267    db: &GraphDb<F>,
1268    match_rs: &ResultSet,
1269    row: usize,
1270    rel_vars: &[String],
1271    item: &RetItem,
1272    params: &BTreeMap<String, Value>,
1273) -> Result<Option<Value>> {
1274    match &item.value {
1275        RetVal::Var(v) => eval_set_return_operand(
1276            db,
1277            match_rs,
1278            row,
1279            rel_vars,
1280            &Operand::Var(v.clone()),
1281            params,
1282        ),
1283        RetVal::Prop { var, field } => eval_set_return_operand(
1284            db,
1285            match_rs,
1286            row,
1287            rel_vars,
1288            &Operand::Prop {
1289                var: var.clone(),
1290                field: field.clone(),
1291            },
1292            params,
1293        ),
1294        RetVal::FuncCall { name, args } => {
1295            eval_set_return_func(db, match_rs, row, rel_vars, name, args, params)
1296        }
1297        RetVal::ScalarExpr(op) => eval_set_return_operand(db, match_rs, row, rel_vars, op, params),
1298        RetVal::Agg { .. } => Err(GraphError::QueryError {
1299            detail: "aggregates are not supported in MATCH … SET … RETURN".into(),
1300        }),
1301    }
1302}
1303
1304/// Project user RETURN from original MATCH rows after SET. No rematch.
1305fn project_set_return_rows<F: Fs>(
1306    db: &GraphDb<F>,
1307    rel_vars: &[String],
1308    match_rs: &ResultSet,
1309    returns: &[RetItem],
1310    params: &BTreeMap<String, Value>,
1311) -> Result<ResultSet> {
1312    let columns: Vec<String> = returns.iter().map(ret_column_name).collect();
1313    let mut out = ResultSet::new(columns);
1314    for row in 0..match_rs.len() {
1315        let mut cells = Vec::with_capacity(returns.len());
1316        for item in returns {
1317            cells.push(eval_set_return_item(
1318                db, match_rs, row, rel_vars, item, params,
1319            )?);
1320        }
1321        out.push_row(cells);
1322    }
1323    Ok(out)
1324}
1325
1326/// Single construction point for a `GraphMut` view over the split-borrowed graph fields.
1327/// Callers use `std::mem::take` on the engine before calling this, then restore it after.
1328/// Extract a `Vec<f64>` from a `Value::List` whose items are all numeric.
1329/// Returns `None` for non-list values or lists with non-numeric elements.
1330/// Extra candidates pulled from an approximate index before re-scoring, over and
1331/// above the `k` asked for.
1332///
1333/// The index orders candidates by `f32` distances, which agree with the exact
1334/// `f64` cosine to about 1e-6. Re-scoring can therefore only reshuffle
1335/// candidates inside a band that narrow — it cannot move a hit past one that is
1336/// further away by more than 1e-6 — so the only way a true top-`k` member can be
1337/// lost is if the index ranked it just outside `k` on the `f32` order. Fetching
1338/// `k + 16` covers any such band up to 16 members wide, which at 1e-6 means 16
1339/// vectors within a millionth of each other in cosine: a duplicate cluster, and
1340/// then the members are interchangeable anyway. `min` is applied to the exact
1341/// score, never to the index's, so a hit sitting on the threshold is decided
1342/// exactly.
1343const VECTOR_RESCORE_MARGIN: usize = 16;
1344
1345/// Cosine similarity between an already-unit query and node `id`'s `field`
1346/// vector, read from the **`f64`** properties. `None` when the node has no
1347/// numeric-list vector there, or its norm is zero.
1348///
1349/// The single definition of the score this API reports. Both the brute-force
1350/// scan and the re-scoring step that follows an index lookup go through it, so
1351/// the two paths cannot disagree — which is the property
1352/// `index_and_brute_force_agree_on_scores` pins.
1353fn exact_vector_similarity(
1354    view: &GraphView<'_>,
1355    id: u32,
1356    field: &str,
1357    q_unit: &[f64],
1358) -> Option<f64> {
1359    let v = view.prop(id, field)?;
1360    let xs = value_as_float_list(&v.into_value())?;
1361    let v_norm: f64 = xs.iter().map(|x| x * x).sum::<f64>().sqrt();
1362    if v_norm == 0.0 {
1363        return None;
1364    }
1365    Some(
1366        q_unit
1367            .iter()
1368            .zip(xs.iter())
1369            .map(|(a, b)| a * (b / v_norm))
1370            .sum(),
1371    )
1372}
1373
1374fn value_as_float_list(v: &Value) -> Option<Vec<f64>> {
1375    match v {
1376        Value::List(items) => items
1377            .iter()
1378            .map(|item| match item {
1379                Value::Float(f) => Some(*f),
1380                Value::Int(i) => Some(*i as f64),
1381                _ => None,
1382            })
1383            .collect(),
1384        _ => None,
1385    }
1386}
1387
1388fn make_graph_mut<'a>(
1389    ids: &'a IdMap,
1390    syms: &'a mut Interner,
1391    labels: &'a [u32],
1392    props: core_storage::v8::seam::ColumnsView<'a>,
1393    topo: &'a mut Topology,
1394    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1395    edge_props: &'a mut EdgeProps,
1396) -> GraphMut<'a> {
1397    GraphMut {
1398        ids,
1399        syms,
1400        labels,
1401        props,
1402        topo,
1403        base_topo: base_csr(base),
1404        edge_props,
1405    }
1406}
1407
1408/// The archived CSR of an open V8 snapshot, for the rule engine's graph reads.
1409///
1410/// A store opened from a snapshot keeps its edges in the mapping and its
1411/// overlay empty, so a rule that reads the graph's shape has to see both.
1412fn base_csr(
1413    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1414) -> Option<&core_storage::v8::layout::ArchivedCsr> {
1415    base.as_ref().map(|b| {
1416        b.topology()
1417            .expect("base topology section bounds validated at open")
1418    })
1419}
1420
1421/// Build a `ColumnsView` from the disjoint `props` overlay and optional V8 base.
1422///
1423/// Takes explicit field references rather than `&self` so the caller can hold
1424/// simultaneous mutable borrows of other fields (e.g. `syms`, `topo`).
1425fn build_props_view<'a>(
1426    props: &'a ColumnStore,
1427    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1428) -> core_storage::v8::seam::ColumnsView<'a> {
1429    match base {
1430        None => core_storage::v8::seam::ColumnsView::owned(props),
1431        Some(b) => {
1432            let archived = b
1433                .columns()
1434                .expect("base columns section bounds validated at open");
1435            core_storage::v8::seam::ColumnsView::with_base_cached(props, archived, b.mixed_cache())
1436                .with_shared_strings(base_string_table(b))
1437        }
1438    }
1439}
1440
1441/// The base columns section paired with the string table that resolves its
1442/// string ids — what `ViewStore` needs to read a neighbour's string property
1443/// out of a V9 snapshot.
1444fn base_columns(
1445    base: &Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1446) -> Option<core_storage::v8::seam::BaseColumns<'_>> {
1447    base.as_ref().map(|b| core_storage::v8::seam::BaseColumns {
1448        cols: b
1449            .columns()
1450            .expect("base columns section bounds validated at open"),
1451        strings: base_string_table(b),
1452    })
1453}
1454
1455/// The shared string table of a V9 base, or `None` for a pre-V9 one.
1456///
1457/// Every `ColumnsView` built over a base must carry it: without it a V9
1458/// snapshot's string columns, whose own tables are empty, read back as absent.
1459fn base_string_table(
1460    base: &core_storage::v8::MappedBase,
1461) -> Option<&core_storage::v8::layout::ArchivedStringTable> {
1462    base.string_table()
1463        .transpose()
1464        .expect("base strings section bounds validated at open")
1465}
1466
1467fn build_topo_view<'a>(
1468    overlay: &'a Topology,
1469    base: &'a Option<std::sync::Arc<core_storage::v8::MappedBase>>,
1470) -> core_storage::v8::seam::TopologyView<'a> {
1471    match base {
1472        None => core_storage::v8::seam::TopologyView::owned(overlay),
1473        Some(b) => {
1474            let archived_csr = b
1475                .topology()
1476                .expect("base topology section bounds validated at open");
1477            core_storage::v8::seam::TopologyView::with_base(overlay, archived_csr)
1478        }
1479    }
1480}
1481
1482/// When [`GraphDb`] calls `Fs::sync` after a WAL append.
1483///
1484/// Default is [`Strict`](FsyncPolicy::Strict): every `log_then_apply_with`
1485/// fsyncs (single `insert_node` / `set_prop`). Ingest and `write_batch`
1486/// emit one `WalRecord::Batch` and fsync once at that frame (Batched).
1487/// [`Relaxed`](FsyncPolicy::Relaxed) skips WAL sync; [`GraphDb::snapshot`]
1488/// is still durable via `write_atomic`. Crash-recovery DST stays Strict.
1489#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
1490pub enum FsyncPolicy {
1491    /// Every WAL commit calls `fs.sync` (today's behavior).
1492    #[default]
1493    Strict,
1494    /// Sync only at a `Batch` frame end. Single-op path stays Strict unless
1495    /// this policy is set on the database.
1496    Batched,
1497    /// Never call `fs.sync`. [`GraphDb::snapshot`] still syncs via `write_atomic`.
1498    Relaxed,
1499}
1500
1501/// A precondition for a compare-and-set batch write.
1502///
1503/// All preconditions in a [`GraphDb::write_batch_cas`] or
1504/// [`crate::SharedDb::submit_batch_cas`] call are checked atomically before
1505/// any operation in the batch is applied.  If any precondition fails, the
1506/// entire batch is rejected with [`GraphError::CasConflict`] and no WAL frame
1507/// is written.
1508///
1509/// # Touch definition
1510///
1511/// A node's last-change commit (`last_changed`) is updated when any of the
1512/// following state-changing WAL records touch it:
1513///
1514/// - `InsertNode` / `InsertNodeId` — the newly-inserted node.
1515/// - `SetProp` / `SetPropId` / `RemoveProp` — the property-bearing node.
1516/// - `InsertEdge` / `InsertEdgeId` / `DeleteEdge` — **both** src and dst
1517///   endpoints (an edge change touches both sides).
1518/// - `DeleteNode` — the node is tombstoned; `last_changed` returns `None`
1519///   for deleted keys so the pre-deletion entry is never observed.
1520///
1521/// History markers (`DerivedEdgeAdded` / `DerivedEdgeRetracted`) are
1522/// state no-ops.  The underlying mutation that triggered rule firing already
1523/// updated the relevant nodes' last-change entries.  Rule-management records
1524/// (`CreateRule`, `DeleteRule`, `RebuildRule`) and view/full-text declarations
1525/// do not touch any node's last-change.
1526#[derive(Debug, Clone, PartialEq, Eq)]
1527pub enum Precondition {
1528    /// The node's last-change commit must equal `expected`.
1529    ///
1530    /// Fails with [`GraphError::CasConflict`] when:
1531    /// - The node does not exist (`last_changed` returns `None`), or
1532    /// - The recorded commit seq does not match `expected`.
1533    NodeUnchangedSince { key: String, expected: u64 },
1534    /// The node must not exist (not inserted, or already deleted).
1535    ///
1536    /// Fails with [`GraphError::CasConflict`] (expected=`u64::MAX`,
1537    /// actual=`last_changed(key).unwrap_or(0)`) when the node is live.
1538    NodeAbsent { key: String },
1539}
1540
1541pub struct GraphDb<F: Fs> {
1542    fs: F,
1543    ids: Arc<IdMap>,
1544    syms: Arc<Interner>,
1545    topo: Arc<Topology>,
1546    props: Arc<ColumnStore>,
1547    labels: Arc<Vec<u32>>, // node id -> label symbol
1548    /// Namespace names by index; index [`NS_DEFAULT_IDX`] is always
1549    /// [`NS_DEFAULT`]. Derived beside [`Self::node_ns`], never persisted.
1550    ///
1551    /// A private table rather than the shared [`Interner`]: interning
1552    /// `"default"` at open would add a symbol to the store's symbol table and
1553    /// change the bytes of the next snapshot of a store that has no namespaces
1554    /// at all.
1555    ns_names: Vec<String>,
1556    /// Namespace index per dense node id, into [`Self::ns_names`];
1557    /// [`NS_DEFAULT_IDX`] for a node with no `ns` property.
1558    ///
1559    /// Derived: built by one pass over the `ns` column at open (which reads
1560    /// nothing when the column does not exist) and maintained at every node
1561    /// insert. Never written to a snapshot or the WAL, because the property it
1562    /// mirrors already is. A namespace cannot change, so no other record shape
1563    /// can move a node between namespaces.
1564    node_ns: Vec<u32>,
1565    edge_props: Arc<EdgeProps>,
1566    engine: RuleEngine,
1567    view_store: ViewStore,
1568    /// Incremental inverted index for full-text-lite search.
1569    /// Rebuild-on-open: populated from WAL replay + rebuild_all at open end.
1570    fulltext: Arc<FulltextIndex>,
1571    /// Opt-in equality index over scalar node properties.
1572    /// Rebuild-on-open: declarations replay from the WAL, postings rebuild at
1573    /// open end (mirrors `fulltext`).
1574    prop_index: PropertyIndex,
1575    /// Whether this store records insert-count multiplicity (§5.13).
1576    ///
1577    /// Declared like `prop_index`'s enabled pairs — a WAL record replayed at
1578    /// open, re-emitted into the baseline by a truncating snapshot — but it
1579    /// gates a *format* step rather than an index: `WalRecord::SetEdgeCount`
1580    /// (discriminant 23) is written only when this is `true`, so a store that
1581    /// never opts in stays readable by a binary that predates the record.
1582    multiplicity: bool,
1583    event_sink: Option<Box<dyn Fn(MutationEvent) + Send + Sync>>,
1584    /// WAL fsync cadence. Default [`FsyncPolicy::Strict`].
1585    fsync: FsyncPolicy,
1586    /// Monotonically increasing per-commit counter.  A single `log_then_apply_with`
1587    /// call increments this once; all events emitted from that call share the same
1588    /// `commit_seq` value.
1589    commit_seq: u64,
1590    /// Commit → wall-clock map, loaded from the `commit_times.bin` sidecar at
1591    /// open and appended to by `log_then_apply_with` — the one place a commit
1592    /// is born. Replay does **not** stamp: `apply_frames` re-applies commits
1593    /// that already happened, and `SystemTime::now()` there would record replay
1594    /// time as commit time. Empty on a store written before v0.6.11, which
1595    /// makes every date query answer `NoRecordedTime` rather than guess.
1596    commit_times: core_storage::commit_times::CommitTimes,
1597    /// When set, subsequent commits are recorded at this instant instead of the
1598    /// system clock.
1599    ///
1600    /// Sticky on purpose. A backfill replays history that happened over months,
1601    /// and a day's worth of rows genuinely share one instant — a one-shot flag
1602    /// would mean setting it before every row of a bulk load, and forgetting one
1603    /// would stamp that row "now" in the middle of 2026-06. Sticky makes the
1604    /// failure visible instead: forget to move it and every commit carries the
1605    /// same timestamp, which a date query answers oddly and an inspection shows
1606    /// at once.
1607    commit_time_override: Option<i64>,
1608    /// `true` only while the open path is replaying, where `load_from_disk`
1609    /// calls `fulltext.rebuild_all` unconditionally afterwards.
1610    ///
1611    /// Replaying an `EnableFulltext` record backfills its pair with a full
1612    /// `0..ids.len()` scan, and every snapshot re-emits one such record per
1613    /// enabled pair — so on a snapshotted store the open does that scan once per
1614    /// pair and then `rebuild_all` clears every posting and does it all again.
1615    /// The backfill is pure waste *when a rebuild follows*, which is true of the
1616    /// open path and **false** of `refresh()`: refresh applies peer frames and
1617    /// then only folds, so its backfill is the only thing that indexes them.
1618    fulltext_rebuild_follows: bool,
1619    /// `true` when this handle's full-text index was read from the snapshot
1620    /// it opened and nothing replayed over it since has spoiled it, so the
1621    /// open did not build it.
1622    fulltext_loaded: bool,
1623    /// Set by the insert arms of `apply` when they index a node that is
1624    /// already indexed under that field. `add_tokens` appends, so that node's
1625    /// postings are now doubled: the WAL being replayed holds writes the
1626    /// snapshot already has — a `keep_wal` snapshot, or a crash between a
1627    /// snapshot and its WAL replacement. The open rebuilds when it sees this.
1628    fulltext_reindexed_a_node: bool,
1629    /// Whether the snapshot this handle opened replaced its WAL when it was
1630    /// written. One that kept it (`keep_wal`) has the whole WAL replayed over
1631    /// it at open, and its index is built after that replay, not read.
1632    base_wal_truncated: bool,
1633    /// How often that happened during this handle's open. Tests read it.
1634    fulltext_reindexed_at_open: u64,
1635    /// `true` only while `load_from_disk` replays the WAL. A node indexed
1636    /// twice there is a sign the index must be rebuilt, and is left for the
1637    /// rebuild; anywhere else it is corrected on the spot.
1638    replaying_at_open: bool,
1639    /// `true` when `snapshot.intent` may hold something that is no longer
1640    /// true: this handle found one that did not match, or a snapshot of its
1641    /// own wrote one and did not get to empty it. Settled, under the write
1642    /// lock, before the next append.
1643    intent_unsettled: bool,
1644    /// Set by [`GraphDb::stop_next_snapshot`]; taken by the next snapshot.
1645    snapshot_stop: Option<SnapshotStop>,
1646    /// `true` when `commit_times.bin` was present but would not decode.
1647    ///
1648    /// Mirrors `roles: None`: a damaged map must not read as "this store
1649    /// records no times", because that is also what an honest pre-v0.6.11 store
1650    /// says. Date queries answer `Corrupt` instead, and nothing is appended to
1651    /// a file already known to be damaged.
1652    commit_times_poisoned: bool,
1653    /// RBAC role definitions loaded from `roles.json` at open.
1654    ///
1655    /// `Some(roles)` — loaded successfully (may be empty when no roles are defined).
1656    /// `None` — `roles.json` was present but corrupt; `mask_for_role` returns
1657    /// `Err` for any request (fail-loud, never silently grant empty visibility).
1658    roles: Option<Vec<RoleDef>>,
1659    /// Memo for [`mask_for_role`](GraphDb::mask_for_role), keyed by
1660    /// `(role, commit_seq)` — a scoped reader between two writes resolves once.
1661    ///
1662    /// Shared by `Arc` with every [`ReaderSnapshot`](crate::reader::ReaderSnapshot)
1663    /// taken from this handle. Replaced (not cleared) whenever the role
1664    /// definitions change or the store is reloaded, which `commit_seq` does not
1665    /// record; see [`RoleMaskCache`](crate::mask::RoleMaskCache).
1666    role_masks: Arc<crate::mask::RoleMaskCache>,
1667    /// Which loaded store this handle is, for memos that outlive it.
1668    ///
1669    /// `role_masks` needs no such thing — the handle owns it and replaces it —
1670    /// but a [`Scope`](crate::mask::Scope) is the caller's, so its resolved key
1671    /// leg is stamped with this alongside `commit_seq`. Minted fresh here and
1672    /// again in [`reset_for_reload`](GraphDb::reset_for_reload), at exactly the
1673    /// two points a fresh `RoleMaskCache` is installed; see
1674    /// [`StoreStamp`](crate::mask::StoreStamp) for the invariant.
1675    store_id: crate::mask::StoreId,
1676    /// Live subscriptions.  Entries with a dead `Weak` are pruned on the next
1677    /// distribute_events call.
1678    subscriptions: Vec<SubEntry>,
1679    /// Live query subscriptions. Re-executed on every commit when non-empty.
1680    /// Dead `Weak` entries are pruned inside `distribute_events`.
1681    query_subscriptions: Vec<QuerySubEntry>,
1682    /// Queue capacity for new subscriptions created by this db.  Default is
1683    /// [`DEFAULT_SUB_CAPACITY`]; can be overridden via [`set_sub_capacity`]
1684    /// to test Lagged behaviour with small queues.
1685    sub_capacity: usize,
1686    /// True for as-of instances opened via [`GraphDb::open_at`].
1687    /// Every mutation method and `snapshot()` returns [`GraphError::ReadOnly`]
1688    /// when this flag is set.
1689    read_only: bool,
1690    /// Total WAL commit count at the time [`open_at`] was called.
1691    /// 0 for normal (non-as-of) instances.
1692    total_wal_commits: u64,
1693    /// Immutable mmap-backed base snapshot (V8).  When `Some`, `self.topo` is
1694    /// the WAL-replay overlay (empty at open time, populated by apply()) and
1695    /// reads go through a merged `TopologyView`.  `self.props` is always
1696    /// fully materialized (base + WAL replay) for HNSW/IVF and view compat.
1697    base: Option<Arc<core_storage::v8::MappedBase>>,
1698    // ── MVCC epoch reader state ───────────────────────────────────────────────
1699    /// Most-recent full overlay clone.  Initialized at end of `open_with` /
1700    /// `open_at_with`; refreshed every `FOLD_EVERY_K` commits.
1701    /// `None` only between struct creation and the first fold.
1702    fold_overlay: Option<Arc<crate::reader::FrozenOverlay>>,
1703    /// Per-commit deltas accumulated since the last fold.
1704    delta_tail: Vec<Arc<crate::reader::CommitDelta>>,
1705    /// How many commits have occurred since the last fold.
1706    commits_since_fold: usize,
1707    /// When true, `log_then_apply_with` buffers event notifications instead of
1708    /// firing them immediately.  Used by the group-commit drain thread to defer
1709    /// events until after the group fsync (R2: durability before notification).
1710    /// Cleared to false once the drain thread flushes or discards the buffer.
1711    defer_events: bool,
1712    /// Buffered events accumulated while `defer_events` is true.
1713    deferred_events: Vec<DeferredEvent>,
1714    /// Set to true by the group-commit drain thread when a group fsync fails
1715    /// after WAL truncation.  All subsequent mutation attempts return an IO
1716    /// error until the database is reopened.
1717    degraded: bool,
1718    /// Set to `true` after `ensure_v8_base_sections_loaded` has read provenance,
1719    /// HNSW, and IVF sections from the mmap base into the engine's retained
1720    /// fields.  `false` on all opens until first use; always `true` for non-V8
1721    /// opens (base is None, fast-path sets flag immediately).
1722    v8_sections_loaded: std::sync::atomic::AtomicBool,
1723    /// Serializes the one-time section population in `ensure_v8_base_sections_loaded`.
1724    v8_sections_mutex: std::sync::Mutex<()>,
1725    /// Per-node last-change commit sequence.  `last_change[node_id] = seq` means
1726    /// the node was last modified by commit `seq`.
1727    ///
1728    /// Loaded from V8 section 11 at open; updated on every state-changing commit
1729    /// and WAL replay frame.  V5-V7 stores start with an empty map; pre-WAL-horizon
1730    /// nodes return `None` from `last_changed` until they are next mutated.
1731    ///
1732    /// See [`Precondition`] for the full touch definition.
1733    last_change: HashMap<u32, u64>,
1734    /// WAL archive retention policy set by [`set_wal_archive_retention`].
1735    /// `None` = unlimited (keep all archives); `Some(N)` = keep N newest archives,
1736    /// pruning older ones at snapshot time.  0 is treated as unlimited.
1737    wal_archive_retention: Option<u32>,
1738    /// Global frame index of the first commit that is still reachable through
1739    /// surviving archives.  Persisted to `wal.floor` sidecar when pruning occurs.
1740    /// Default 0 = all history reachable.
1741    wal_horizon_floor: u64,
1742    /// True when the surviving archive chain forms a continuous WAL history
1743    /// starting from the store's first commit (the genesis chain).
1744    ///
1745    /// `open_at` may replay archive-resident commits from empty state only when
1746    /// this flag is true AND `wal_horizon_floor == 0`.  Cleared whenever:
1747    ///   - a WAL-truncating snapshot (`keep_wal=false`) is taken after archives
1748    ///     already exist (breaks the chain for subsequent archives), or
1749    ///   - any archive is pruned (floor advances past zero).
1750    ///
1751    /// Persisted via the `wal.genesis` marker file; loaded from it at open.
1752    archive_genesis_chain: bool,
1753    /// True when this handle can *prove* the live WAL has never been truncated:
1754    /// there was no `snapshot.bin` when it opened the store, and it has taken no
1755    /// truncating snapshot since.
1756    ///
1757    /// The archive path's genesis check asks "did a snapshot exist before this
1758    /// one?" as a proxy for "was the WAL ever truncated". The proxy is sound
1759    /// across sessions — this binary cannot tell a history-preserving snapshot
1760    /// from a truncating one once the handle that took it is gone — but inside
1761    /// one session it is not, and `enable_multiplicity` made that visible: its
1762    /// forced `keep_wal` snapshot left the WAL entirely intact and yet
1763    /// permanently disqualified the store from ever receiving a genesis marker
1764    /// (defect #23). This flag is what the proxy defers to when the answer is
1765    /// actually known.
1766    snapshot_preserved_history: bool,
1767    /// Transient write-authz context set by `write_batch_authz` /
1768    /// `query_write_authz` for the duration of ONE mutation call.
1769    /// Always `None` at rest.  Never serialized, never WAL-replayed.
1770    pending_write_authz: Option<WriteAuthz>,
1771    /// Slow-query threshold in milliseconds.  0 = disabled.
1772    /// Seeded from `MUSHROOMDB_SLOW_QUERY_MS` at open; override via
1773    /// [`GraphDb::set_slow_query_threshold_ms`] (tests must use the setter
1774    /// — env vars are process-global and race parallel test threads).
1775    slow_query_threshold_ms: u64,
1776    /// Ring buffer of recent slow queries (interior-mutable so `query(&self)`
1777    /// can record entries without requiring `&mut self`).
1778    slow_queries: std::sync::Mutex<SlowQueryLog>,
1779    /// `(field, label, caller)` triples whose exact-versus-approximate
1780    /// ambiguity this handle has already explained once. See
1781    /// [`note_ambiguous_exactness`](GraphDb::note_ambiguous_exactness).
1782    /// The caller shape is part of the key because the two shapes give
1783    /// different advice — silencing one with the other would leave a caller
1784    /// reading advice meant for a signature it does not have.
1785    /// Advice bookkeeping, not graph state: a reload keeps it, as the
1786    /// slow-query log does.
1787    warned_ambiguous_exactness: std::sync::Mutex<HashSet<(String, String, ExactnessCaller)>>,
1788    /// Instant at which the database was opened (used by `/metrics` uptime).
1789    started_at: std::time::Instant,
1790    // ── Multi-process state (cross-process lock + WAL tailing) ────────────────
1791    /// Byte offset of the WAL prefix already applied to in-memory state.
1792    ///
1793    /// Advanced by exactly the encoded length of every frame this handle
1794    /// appends, and by the decoded byte count of every tail
1795    /// [`refresh`](GraphDb::refresh) absorbs. Rewound by
1796    /// [`set_wal_consumed`](GraphDb::set_wal_consumed) when the group-commit
1797    /// drain thread truncates a failed group. Compared against the WAL's
1798    /// on-disk length to decide staleness.
1799    wal_consumed: u64,
1800    /// The **global 0-based frame index the next appended WAL frame will
1801    /// occupy** — `wal_horizon_floor` plus every frame currently reachable
1802    /// through archives and the live WAL.
1803    ///
1804    /// This is the space every history surface addresses: `edges_at`,
1805    /// `was_linked`, both history readouts and `open_at` all index the sequence
1806    /// [`all_frames`](GraphDb::all_frames) returns, and
1807    /// [`wal_total_commits`](GraphDb::wal_total_commits) counts it.
1808    ///
1809    /// It exists because **a commit is not a frame**. `commit_seq` counts
1810    /// commits; a commit whose rules fire appends a *second* frame — the
1811    /// derived-edge history marker — that no counter of commits ever sees. The
1812    /// two diverge by one frame per rule-firing commit, cumulatively, so
1813    /// deriving a frame index from `commit_seq` under-reports by more and more
1814    /// as history grows and resolves every date to an earlier graph. Silently:
1815    /// an older graph is a plausible answer, not an error.
1816    ///
1817    /// Maintained in lockstep with [`wal_consumed`](GraphDb::wal_consumed) —
1818    /// the same appends advance both, one in frames and one in bytes — so the
1819    /// two are seeded and rewound at exactly the same places. Keep it that way.
1820    wal_frames_written: u64,
1821    /// Identity of the snapshot this handle's base state came from, as
1822    /// `(len, mtime_nanos)`. A different value means another process replaced
1823    /// the snapshot and the WAL no longer continues our state: refresh reloads.
1824    snapshot_ident: Option<(u64, u64)>,
1825    /// The options this handle was opened with. Replayed verbatim when
1826    /// `refresh` has to rebuild from disk.
1827    open_opts: OpenOptions,
1828    /// True when this handle holds the cross-process write lock for its whole
1829    /// lifetime (a plain read-write open). Per-write lock acquisition is a
1830    /// no-op on such a handle, and never releases the lock.
1831    holds_lifetime_lock: bool,
1832    /// True between a failed lock acquisition and the end of the write scope
1833    /// that failed. Makes every WAL-appending mutation in that scope return
1834    /// [`GraphError::Busy`] instead of writing.
1835    lock_denied: bool,
1836    /// True for an as-of view opened via [`GraphDb::open_at`]. Such a view is
1837    /// pinned to one commit, so it is never stale and never refreshes — later
1838    /// commits by any process are deliberately invisible to it.
1839    pinned: bool,
1840}
1841
1842/// One group of deferred event notifications, held until the group fsync
1843/// completes.  Replayed by [`GraphDb::flush_deferred_events`].
1844struct DeferredEvent {
1845    rec: core_storage::WalRecord,
1846    engine_deltas: Vec<EngineEdgeDelta>,
1847    seq: u64,
1848    ingest: Option<(String, usize)>,
1849}
1850
1851/// Options for [`GraphDb::open_with_options`].
1852#[derive(Clone, Copy, Debug)]
1853pub struct OpenOptions {
1854    /// Rewrite an old-format snapshot to the current VERSION after a
1855    /// successful load (default `true`). The old snapshot is kept as
1856    /// `snapshot.bin.bak` until the next clean open at the current version,
1857    /// at which point the `.bak` is deleted.
1858    ///
1859    /// Set to `false` to open a store without touching any on-disk files
1860    /// (useful for read-only inspection of a store at an older format).
1861    pub auto_migrate: bool,
1862
1863    /// Write the valid WAL prefix back over a torn tail on open (default
1864    /// `true`). Truncating a genuinely torn tail is correct crash recovery.
1865    ///
1866    /// Set to `false` for an unattended reader. The valid prefix is still
1867    /// decoded and replayed in memory, but nothing is written: a reader that
1868    /// opens while another process is mid-append would otherwise discard a
1869    /// frame that writer believes durable. `mushroomdb recall`, which runs on
1870    /// every prompt, passes `false` for exactly this reason.
1871    pub repair_wal: bool,
1872
1873    /// Open without ever writing to the store (default `false`).
1874    ///
1875    /// A read-only handle:
1876    /// - returns [`GraphError::ReadOnly`] from every mutation and from
1877    ///   `snapshot()`;
1878    /// - performs no disk write at open — no WAL repair write-back and no
1879    ///   auto-migration rewrite, whatever the other two flags say;
1880    /// - never takes the cross-process write lock, so it opens immediately even
1881    ///   while another process is writing, and never makes a writer wait.
1882    ///
1883    /// [`refresh`](GraphDb::refresh) and [`is_stale`](GraphDb::is_stale) work
1884    /// normally, so a read-only handle can follow another process's commits.
1885    pub read_only: bool,
1886}
1887
1888impl Default for OpenOptions {
1889    fn default() -> Self {
1890        Self {
1891            auto_migrate: true,
1892            repair_wal: true,
1893            read_only: false,
1894        }
1895    }
1896}
1897
1898/// How long a writer polls for the cross-process write lock before giving up
1899/// with [`GraphError::Busy`].
1900///
1901/// Long enough to ride out another process's commit (a batch apply plus one
1902/// fsync), short enough that a stuck peer surfaces as an error rather than a
1903/// hang.
1904pub const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
1905
1906/// Refusal when a `MERGE` create cannot choose a namespace.
1907///
1908/// A role bound to two or more namespaces cannot have its create arm land in
1909/// `default`, and the statement did not name `ns`. The role must name one.
1910pub const MERGE_CREATE_NEEDS_ONE_NAMESPACE: &str =
1911    "role-bound token: MERGE create requires the role to name one namespace";
1912
1913/// Interval between poll attempts while waiting for the cross-process lock.
1914pub(crate) const LOCK_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
1915
1916/// Why `load_from_disk` is running, which decides whether it may repair.
1917#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1918enum LoadOrigin {
1919    /// A fresh open. Crash recovery is this handle's job: a torn WAL tail is
1920    /// the signature of a crash and truncating it is correct, and archives
1921    /// orphaned by an interrupted prune can be swept.
1922    Open,
1923    /// A reload driven by [`GraphDb::refresh`], because another process
1924    /// replaced the snapshot. Nothing here is crash recovery — the store is
1925    /// live and someone else is writing it — so this origin writes nothing.
1926    Reload,
1927}
1928
1929/// Authorization context carried by `write_batch_authz` / `query_write_authz`.
1930///
1931/// `None` at the call site = full authority (today's zero-cost behavior).
1932/// `Some(WriteAuthz)` = role-scoped: the decision table (plan §"authz decision
1933/// table") is evaluated per-op inside `commit_logged_batch` BEFORE any WAL
1934/// record is built.  A denial returns an error with no WAL frame written.
1935///
1936/// The mask is ALWAYS `Omit`-mode: role-token paths must never acknowledge
1937/// hidden-node existence to callers.
1938#[derive(Clone, Debug)]
1939pub struct WriteAuthz {
1940    pub role: String,
1941    pub scope: WriteScope,
1942    /// Resolved by `mask_for_role` under the same write guard as the mutation.
1943    /// Always `Omit`-mode — never `Stub`.
1944    pub mask: crate::mask::NodeMask,
1945}
1946
1947/// The error every role surface gives when `roles.json` did not parse at open.
1948///
1949/// One text, so `mask_for_role` and [`GraphDb::roles_checked`] cannot drift
1950/// apart on the same cause.
1951fn roles_poisoned() -> GraphError {
1952    GraphError::Corrupt {
1953        detail: "roles.json was corrupt at open; fix the file and re-open to restore role access"
1954            .into(),
1955    }
1956}
1957
1958/// Write `bytes` to `snapshot.bin.bak` atomically with full fsync.
1959///
1960/// Uses [`RealFs::write_atomic`] which applies `F_FULLFSYNC` on macOS and
1961/// `sync_all` on other platforms, then renames the `.tmp` file into place and
1962/// syncs the directory entry. This is the only correct path for writing the
1963/// `.bak` — plain `std::fs::write + sync_all` misses both `F_FULLFSYNC` and
1964/// the directory sync.
1965pub fn write_snapshot_bak(dir: &std::path::Path, bytes: &[u8]) -> crate::Result<()> {
1966    use core_storage::fs::{FileId, Fs as _};
1967    RealFs::new(dir)
1968        .map_err(core_storage::GraphError::Io)?
1969        .write_atomic(FileId::SnapshotBak, bytes)
1970        .map_err(core_storage::GraphError::Io)
1971}
1972
1973/// Return the on-disk snapshot format version without decoding the full snapshot.
1974///
1975/// Reads only the 6-byte header (magic + version LE). Returns `None` when no
1976/// snapshot file exists (WAL-only store). Returns an error if the header is
1977/// malformed.
1978pub fn snapshot_version_at(dir: &std::path::Path) -> crate::Result<Option<u16>> {
1979    use std::io::Read as _;
1980    let path = dir.join("snapshot.bin");
1981    let mut header = [0u8; 6];
1982    let n = match std::fs::File::open(&path) {
1983        Ok(mut f) => f.read(&mut header).map_err(core_storage::GraphError::Io)?,
1984        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1985        Err(e) => return Err(core_storage::GraphError::Io(e)),
1986    };
1987    core_storage::snapshot::peek_version(&header[..n])
1988}
1989
1990/// Where [`GraphDb::stop_next_snapshot`] makes the next snapshot fail, leaving
1991/// the files as a crash at that point would. For tests of what an interrupted
1992/// snapshot leaves behind; not part of the stable surface.
1993#[doc(hidden)]
1994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1995pub enum SnapshotStop {
1996    /// The intent is written; `snapshot.bin` is still the old one.
1997    BeforeSnapshotWrite,
1998    /// `snapshot.bin` is the new one; nothing else has moved.
1999    AfterSnapshotWrite,
2000    /// As above, and the history floor has been advanced; the WAL is still
2001    /// the one the snapshot was made from.
2002    BeforeWalReplacement,
2003    /// The WAL is replaced; the intent has not been emptied.
2004    AfterWalReplacement,
2005}
2006
2007/// Options for [`GraphDb::snapshot_with`].
2008#[derive(Debug, Clone, Default)]
2009pub struct SnapshotOptions {
2010    /// When `true`, the WAL is preserved after the snapshot write.
2011    /// Pre-snapshot commits remain reachable via [`GraphDb::open_at`].
2012    /// When `false` (the default), the WAL is truncated to a minimal
2013    /// baseline so cold-start replay stays fast.
2014    pub keep_wal: bool,
2015    /// When `true`, the current WAL is renamed to `wal.<commit_seq>.archive`
2016    /// before a fresh WAL baseline is written (history-preserving snapshot).
2017    ///
2018    /// This is the feature opt-in: `false` (the default) leaves the existing
2019    /// truncation / keep-wal behaviour byte-identical.  `archive_wal` takes
2020    /// precedence over `keep_wal` when both are set.
2021    ///
2022    /// Archives can be scanned by [`GraphDb::node_history`],
2023    /// [`GraphDb::edge_history`], [`GraphDb::was_linked`], and
2024    /// [`GraphDb::open_at`], extending the reachable history horizon across
2025    /// snapshot boundaries.
2026    pub archive_wal: bool,
2027}
2028
2029/// Derive the scan-label sym for the commit-skip fast-path.
2030///
2031/// Walks `ops` to find the plan's leading scan op (`ScanLabel`, `IndexScan`,
2032/// or `IndexIntersect`) with a concrete label string, then interns it.
2033///
2034/// Returns `None` in all cases where skipping is unsafe:
2035/// - Any `Expand` op is present (edge traversal; edges change results regardless
2036///   of node labels).
2037/// - The leading scan has no label (`ScanLabel { label: None }` — full scan).
2038/// - No recognizable leading scan op is found.
2039///
2040/// This is the conservative v0.4.3 boundary. The caller stores the result in
2041/// [`QuerySubEntry::scan_label`] at subscribe time; `None` means always execute.
2042fn extract_scan_label(ops: &[PlanOp], syms: &mut Interner) -> Option<u32> {
2043    // Any Expand → must always re-execute (edges can change join results).
2044    if ops.iter().any(|op| matches!(op, PlanOp::Expand { .. })) {
2045        return None;
2046    }
2047    for op in ops {
2048        match op {
2049            PlanOp::ScanLabel {
2050                label: Some(label), ..
2051            } => return Some(syms.intern(label)),
2052            PlanOp::IndexScan {
2053                label: Some(label), ..
2054            } => return Some(syms.intern(label)),
2055            PlanOp::IndexIntersect {
2056                label: Some(label), ..
2057            } => return Some(syms.intern(label)),
2058            _ => {}
2059        }
2060    }
2061    None
2062}
2063
2064/// How an as-of read is restricted — the argument to
2065/// [`GraphDb::query_at_scoped`].
2066///
2067/// Every variant is resolved against the graph **as it was at the requested
2068/// commit**, not against the current graph.
2069#[derive(Debug, Clone, Copy)]
2070pub enum AsOfScope<'a> {
2071    /// Everything the named role may see. The role *definition* is the current
2072    /// one — `roles.json` is a sidecar and has no past version — but its
2073    /// `keys` and `labels` are resolved against the as-of graph.
2074    Role(&'a str),
2075    /// An explicit node-key allow-list. Keys that did not exist at that commit
2076    /// resolve to nothing.
2077    Keys(&'a [String]),
2078    /// A role intersected with a client-supplied allow-list. The intersection
2079    /// is the never-widen rule: a client mask can only narrow a role.
2080    RoleAndKeys(&'a str, &'a [String]),
2081    /// Every live node in one namespace, as the graph was at that commit.
2082    ///
2083    /// A namespace cannot change — it is set at insert and immutable — so the
2084    /// answer is simply "the nodes that existed then and are in this
2085    /// namespace". A name no node uses resolves to nothing, never to
2086    /// everything.
2087    Namespace(&'a str),
2088}
2089
2090impl GraphDb<RealFs> {
2091    /// Open the database at `dir` with default options.
2092    ///
2093    /// Equivalent to `open_with_options(dir, OpenOptions::default())`.
2094    /// Old-format snapshots (V5, V6) are automatically migrated to the
2095    /// current version on a successful load (see [`OpenOptions::auto_migrate`]).
2096    pub fn open(dir: &std::path::Path) -> Result<Self> {
2097        Self::open_with_options(dir, OpenOptions::default())
2098    }
2099
2100    /// Open the database at `dir` with explicit options.
2101    ///
2102    /// When `opts.auto_migrate` is `true` (the default) and the on-disk
2103    /// snapshot is an older format version, this function:
2104    ///   1. Copies the current `snapshot.bin` to `snapshot.bin.bak` (atomic
2105    ///      + fsynced) before any modification.
2106    ///   2. Rewrites `snapshot.bin` at the current format version via
2107    ///      [`GraphDb::snapshot_with`] with `keep_wal: true` (WAL preserved).
2108    ///
2109    /// If migration fails the error is returned and the original files are
2110    /// intact (the `.bak` was written before the new snapshot was attempted).
2111    ///
2112    /// A clean open that finds the snapshot already at the current version
2113    /// deletes any leftover `.bak` file.
2114    ///
2115    /// WAL-only stores (no snapshot) are never auto-migrated on open.
2116    ///
2117    /// `opts.repair_wal` controls the other write this function can make; see
2118    /// [`OpenOptions::repair_wal`]. With both flags `false` the open touches
2119    /// no file on disk.
2120    pub fn open_with_options(dir: &std::path::Path, opts: OpenOptions) -> Result<Self> {
2121        Self::open_dir(dir, opts, true)
2122    }
2123
2124    /// Open without taking the cross-process write lock for the handle's
2125    /// lifetime.
2126    ///
2127    /// Only [`SharedDb`](crate::SharedDb) uses this: a long-lived server holds
2128    /// its handle open indefinitely, so it takes the lock per write instead of
2129    /// keeping every other process out of the store for as long as it runs.
2130    pub(crate) fn open_unlocked(dir: &std::path::Path) -> Result<Self> {
2131        Self::open_dir(dir, OpenOptions::default(), false)
2132    }
2133
2134    fn open_dir(dir: &std::path::Path, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2135        // Header-only peek — 6 bytes, no full decode.
2136        let snap_version = snapshot_version_at(dir)?;
2137
2138        // Full load: decode snapshot + replay WAL + rebuild indexes.
2139        let mut db = Self::open_generic(RealFs::new(dir)?, opts, hold_lock)?;
2140
2141        // A read-only handle writes nothing at open, so it never migrates —
2142        // the old-format snapshot is loaded and left exactly as it is.
2143        if opts.auto_migrate && !opts.read_only {
2144            match snap_version {
2145                Some(ver) if ver < core_storage::snapshot::VERSION => {
2146                    let _tm = std::time::Instant::now();
2147                    // Copy the original snapshot to .bak at OS level — no in-memory
2148                    // buffer required for a 2+ GiB file.
2149                    //
2150                    // Crash-safety: snapshot.bin remains intact (write_atomic inside
2151                    // snapshot_with uses a .tmp+rename) until the V8 write succeeds.
2152                    // A torn .bak on crash is acceptable because the original
2153                    // snapshot.bin is the authoritative source until after the rename.
2154                    std::fs::copy(dir.join("snapshot.bin"), dir.join("snapshot.bin.bak"))
2155                        .map_err(core_storage::GraphError::Io)?;
2156                    trace_migrate!("bak copy done", _tm);
2157                    // Rewrite snapshot at current version; keep WAL intact.
2158                    db.snapshot_with(SnapshotOptions {
2159                        keep_wal: true,
2160                        ..SnapshotOptions::default()
2161                    })?;
2162                    trace_migrate!("snapshot_with done", _tm);
2163                }
2164                Some(_) => {
2165                    // Already current version: remove any leftover .bak.
2166                    let bak = dir.join("snapshot.bin.bak");
2167                    if bak.exists() {
2168                        std::fs::remove_file(&bak).map_err(core_storage::GraphError::Io)?;
2169                    }
2170                }
2171                None => {
2172                    // WAL-only store — nothing to migrate on open.
2173                }
2174            }
2175        }
2176
2177        Ok(db)
2178    }
2179
2180    /// Open a read-only view of the database as it existed after `commit`.
2181    ///
2182    /// Commit indices are 0-based over the current WAL: commit 0 is the state
2183    /// after the first WAL frame, commit N-1 is the state after the N-th (most
2184    /// recent) frame.  Call [`GraphDb::open`] to read the full current state.
2185    ///
2186    /// **Replay base.** [`GraphDb::snapshot`] truncates the WAL when it runs,
2187    /// so as-of can only reach commits recorded in the current WAL (those
2188    /// written after the most recent snapshot, or all commits if no snapshot
2189    /// was ever taken).  Commit 0 in `open_at` always refers to the first
2190    /// frame in the WAL that exists on disk, not the first ever write to the
2191    /// database.  When the on-disk snapshot recorded that it truncated the
2192    /// WAL (V7, default `keep_wal: false`), it is loaded as the base state
2193    /// before frame replay, so the as-of view includes all pre-snapshot data.
2194    /// Snapshots written with `keep_wal: true` (and legacy V5/V6 snapshots)
2195    /// are ignored and replay is WAL-only, as before.
2196    ///
2197    /// **Read-only.** Every mutation method and `snapshot()` on the returned
2198    /// instance returns [`GraphError::ReadOnly`].  Queries, `explain()`, and
2199    /// `stats()` work normally.
2200    ///
2201    /// # Errors
2202    /// - [`GraphError::CommitOutOfRange`] if `commit >= wal_commit_count` (including
2203    ///   when the WAL is empty after a snapshot).
2204    pub fn open_at(dir: &std::path::Path, commit: u64) -> Result<Self> {
2205        Self::open_at_with(RealFs::new(dir)?, commit)
2206    }
2207
2208    /// Run a **read-only** Cypher query against the graph as it existed at
2209    /// `commit` — the "time-travel" / agent-replay query. Opens a temporal view
2210    /// of this store's directory at that commit and executes the read there.
2211    ///
2212    /// The current instance is unaffected. Write statements are rejected (the
2213    /// temporal view is read-only). `commit` is a 0-based WAL commit index;
2214    /// `commit == wal_commit_count` (or `open_at`'s range) yields the newest
2215    /// state. Prefer this over holding many historical instances open.
2216    ///
2217    /// # Errors
2218    /// - [`GraphError::CommitOutOfRange`] if `commit` is past the WAL horizon.
2219    /// - A query error for a malformed or write query.
2220    pub fn query_at(
2221        &self,
2222        commit: u64,
2223        cypher: &str,
2224        params: &std::collections::BTreeMap<String, Value>,
2225    ) -> Result<ResultSet> {
2226        let temporal = self.open_at_for_read(commit, cypher)?;
2227        temporal.query(cypher, params)
2228    }
2229
2230    /// Run a **read-only** Cypher query at `commit`, restricted by `scope`.
2231    ///
2232    /// The **graph** is as of `commit`; the **role definition** is as it is
2233    /// now, because `roles.json` is a sidecar and is never a WAL record — it
2234    /// has no past version to read. A role's `keys` and `labels` are resolved
2235    /// against the commit-`commit` graph, so a role that may see a label sees
2236    /// exactly the nodes that carried it then, and an explicit key that did
2237    /// not exist yet resolves to nothing.
2238    ///
2239    /// [`AsOfScope::RoleAndKeys`] intersects the two: a client allow-list can
2240    /// only narrow what a role may see, never widen it.
2241    ///
2242    /// Write statements are rejected, exactly as [`GraphDb::query_at`] rejects
2243    /// them.
2244    ///
2245    /// # Errors
2246    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2247    ///   range; the error carries that range.
2248    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2249    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2250    /// - A query error for a malformed or write query.
2251    pub fn query_at_scoped(
2252        &self,
2253        commit: u64,
2254        cypher: &str,
2255        params: &std::collections::BTreeMap<String, Value>,
2256        scope: AsOfScope<'_>,
2257    ) -> Result<ResultSet> {
2258        let temporal = self.open_at_for_read(commit, cypher)?;
2259        let mask = temporal.mask_at_scope(scope)?;
2260        temporal.query_masked(cypher, params, &mask)
2261    }
2262
2263    /// As [`GraphDb::query_at_scoped`], with `namespace` intersected into
2264    /// whatever `scope` resolves to.
2265    ///
2266    /// This is what a surface needs when a caller passes `namespace` beside a
2267    /// `role` or a client mask on a time-travel read: [`AsOfScope`] names one
2268    /// restriction, and the namespace is a second one that composes with it
2269    /// rather than replacing it. The intersection is the never-widen rule — a
2270    /// namespace can only narrow what the scope already allows — and both legs
2271    /// are resolved against the graph as it was at `commit`.
2272    ///
2273    /// `AsOfScope::Namespace(ns)` is still the way to ask for a namespace alone.
2274    pub fn query_at_scoped_in_namespace(
2275        &self,
2276        commit: u64,
2277        cypher: &str,
2278        params: &std::collections::BTreeMap<String, Value>,
2279        scope: AsOfScope<'_>,
2280        namespace: &str,
2281    ) -> Result<ResultSet> {
2282        let temporal = self.open_at_for_read(commit, cypher)?;
2283        let mask = temporal
2284            .mask_at_scope(scope)?
2285            .intersect(&temporal.mask_for_namespace(namespace));
2286        temporal.query_masked(cypher, params, &mask)
2287    }
2288
2289    /// Run a **read-only** Cypher query at `commit`, restricted by a
2290    /// [`Scope`](crate::mask::Scope).
2291    ///
2292    /// [`AsOfScope`] names *one* restriction — a role, a key list, a namespace,
2293    /// or a role-and-keys pair. A `Scope` is the general shape a handle carries,
2294    /// and nesting can give it several role or namespace legs at once, so it
2295    /// cannot be spelled as an `AsOfScope`. This is the entry point a scoped
2296    /// handle uses for time travel; `query_at_scoped` stays the way to ask for
2297    /// one named restriction.
2298    ///
2299    /// Both the graph and the scope's key and namespace legs are resolved
2300    /// against `commit`; a role's *definition* is the current one, because
2301    /// `roles.json` is a sidecar with no past version — the same split
2302    /// [`GraphDb::query_at_scoped`] documents.
2303    ///
2304    /// The scope resolves **cold** here: a temporal handle is its own store, so
2305    /// its ids could never be served to a live read, but filling the scope's
2306    /// one-entry key memo from a handle thrown away at the end of this call
2307    /// would evict the live entry for nothing. See
2308    /// [`Scope::resolve_uncached`](crate::mask::Scope::resolve_uncached).
2309    ///
2310    /// # Errors
2311    /// - [`GraphError::CommitOutOfRange`] if `commit` is outside the retained
2312    ///   range; the error carries that range.
2313    /// - [`GraphError::KeyNotFound`] with a `role:` prefix for an unknown role,
2314    ///   or [`GraphError::Corrupt`] when `roles.json` was corrupt at open.
2315    /// - A query error for a malformed or write query.
2316    pub fn query_at_with_scope(
2317        &self,
2318        commit: u64,
2319        cypher: &str,
2320        params: &std::collections::BTreeMap<String, Value>,
2321        scope: &crate::mask::Scope,
2322    ) -> Result<ResultSet> {
2323        let temporal = self.open_at_for_read(commit, cypher)?;
2324        let mask = scope.resolve_uncached(&temporal)?;
2325        temporal.query_masked(cypher, params, &mask)
2326    }
2327
2328    /// Open the temporal view for a time-travel read and refuse write Cypher.
2329    ///
2330    /// Shared by [`GraphDb::query_at`] and [`GraphDb::query_at_scoped`] so both
2331    /// resolve the commit and reject writes identically.
2332    fn open_at_for_read(&self, commit: u64, cypher: &str) -> Result<Self> {
2333        let dir = self.fs.dir().to_path_buf();
2334        let temporal = Self::open_at(&dir, commit)?;
2335        if is_write_tokens(&lex(cypher).map_err(|e| GraphError::QueryError {
2336            detail: format!("lex: {e}"),
2337        })?) {
2338            return Err(GraphError::QueryError {
2339                detail: "query_at is read-only: write statements are not permitted in a \
2340                         time-travel query"
2341                    .into(),
2342            });
2343        }
2344        Ok(temporal)
2345    }
2346}
2347
2348impl<F: Fs> GraphDb<F> {
2349    /// Open over an arbitrary [`Fs`], repairing a torn WAL tail as usual.
2350    pub fn open_with(fs: F) -> Result<Self> {
2351        Self::open_with_repair(fs, true)
2352    }
2353
2354    /// As [`GraphDb::open_with`], but `repair_wal: false` decodes the valid WAL
2355    /// prefix without writing the truncation back. See
2356    /// [`OpenOptions::repair_wal`].
2357    pub fn open_with_repair(fs: F, repair_wal: bool) -> Result<Self> {
2358        Self::open_generic(
2359            fs,
2360            OpenOptions {
2361                repair_wal,
2362                ..OpenOptions::default()
2363            },
2364            true,
2365        )
2366    }
2367
2368    /// Shared open path.
2369    ///
2370    /// `hold_lock` requests the cross-process write lock for the whole handle
2371    /// lifetime — the right behaviour for a plain read-write `GraphDb`, whose
2372    /// owner writes through it directly. [`SharedDb`](crate::SharedDb) passes
2373    /// `false` and takes the lock per write instead, so that a long-lived
2374    /// server does not keep every other process out of the store.
2375    ///
2376    /// A read-only open never takes the lock regardless of `hold_lock`.
2377    fn open_generic(fs: F, opts: OpenOptions, hold_lock: bool) -> Result<Self> {
2378        let mut db = Self::new_empty(fs, opts);
2379        db.read_only = opts.read_only;
2380        if hold_lock && !opts.read_only {
2381            if !db.poll_lock(WRITE_LOCK_WAIT)? {
2382                return Err(GraphError::Busy { holder: None });
2383            }
2384            db.holds_lifetime_lock = true;
2385        }
2386        db.load_from_disk(LoadOrigin::Open)?;
2387        Ok(db)
2388    }
2389
2390    /// A handle with no state loaded: every field at its empty value, the
2391    /// filesystem and options in place. Only [`load_from_disk`] makes it
2392    /// usable.
2393    fn new_empty(fs: F, opts: OpenOptions) -> Self {
2394        Self {
2395            fs,
2396            ids: Arc::new(IdMap::new()),
2397            syms: Arc::new(Interner::new()),
2398            topo: Arc::new(Topology::new()),
2399            props: Arc::new(ColumnStore::new()),
2400            labels: Arc::new(Vec::new()),
2401            ns_names: vec![NS_DEFAULT.to_string()],
2402            node_ns: Vec::new(),
2403            edge_props: Arc::new(EdgeProps::new()),
2404            engine: RuleEngine::new(),
2405            view_store: ViewStore::new(),
2406            fulltext: Arc::new(FulltextIndex::new()),
2407            prop_index: PropertyIndex::new(),
2408            multiplicity: false,
2409            event_sink: None,
2410            fsync: FsyncPolicy::Strict,
2411            commit_seq: 0,
2412            commit_times: core_storage::commit_times::CommitTimes::default(),
2413            commit_times_poisoned: false,
2414            fulltext_rebuild_follows: false,
2415            fulltext_loaded: false,
2416            fulltext_reindexed_a_node: false,
2417            base_wal_truncated: false,
2418            fulltext_reindexed_at_open: 0,
2419            replaying_at_open: false,
2420            intent_unsettled: false,
2421            snapshot_stop: None,
2422            commit_time_override: None,
2423            roles: Some(vec![]),
2424            role_masks: Arc::new(crate::mask::RoleMaskCache::new()),
2425            store_id: crate::mask::StoreId::next(),
2426            subscriptions: Vec::new(),
2427            query_subscriptions: Vec::new(),
2428            sub_capacity: DEFAULT_SUB_CAPACITY,
2429            read_only: false,
2430            total_wal_commits: 0,
2431            base: None,
2432            fold_overlay: None,
2433            delta_tail: Vec::new(),
2434            commits_since_fold: 0,
2435            defer_events: false,
2436            deferred_events: Vec::new(),
2437            degraded: false,
2438            v8_sections_loaded: std::sync::atomic::AtomicBool::new(false),
2439            v8_sections_mutex: std::sync::Mutex::new(()),
2440            last_change: HashMap::new(),
2441            wal_archive_retention: None,
2442            wal_horizon_floor: 0,
2443            archive_genesis_chain: false,
2444            // Nothing is proven until `load_from_disk` has looked at the store.
2445            snapshot_preserved_history: false,
2446            pending_write_authz: None,
2447            slow_query_threshold_ms: std::env::var("MUSHROOMDB_SLOW_QUERY_MS")
2448                .ok()
2449                .and_then(|v| v.parse().ok())
2450                .unwrap_or(100),
2451            slow_queries: std::sync::Mutex::new(SlowQueryLog {
2452                entries: std::collections::VecDeque::new(),
2453                total: 0,
2454            }),
2455            warned_ambiguous_exactness: std::sync::Mutex::new(HashSet::new()),
2456            started_at: std::time::Instant::now(),
2457            wal_consumed: 0,
2458            wal_frames_written: 0,
2459            snapshot_ident: None,
2460            open_opts: opts,
2461            holds_lifetime_lock: false,
2462            lock_denied: false,
2463            pinned: false,
2464        }
2465    }
2466
2467    /// Return every field describing stored graph state to its empty value,
2468    /// leaving this handle's own identity alone.
2469    ///
2470    /// Preserved on purpose: the filesystem, open options, lock ownership, the
2471    /// event sink and subscriptions, fsync policy, degraded flag, and the
2472    /// slow-query configuration and log. A caller that registered a sink or a
2473    /// subscription keeps it across a reload.
2474    fn reset_for_reload(&mut self) {
2475        self.ids = Arc::new(IdMap::new());
2476        self.syms = Arc::new(Interner::new());
2477        self.topo = Arc::new(Topology::new());
2478        self.props = Arc::new(ColumnStore::new());
2479        self.labels = Arc::new(Vec::new());
2480        self.ns_names = vec![NS_DEFAULT.to_string()];
2481        self.node_ns = Vec::new();
2482        self.edge_props = Arc::new(EdgeProps::new());
2483        self.engine = RuleEngine::new();
2484        self.view_store = ViewStore::new();
2485        self.fulltext = Arc::new(FulltextIndex::new());
2486        self.prop_index = PropertyIndex::new();
2487        // Cleared like every other declaration: a reload replays the store's own
2488        // WAL, and the opt-in comes back from it or not at all.
2489        self.multiplicity = false;
2490        self.commit_seq = 0;
2491        self.commit_times = core_storage::commit_times::CommitTimes::default();
2492        self.commit_times_poisoned = false;
2493        self.fulltext_rebuild_follows = false;
2494        self.fulltext_loaded = false;
2495        self.fulltext_reindexed_a_node = false;
2496        self.base_wal_truncated = false;
2497        self.fulltext_reindexed_at_open = 0;
2498        self.replaying_at_open = false;
2499        self.commit_time_override = None;
2500        self.roles = Some(vec![]);
2501        // A fresh cache, not a cleared one: any reader snapshot still holding
2502        // the old `Arc` keeps it to itself, so nothing it memoised against the
2503        // pre-reload store can be read back through this handle.
2504        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
2505        // The same move for memos this handle does not own. `commit_seq` is
2506        // zeroed just above and reseeded from `max(last_change)`, which a
2507        // delete-only commit leaves where it was — so a reload can land back on
2508        // a sequence a caller's `Scope` already cached a mask at. A new id is
2509        // what makes that entry stop matching.
2510        self.store_id = crate::mask::StoreId::next();
2511        self.total_wal_commits = 0;
2512        self.base = None;
2513        self.fold_overlay = None;
2514        self.delta_tail = Vec::new();
2515        self.commits_since_fold = 0;
2516        self.deferred_events = Vec::new();
2517        self.v8_sections_loaded
2518            .store(false, std::sync::atomic::Ordering::Release);
2519        self.last_change = HashMap::new();
2520        self.wal_horizon_floor = 0;
2521        self.archive_genesis_chain = false;
2522        // Re-derived by `load_from_disk` from the store it is about to read.
2523        self.snapshot_preserved_history = false;
2524        self.pending_write_authz = None;
2525        self.wal_consumed = 0;
2526        self.wal_frames_written = 0;
2527        self.snapshot_ident = None;
2528    }
2529
2530    /// Load the snapshot base and replay the WAL into an empty handle — the
2531    /// whole of what opening a store does after the struct exists.
2532    ///
2533    /// Split out of the open path so that [`refresh`](GraphDb::refresh) can
2534    /// rebuild a handle in place, without ownership of `F`, when another
2535    /// process replaces the snapshot underneath it.
2536    ///
2537    /// `origin` decides whether the two repair writes this function can make
2538    /// are appropriate; see [`LoadOrigin`].
2539    fn load_from_disk(&mut self, origin: LoadOrigin) -> Result<usize> {
2540        // Both writes below are crash recovery, and only an open is entitled to
2541        // perform them. A read-only handle promises to touch nothing, and a
2542        // reload driven by `refresh` is looking at a store another process is
2543        // actively writing: what looks like a torn tail there is a peer
2544        // mid-append, and what looks like an orphaned archive may be one that
2545        // peer is about to reference.
2546        let may_repair = origin == LoadOrigin::Open && !self.open_opts.read_only;
2547        let repair_wal = self.open_opts.repair_wal && may_repair;
2548        let db = self;
2549        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
2550        db.archive_genesis_chain = db.fs.has_genesis_marker();
2551        // Opening cleanup: remove orphaned archives — archives whose frames all
2552        // fall below the horizon floor.  Orphans arise when a crash interrupted
2553        // the retention-prune sequence after the floor was written but before
2554        // all surplus archives were deleted.  Safe to delete: floor already
2555        // accounts for their frames.
2556        if may_repair {
2557            db.cleanup_orphaned_archives()?;
2558        }
2559        let _t0 = std::time::Instant::now();
2560        // Peek 6 bytes to determine snapshot version without reading the full
2561        // file. For RealFs this is a true partial read (O(1)); for SimFs the
2562        // default impl reads all bytes and truncates (still correct).
2563        let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
2564        // V8, V9 and V10 share the mmap-able container; V9 only adds section 12
2565        // and V10 adds nothing but its version stamp. A version outside that set
2566        // falls through to the full-read path below, where `snapshot::decode`
2567        // either handles it (V5–V7) or refuses it by name — which is what stops
2568        // an older binary before it reaches the WAL.
2569        let is_v8 = snap_header.len() >= 6
2570            && &snap_header[0..4] == b"GDB1"
2571            && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
2572                snap_header[4],
2573                snap_header[5],
2574            ]));
2575        // The version this store is stamped with, or `None` when it has never
2576        // been snapshotted. Read from the same six bytes, with no second read.
2577        let snapshot_version = if snap_header.len() >= 6 && &snap_header[0..4] == b"GDB1" {
2578            Some(u16::from_le_bytes([snap_header[4], snap_header[5]]))
2579        } else {
2580            None
2581        };
2582        // No snapshot means no snapshot has ever truncated the WAL, so this
2583        // handle can prove the history is whole. Once a snapshot exists that
2584        // this handle did not take, it cannot: see `snapshot_preserved_history`.
2585        db.snapshot_preserved_history = snap_header.is_empty();
2586        if is_v8 {
2587            // V8: map the file zero-copy (RealFs) or read full bytes (SimFs).
2588            // No 2.4GB heap Vec is allocated on RealFs.
2589            let mapped = Arc::new(
2590                if let Some(snap_path) = db.fs.snapshot_path() {
2591                    core_storage::v8::MappedBase::map(&snap_path)
2592                } else {
2593                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
2594                    core_storage::v8::MappedBase::from_bytes(snap_bytes)
2595                }
2596                .map_err(|e| GraphError::Corrupt {
2597                    detail: format!("v8: mmap open: {e:?}"),
2598                })?,
2599            );
2600            db.restore_v8_base(Arc::clone(&mapped))?;
2601            trace_open!("restore_v8_base", _t0);
2602            db.base = Some(mapped);
2603            trace_open!("base assigned", _t0);
2604            // Only a snapshot from a writer that could leave a deleted node's
2605            // edges behind is searched for them.
2606            if !db.base.as_ref().is_some_and(|base| base.edges_swept()) {
2607                db.drop_edges_of_retired_nodes();
2608            }
2609            db.load_fulltext_from_base();
2610            trace_open!("full-text index read from the snapshot", _t0);
2611        } else if !snap_header.is_empty() {
2612            // Legacy V5-V7: full read required for decode.
2613            let snap_bytes = db.fs.read(FileId::Snapshot)?;
2614            if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
2615                db.restore_snapshot_state(state)?;
2616            }
2617        }
2618        // else: snap_header is empty = no snapshot file, fresh store.
2619        //
2620        // Seed commit_seq from the highest seq persisted in last_change so that
2621        // WAL-replay frames (which start at commit_seq+1) always exceed any seq
2622        // already stored in the snapshot.  Without this, a db with one snapshot
2623        // commit would save last_change["a"]=1, then on reopen the first WAL
2624        // frame would replay at seq=1 again — colliding and making WAL-tail
2625        // mutations indistinguishable from the snapshot baseline.
2626        //
2627        // Safety invariant (seq-recycling):
2628        //   Recycled seqs (those below the seeded baseline) were NEVER stored in
2629        //   last_change because they belonged to a previous db lifetime — a new
2630        //   db starts at commit_seq=0 with an empty last_change.  Therefore no
2631        //   CAS precondition can carry a recycled seq as its `expected` value
2632        //   and accidentally match a live node's last_change entry.
2633        //
2634        // `expected:0` on a deleted-then-reinserted node:
2635        //   After deletion, last_changed() returns None; callers that call
2636        //   last_changed() and then use NodeUnchangedSince get None.unwrap_or(0)
2637        //   = 0.  The reinserted node gets seq > 0, so a subsequent CAS with
2638        //   expected=0 correctly conflicts.  The only way to observe actual=0 in
2639        //   a CasConflict would be a caller that invented expected=0 without ever
2640        //   calling last_changed() — unreachable via the documented API contract.
2641        if let Some(&max_seq) = db.last_change.values().max() {
2642            db.commit_seq = db.commit_seq.max(max_seq);
2643        }
2644        let bytes = db.fs.read(FileId::Wal)?;
2645        let (records, valid_len) = decode_all(&bytes);
2646        // The valid prefix is replayed either way; `repair_wal` only decides
2647        // whether the truncation is written back. A reader that races a live
2648        // appender must not persist a truncation the writer never asked for.
2649        if valid_len < bytes.len() && repair_wal {
2650            db.fs.write_atomic(FileId::Wal, &bytes[..valid_len])?;
2651        }
2652        // WAL-present path: build indexes eagerly BEFORE replay so that the
2653        // first replayed record does not trigger the lazy-init guard (which
2654        // would call reindex_all_load_state on an empty graph, defeating the
2655        // point of restoring IVF/HNSW blobs from the snapshot).
2656        if !records.is_empty() {
2657            db.ensure_v8_base_sections_loaded();
2658            trace_open!("lazy sections loaded (WAL path)", _t0);
2659        }
2660        // Scoped to this call: `refresh()` also replays frames and is *not*
2661        // followed by a rebuild, so its backfill must still run.
2662        //
2663        // And only when a rebuild does follow. An index read from the snapshot
2664        // is kept, so a pair declared after that snapshot is indexed by its
2665        // own backfill or not at all.
2666        // Every decoded frame occupies an index, replayed or not: markers
2667        // are state no-ops but they are not index no-ops.
2668        let decoded_frames = records.len() as u64;
2669        // A snapshot that kept its WAL says how much of it it already holds.
2670        // Those frames are counted, and not applied a second time.
2671        //
2672        // So does the intent of a snapshot that was to replace its WAL and
2673        // did not get that far: the same state, reached by a crash.
2674        let (interrupted, intent_left_over) = db.interrupted_snapshot(&bytes)?;
2675        // An intent that is not about this snapshot and this WAL is left over
2676        // from a snapshot that finished, or never wrote its snapshot. It has
2677        // to go before this handle appends: once new frames follow, a WAL can
2678        // come to begin with the bytes an old intent names. It goes at the
2679        // first append and not here, because only an appender holds the write
2680        // lock, and a handle without it may be looking at a peer's snapshot
2681        // half way through.
2682        db.intent_unsettled = intent_left_over;
2683        let held = db
2684            .base
2685            .as_ref()
2686            .and_then(|base| base.wal_mark())
2687            .or(interrupted)
2688            .filter(|mark| mark.matches(&bytes))
2689            .map(|mark| (mark.len as usize, decode_all(&bytes[..mark.len as usize])))
2690            // The mark was taken at the end of a whole WAL, so it ends on a
2691            // frame boundary; one that does not is not this WAL's.
2692            .filter(|(len, (_, consumed))| consumed == len)
2693            .map(|(_, (held, _))| held.len());
2694        let marked_wal_is_here = held.is_some();
2695        let held = held.unwrap_or(0);
2696        // A snapshot that kept its WAL has its index read on the strength of
2697        // its mark. If the WAL beside it is not the one marked — it lost its
2698        // tail, or was repaired shorter — the whole of it replays over the
2699        // snapshot, and what that produces is not what the index was read
2700        // for: a replayed insert can relabel a node and leave a posting under
2701        // a field its new label does not index. The index is set aside and
2702        // built after the replay, as it was before it could be read.
2703        if db.fulltext_loaded && !db.base_wal_truncated && !marked_wal_is_here {
2704            db.fulltext = Arc::new(core_storage::fulltext::FulltextIndex::new());
2705            db.fulltext_loaded = false;
2706        }
2707        db.fulltext_rebuild_follows = !db.fulltext_loaded;
2708        db.fulltext_reindexed_a_node = false;
2709        db.replaying_at_open = true;
2710        let replayed = db.apply_frames_over(records, held);
2711        db.fulltext_rebuild_follows = false;
2712        db.replaying_at_open = false;
2713        let replayed = replayed?;
2714        // ── The multiplicity declaration, recovered from the stamp ───────────
2715        //
2716        // The opt-in is re-emitted into every baseline WAL a snapshot writes, so
2717        // ordinarily the replay above has already found it. But
2718        // `snapshot_with(archive_wal)` renames the live WAL away and writes its
2719        // replacement afterwards, and between those two points the store holds
2720        // no live declaration at all. A crash there — or a single `Err` from any
2721        // call in between — used to opt the store back out on the next open
2722        // (defect #22): it would stop counting and write a **V9** snapshot while
2723        // the archives still carried discriminant 23, which is the exact state
2724        // the V10 stamp exists to prevent.
2725        //
2726        // The V10 stamp is what carries the conclusion. The archive clause is a
2727        // scope restriction, not a second proof — an earlier version of this
2728        // comment, and defect #22, claimed otherwise, and defect #33 corrects
2729        // it. Taking the two in order:
2730        //
2731        // **The stamp.** `snapshot_with` stamps the snapshot from
2732        // `self.multiplicity` *before* it touches the WAL, and nothing rewrites
2733        // a V10 snapshot at V9 while the store believes it is opted in. So a
2734        // V10 stamp says this store reached `enable_multiplicity` far enough to
2735        // write the snapshot — and, decisively, that every older binary already
2736        // refuses this store by name. Opting in here can cost such a reader
2737        // nothing it was not already being told.
2738        //
2739        // **What the archive clause does not prove.** It is *not* evidence that
2740        // the archive was taken while the store was opted in. A store can
2741        // archive at V9 and opt in afterwards, leaving a V10 snapshot standing
2742        // beside an archive whose WAL carries no declaration at all — see
2743        // `a_failed_opt_in_beside_an_archive_comes_back_opted_in`. The inference
2744        // held in the success case by coincidence, not by construction.
2745        //
2746        // **What it does buy: scope.** Without it the recovery would also fire
2747        // on a store that reached the V10 snapshot write and then failed with no
2748        // archive in sight. That store must stay opted out, and can: no WAL was
2749        // renamed away, nothing carries discriminant 23, and its next snapshot
2750        // rewrites at V9, which puts it back within reach of every older reader.
2751        // An archive is the marker for the one state that is not recoverable
2752        // that way — a WAL renamed away that may hold the only copy of the
2753        // declaration. `no_crash_leaves_discriminant_23_unguarded` pins that
2754        // line: it sweeps a workload with no archives at all and refuses a
2755        // V10-implies-enabled rule.
2756        //
2757        // **The invariant, whichever way the clause goes:** the recovery never
2758        // opts in a store whose snapshot is not V10. A V9 store has made no
2759        // promise to an older reader, so opting it in would start writing
2760        // discriminant 23 behind a stamp that does not guard it. Pinned by
2761        // `the_recovery_never_opts_in_a_store_whose_snapshot_is_not_v10` and
2762        // `the_recovery_does_not_opt_a_store_in_by_itself`.
2763        //
2764        // What this recovery cannot do is make the opt-in atomic; it is not,
2765        // and `enable_multiplicity` says so. See defects #32-#34.
2766        if !db.multiplicity
2767            && snapshot_version == Some(core_storage::snapshot::VERSION_10)
2768            && !db.fs.list_archives()?.is_empty()
2769        {
2770            db.multiplicity = true;
2771        }
2772        // The cursor sits at the end of the valid prefix, not the end of the
2773        // file: a torn or still-being-written tail is unconsumed by definition
2774        // and stays visible to `is_stale` until it decodes.
2775        db.wal_consumed = valid_len as u64;
2776        // The frame cursor counts the same sequence `all_frames` returns:
2777        // surviving archives first, then the live WAL, offset by the floor.
2778        // Counting the archives separately rather than calling
2779        // `wal_total_commits` keeps the live WAL from being decoded twice on
2780        // every open, and costs nothing on a store that has never archived.
2781        db.wal_frames_written = db.wal_horizon_floor + db.archive_frame_count()? + decoded_frames;
2782        db.snapshot_ident = db.fs.snapshot_ident().map_err(GraphError::Io)?;
2783        trace_open!("wal replay done", _t0);
2784        // Rebuild view values after WAL replay only when there is no V8 base.
2785        // With a V8 base, view values are correct in the snapshot and are updated
2786        // incrementally during WAL replay (on_edge_changed / on_prop_changed).
2787        // A full rebuild would read overlay-only props (empty after restore_v8_base)
2788        // and overwrite correct base values with wrong results (e.g. NeighborAgg
2789        // Sum reads no "score" in overlay → writes 0.0, shadowing the correct
2790        // base value).
2791        if db.base.is_none() {
2792            let topo_view = TopologyView::owned(&db.topo);
2793            db.view_store.rebuild_all(
2794                Arc::make_mut(&mut db.props),
2795                &topo_view,
2796                &db.ids,
2797                &db.syms,
2798                &db.labels,
2799            );
2800        }
2801        // The full-text index after WAL replay. One read from the snapshot has
2802        // had the replay applied to it record by record and is kept — unless
2803        // the replay indexed a node the snapshot already held, which doubles
2804        // that node's postings. Then, and wherever no index was read, it is
2805        // built from the declared pairs and the stored values.
2806        db.fulltext_loaded = db.fulltext_loaded && !db.fulltext_reindexed_a_node;
2807        if db.fulltext_loaded {
2808            #[cfg(debug_assertions)]
2809            {
2810                let mut rebuilt = (*db.fulltext).clone();
2811                rebuilt.rebuild_all(
2812                    &db.ids,
2813                    &db.labels,
2814                    &db.syms,
2815                    build_props_view(&db.props, &db.base),
2816                );
2817                debug_assert!(
2818                    rebuilt == *db.fulltext,
2819                    "the full-text index read from the snapshot is not the one a rebuild gives"
2820                );
2821            }
2822        } else {
2823            Arc::make_mut(&mut db.fulltext).rebuild_all(
2824                &db.ids,
2825                &db.labels,
2826                &db.syms,
2827                build_props_view(&db.props, &db.base),
2828            );
2829        }
2830        trace_open!("full-text index ready", _t0);
2831        db.prop_index.rebuild_all(
2832            &db.ids,
2833            &db.labels,
2834            &db.syms,
2835            build_props_view(&db.props, &db.base),
2836        );
2837        // Namespaces: one pass over the `ns` column, after the snapshot is
2838        // restored and the WAL replayed. Replay maintains `node_ns` record by
2839        // record as well; this pass is what makes a snapshot-only open right,
2840        // and it reads nothing on a store with no `ns` column.
2841        db.rebuild_node_ns();
2842        // A mid-build snapshot's HNSW blob carries `complete == false`.
2843        // Register it so `serve`'s ticker sees work without waiting for a write.
2844        db.register_outstanding_index_builds();
2845        // Load roles sidecar. Missing file = no roles (Some(vec![])).
2846        // Corrupt/unparseable = poisoned (None); mask_for_role will fail-loud.
2847        db.roles = Self::load_roles_from_fs(&db.fs)?;
2848        // The time sidecar. Absent is the normal case for any store written
2849        // before v0.6.11 and is not an error; unreadable is recorded so date
2850        // queries can say "damaged" rather than "none recorded".
2851        db.load_commit_times_from_fs();
2852        // Capture the initial MVCC fold so reader() is ready immediately.
2853        db.fold_now();
2854        trace_open!("open_with complete", _t0);
2855        Ok(replayed)
2856    }
2857
2858    /// Apply decoded WAL frames to in-memory state, exactly as the open-path
2859    /// replay does — same `apply` calls, same per-frame delta drain, same
2860    /// commit-seq and last-change bookkeeping. Rules therefore fire and derived
2861    /// edges appear identically whether a frame arrives at open, from a local
2862    /// commit, or from another process by way of [`refresh`](GraphDb::refresh).
2863    ///
2864    /// Returns the number of frames applied.
2865    ///
2866    /// Deltas are drained and discarded per frame: replayed frames are already
2867    /// reflected on disk, so they are not news to a subscriber, and draining
2868    /// inside the loop keeps `pending_deltas` O(1) over a large WAL (I-2).
2869    fn apply_frames(&mut self, records: Vec<WalRecord>) -> Result<usize> {
2870        self.apply_frames_over(records, 0)
2871    }
2872
2873    /// [`Self::apply_frames`] over a base that already holds the first `held`
2874    /// records: the state a snapshot that kept its WAL was taken at. Those
2875    /// are numbered like any other frame and not applied, because applying a
2876    /// write to the state it produced is not always a no-op — an insert
2877    /// under a key since renamed or deleted makes a second node. A
2878    /// declaration among them is applied all the same: the pairs a store has
2879    /// declared are known from the WAL — and, when a snapshot carries a
2880    /// full-text index that is read, from that index too.
2881    fn apply_frames_over(&mut self, records: Vec<WalRecord>, held: usize) -> Result<usize> {
2882        if records.is_empty() {
2883            return Ok(0);
2884        }
2885        // Materialize any state retained in the mmap base before the first
2886        // frame lands, so a replayed record cannot trip the lazy-init guard and
2887        // rebuild indexes from an empty graph. Both calls are idempotent.
2888        //
2889        // Not before a declaration, though. Every truncating snapshot re-emits
2890        // one record for each declared pair, so a snapshotted store with any
2891        // declaration always has frames to replay; counting those as writes
2892        // made every open of such a store pay the scan a store with no
2893        // declaration defers to its first write.
2894        let mut retained_consumed = false;
2895        let applied = records.len();
2896        // A live commit turns on the engine's report of the edges its rules
2897        // add and retract, which is what keeps a view over a derived edge
2898        // type current. Replay has to do the same, or a store with a snapshot
2899        // — where nothing recomputes the views afterwards — reopens with the
2900        // counts the snapshot had.
2901        let emitted_before = self.engine.emit_deltas();
2902        for (i, rec) in records.into_iter().enumerate() {
2903            if !self.view_store.is_empty() && !self.engine.emit_deltas() {
2904                self.engine.set_emit_deltas(true);
2905            }
2906            // A frame the snapshot already holds is counted and not applied,
2907            // unless it is a declaration.
2908            let apply = i >= held || is_declaration(&rec);
2909            if apply && !retained_consumed && !is_declaration(&rec) {
2910                self.ensure_v8_base_sections_loaded();
2911                self.engine.consume_retained_state_eager(
2912                    &self.ids,
2913                    &self.syms,
2914                    &self.labels,
2915                    build_props_view(&self.props, &self.base),
2916                );
2917                retained_consumed = true;
2918            }
2919            if apply {
2920                self.apply(&rec)?;
2921            }
2922            let _ = self.engine.drain_deltas();
2923            // Track commit_seq during replay so last_change entries are
2924            // consistent with the seqs assigned by log_then_apply_with on
2925            // subsequent live commits.  After N replayed frames, commit_seq=N;
2926            // live commits begin at N+1.
2927            self.commit_seq += 1;
2928            let replay_seq = self.commit_seq;
2929            self.update_last_change_from_rec(&rec, replay_seq);
2930        }
2931        // Enforce I-2: if the per-frame drain above is ever removed or skipped,
2932        // this assert catches the regression in debug builds immediately.
2933        debug_assert_eq!(
2934            self.engine.pending_delta_count(),
2935            0,
2936            "pending_deltas non-empty after replay — \
2937             per-frame drain must run inside the loop to keep memory O(1)"
2938        );
2939        // T2 note: the per-frame drain IS the suppression seam for replay.
2940        // Any future as-of replay path (Plan-15 T2) must drain here to feed
2941        // replaying subscribers; the mechanism is already in place.
2942        let _ = self.engine.drain_deltas(); // belt-and-braces no-op after loop drain
2943        self.engine.set_emit_deltas(emitted_before);
2944        Ok(applied)
2945    }
2946
2947    // ── Multi-process safety: cross-process write lock + WAL tailing ──────────
2948    //
2949    // mushroomdb is many-readers / one-writer across processes. Writers take an
2950    // advisory exclusive lock on the store's `LOCK` file; readers never do.
2951    // Every handle tracks how much of the WAL it has consumed, so it can pick
2952    // up another process's commits by decoding only the new tail rather than
2953    // reopening. See `docs/site/concurrency.md`.
2954
2955    /// Whether the store on disk has moved ahead of (or out from under) this
2956    /// handle's in-memory state.
2957    ///
2958    /// True when the WAL's length differs from this handle's cursor — another
2959    /// process committed, or is mid-append — or when the snapshot file's
2960    /// identity changed. Costs two metadata lookups and reads no file contents,
2961    /// so it is cheap enough for a read path to call.
2962    ///
2963    /// Always false for an as-of view from [`GraphDb::open_at`]: such a view is
2964    /// pinned to one commit and later commits are deliberately invisible to it.
2965    pub fn is_stale(&self) -> Result<bool> {
2966        if self.pinned {
2967            return Ok(false);
2968        }
2969        if self.fs.wal_len().map_err(GraphError::Io)? != self.wal_consumed {
2970            return Ok(true);
2971        }
2972        Ok(self.fs.snapshot_ident().map_err(GraphError::Io)? != self.snapshot_ident)
2973    }
2974
2975    /// Bring this handle up to date with every commit other processes have made,
2976    /// and return how many frames were applied.
2977    ///
2978    /// The WAL tail is decoded from this handle's cursor and applied through the
2979    /// same path the open replay uses, so rules fire and derived edges appear
2980    /// exactly as they would on a fresh open. Interners, id maps and indexes
2981    /// stay valid for the same reason.
2982    ///
2983    /// A frame another process is still writing is left alone: a trailing
2984    /// partial frame is a wait, not a corruption, and the handle stays stale
2985    /// until that frame is complete. Nothing is written to disk, so a read-only
2986    /// handle can refresh freely.
2987    ///
2988    /// When the snapshot file's identity changed, or the WAL is shorter than
2989    /// this handle's cursor, the WAL no longer continues our state — another
2990    /// process snapshotted or archived. The handle is then rebuilt from disk
2991    /// with the options it was opened with, and the return value is the number
2992    /// of frames in the new WAL.
2993    ///
2994    /// Returns 0 for an as-of view, which never follows later commits.
2995    ///
2996    /// # Errors
2997    ///
2998    /// An error here leaves the handle **degraded**: it got partway through
2999    /// applying the tail, or partway through a reload, so its in-memory state
3000    /// no longer matches any point on disk. Further mutations are refused and
3001    /// the handle must be reopened. Nothing on disk was damaged — the store
3002    /// itself is fine, and a fresh open recovers it.
3003    pub fn refresh(&mut self) -> Result<u64> {
3004        if self.pinned {
3005            return Ok(0);
3006        }
3007        let disk_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
3008        let wal_len = self.fs.wal_len().map_err(GraphError::Io)?;
3009        if disk_ident != self.snapshot_ident || wal_len < self.wal_consumed {
3010            // The WAL no longer continues our state: rebuild from disk. State
3011            // is cleared first, so a failed load leaves an empty handle — mark
3012            // it degraded rather than let a caller read an empty graph as if
3013            // it were the store's contents.
3014            self.reset_for_reload();
3015            return match self.load_from_disk(LoadOrigin::Reload) {
3016                Ok(frames) => Ok(frames as u64),
3017                Err(e) => {
3018                    self.degraded = true;
3019                    Err(e)
3020                }
3021            };
3022        }
3023        if wal_len == self.wal_consumed {
3024            return Ok(0);
3025        }
3026        let tail = self
3027            .fs
3028            .read_range(FileId::Wal, self.wal_consumed)
3029            .map_err(GraphError::Io)?;
3030        let (records, valid_len) = decode_all(&tail);
3031        let decoded_frames = records.len() as u64;
3032        let applied = match self.apply_frames(records) {
3033            Ok(n) => n,
3034            Err(e) => {
3035                // Some frames landed and some did not, and the cursor cannot
3036                // say how many. Advancing it would skip the rest; leaving it
3037                // would replay what already applied. Neither is recoverable in
3038                // place, so refuse further writes and require a reopen.
3039                self.degraded = true;
3040                return Err(e);
3041            }
3042        };
3043        // Advance by the bytes actually decoded, never by the file length: an
3044        // incomplete trailing frame stays unconsumed for the next refresh.
3045        self.wal_consumed += valid_len as u64;
3046        self.wal_frames_written += decoded_frames;
3047        // The peer that wrote those frames also stamped them. Absorbing the
3048        // frames without the stamps leaves this handle resolving dates from a
3049        // prefix of the store's history, and — while our own map is still
3050        // empty — one commit away from rewriting the peer's file out of
3051        // existence (`first` below decides on the map, and the map is what we
3052        // just brought up to date).
3053        self.load_commit_times_from_fs();
3054        if applied > 0 {
3055            // Peer commits must reach `reader()` snapshots taken from here on.
3056            // A full fold is what open does; refresh does not build per-commit
3057            // deltas, so there is nothing cheaper that stays correct.
3058            self.fold_now();
3059        }
3060        Ok(applied as u64)
3061    }
3062
3063    /// Byte offset of the WAL prefix this handle has applied.
3064    ///
3065    /// Exposed for tests that assert the cursor tracks appended bytes exactly.
3066    #[doc(hidden)]
3067    pub fn wal_consumed(&self) -> u64 {
3068        self.wal_consumed
3069    }
3070
3071    /// Rewind the WAL cursor after the group-commit drain thread truncated a
3072    /// failed group off the tail, so the cursor still describes the file.
3073    pub(crate) fn set_wal_consumed(&mut self, len: u64) {
3074        self.wal_consumed = len;
3075    }
3076
3077    /// One non-blocking attempt at the cross-process write lock.
3078    ///
3079    /// Takes `&self` so a caller can poll for the lock *before* it acquires the
3080    /// in-process write guard. That ordering is what keeps a busy peer in
3081    /// another process from stalling this process's readers.
3082    ///
3083    /// A handle that owns the lock for its lifetime always succeeds.
3084    pub(crate) fn try_cross_process_lock(&self) -> Result<bool> {
3085        if self.holds_lifetime_lock {
3086            return Ok(true);
3087        }
3088        self.fs.try_lock_exclusive().map_err(GraphError::Io)
3089    }
3090
3091    /// Poll for the cross-process write lock until `wait` elapses.
3092    ///
3093    /// One attempt is always made, so a zero wait is a single try. Returns
3094    /// `false` when the lock is still held elsewhere at the deadline; nothing
3095    /// has been written and retrying later is safe.
3096    ///
3097    /// Only the plain-`GraphDb` open path uses this, where the caller owns the
3098    /// handle outright. [`SharedDb`](crate::SharedDb) polls
3099    /// [`try_cross_process_lock`](GraphDb::try_cross_process_lock) itself so
3100    /// that it holds no in-process guard while it waits.
3101    fn poll_lock(&self, wait: std::time::Duration) -> Result<bool> {
3102        let deadline = std::time::Instant::now() + wait;
3103        loop {
3104            if self.try_cross_process_lock()? {
3105                return Ok(true);
3106            }
3107            let now = std::time::Instant::now();
3108            if now >= deadline {
3109                return Ok(false);
3110            }
3111            std::thread::sleep(LOCK_POLL_INTERVAL.min(deadline.saturating_duration_since(now)));
3112        }
3113    }
3114
3115    /// Open a cross-process write scope, given the outcome of an already-made
3116    /// lock attempt.
3117    ///
3118    /// The caller polls for the lock first — outside any in-process guard — and
3119    /// passes what it got. On success this refreshes, so the writes about to
3120    /// happen land on top of every other process's commits. On failure the
3121    /// handle refuses WAL-appending mutations and `snapshot()` with
3122    /// [`GraphError::Busy`] until [`end_write_lock`](GraphDb::end_write_lock)
3123    /// closes the scope, so a caller holding a guard cannot write behind
3124    /// another process's back.
3125    ///
3126    /// A handle that already owns the lock for its lifetime skips the refresh:
3127    /// no other process can have written, so there is nothing to pick up.
3128    pub(crate) fn enter_write_scope(&mut self, acquired: bool) -> Result<()> {
3129        self.lock_denied = !acquired;
3130        if !acquired || self.holds_lifetime_lock {
3131            return Ok(());
3132        }
3133        if let Err(e) = self.refresh() {
3134            // Do not hold a lock we cannot use: release it and let the caller
3135            // see the underlying failure.
3136            let _ = self.fs.unlock();
3137            self.lock_denied = true;
3138            return Err(e);
3139        }
3140        Ok(())
3141    }
3142
3143    /// Close a cross-process write scope opened by
3144    /// [`enter_write_scope`](GraphDb::enter_write_scope): release the lock and
3145    /// clear the Busy latch. Safe to call when the lock was never taken.
3146    pub(crate) fn end_write_lock(&mut self) {
3147        self.lock_denied = false;
3148        if !self.holds_lifetime_lock {
3149            // Releasing a lock we do not hold is a no-op; a failure to release
3150            // is reported by the OS closing the descriptor at handle drop.
3151            let _ = self.fs.unlock();
3152        }
3153    }
3154
3155    /// As-of replay for [`GraphDb::open_at`]: snapshot base (only when the
3156    /// snapshot truncated the WAL) plus the first `commit + 1` WAL frames;
3157    /// see [`GraphDb::open_at`] for the semantics.  The per-frame drain
3158    /// mirrors `open_with` exactly so pending_delta_count is 0 on exit.
3159    /// Restore all persisted state from a decoded snapshot. Shared by
3160    /// `open_with` and (when the snapshot truncated the WAL) `open_at_with`.
3161    fn restore_snapshot_state(
3162        &mut self,
3163        state: core_storage::snapshot::SnapshotState,
3164    ) -> Result<()> {
3165        self.ids = Arc::new(state.ids);
3166        self.syms = Arc::new(state.syms);
3167        self.topo = Arc::new(state.topo);
3168        self.props = Arc::new(state.props);
3169        self.labels = Arc::new(state.labels);
3170        self.edge_props = Arc::new(state.edge_props);
3171        // Cross-section label integrity for V5/V7 snapshots: same invariants as
3172        // restore_v8_base.  A crafted bincode snapshot with a short `labels` vec,
3173        // out-of-range sym ids, or a sentinel label on a live node would otherwise
3174        // open successfully and panic later in `NodeRef::label()` or
3175        // `neighborhood_masked()`.  Catching it here turns those into typed
3176        // `GraphError::Corrupt` at open time.
3177        {
3178            let ids_len = self.ids.len();
3179            if self.labels.len() != ids_len {
3180                return Err(GraphError::Corrupt {
3181                    detail: format!(
3182                        "snapshot: labels vec has {} entries but id table has {} total slots",
3183                        self.labels.len(),
3184                        ids_len,
3185                    ),
3186                });
3187            }
3188            let syms_len = self.syms.len() as u32;
3189            for (i, &sym) in self.labels.iter().enumerate() {
3190                let is_tombstoned = self.ids.is_tombstoned(i as u32);
3191                if sym == u32::MAX {
3192                    if !is_tombstoned {
3193                        return Err(GraphError::Corrupt {
3194                            detail: format!(
3195                                "snapshot: live node at id slot {i} has sentinel label (u32::MAX)"
3196                            ),
3197                        });
3198                    }
3199                } else if sym >= syms_len {
3200                    return Err(GraphError::Corrupt {
3201                        detail: format!(
3202                            "snapshot: label at id slot {i} references sym {sym} \
3203                             which is out of interner range ({syms_len})"
3204                        ),
3205                    });
3206                }
3207            }
3208        }
3209        let defs: Vec<RuleDef> = state
3210            .rule_defs
3211            .iter()
3212            .map(|b| {
3213                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3214                    detail: format!("snapshot rule_def deserialize: {e}"),
3215                })
3216            })
3217            .collect::<Result<Vec<_>>>()?;
3218        self.engine =
3219            RuleEngine::from_persist(defs, state.provenance, state.rule_tripped, state.rule_fires);
3220        // Candidate indexes are rebuilt lazily on the first mutation (see
3221        // RuleEngine::on_node_changed).  HNSW blobs and IVF centroids from the
3222        // snapshot are retained without deserializing so that:
3223        //   - clean-open (empty WAL): indexes stay empty; blobs load on first
3224        //     ANN query via ensure_hnsw_loaded, or on first mutation via the
3225        //     lazy-init guard which calls reindex_all_load_state (the scan
3226        //     skips the HNSW build for every side the blob supplies).
3227        //   - WAL-present: open_with calls consume_retained_state_eager before
3228        //     replay so HNSW/IVF are live before any record fires the hooks.
3229        let ivf_bytes = if state.ivf_state.is_empty() {
3230            Vec::new()
3231        } else {
3232            bincode::serialize(&state.ivf_state).expect("IVF state serialize cannot fail")
3233        };
3234        // Store blobs without eagerly deserializing them.
3235        // `self.ids` is the snapshot's id table at this point — WAL replay has
3236        // not run — so its length is the line an interrupted build is detected
3237        // against.
3238        let snapshot_ids = self.ids.len() as u32;
3239        self.engine
3240            .store_snapshot_state(state.hnsw_state, ivf_bytes, snapshot_ids);
3241        // Restore view defs from snapshot (V5).
3242        // The ColumnStore already contains view values from the snapshot;
3243        // use restore_view (no collision check, no backfill) so the store
3244        // is aware of the definitions.  rebuild_all runs after WAL replay.
3245        for def_bytes in &state.view_defs {
3246            let def: ViewDef =
3247                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3248                    detail: format!("snapshot view_def deserialize: {e}"),
3249                })?;
3250            self.view_store
3251                .restore_view(def)
3252                .map_err(|e| GraphError::Corrupt {
3253                    detail: format!("snapshot view restore: {e}"),
3254                })?;
3255        }
3256        Ok(())
3257    }
3258
3259    /// Restore all persisted state from a V8 `MappedBase` snapshot, **except**
3260    /// topology (`self.topo` stays empty and serves as the WAL-replay overlay).
3261    ///
3262    /// `self.props` IS fully materialised from the base so that HNSW/IVF blob
3263    /// deserialization and view rebuild have access to all column data.
3264    fn restore_v8_base(&mut self, mapped: Arc<core_storage::v8::MappedBase>) -> Result<()> {
3265        self.ids = Arc::new(archived_to_idmap(mapped.ids().map_err(|e| {
3266            GraphError::Corrupt {
3267                detail: format!("v8: ids section: {e:?}"),
3268            }
3269        })?));
3270        self.syms = Arc::new(archived_to_interner(mapped.syms().map_err(|e| {
3271            GraphError::Corrupt {
3272                detail: format!("v8: syms section: {e:?}"),
3273            }
3274        })?));
3275
3276        // C1: self.props is left as an empty overlay. Column reads go through
3277        // props_view() (ColumnsView::with_base), which consults the archived base
3278        // section zero-copy. This avoids the O(columns) heap copy at every open.
3279
3280        // self.topo deliberately left as Topology::new() — overlay path.
3281
3282        let meta = decode_meta(mapped.meta_bytes().map_err(|e| GraphError::Corrupt {
3283            detail: format!("v8: meta section: {e:?}"),
3284        })?)
3285        .map_err(|e| GraphError::Corrupt {
3286            detail: format!("v8: meta decode: {e:?}"),
3287        })?;
3288        self.base_wal_truncated = meta.wal_truncated;
3289        self.labels = Arc::new(meta.labels);
3290        // Cross-section label integrity: labels must cover every id slot (live
3291        // and tombstoned), every non-sentinel sym must be within the interner's
3292        // bound, and no live (non-tombstoned) node may carry the u32::MAX
3293        // sentinel label.  Without this check, a crafted snapshot where the META
3294        // section (small, CRC-validated) holds a short `labels` vec, out-of-range
3295        // sym ids, or a sentinel label on a live node, would open successfully
3296        // and then panic in `NodeRef::label()`, `neighborhood_masked()`, and
3297        // related read paths.  Catching the inconsistency here converts those
3298        // panics into typed `GraphError::Corrupt` at open time.
3299        {
3300            let ids_len = self.ids.len();
3301            if self.labels.len() != ids_len {
3302                return Err(GraphError::Corrupt {
3303                    detail: format!(
3304                        "v8: labels section has {} entries but id table has {} total slots",
3305                        self.labels.len(),
3306                        ids_len,
3307                    ),
3308                });
3309            }
3310            let syms_len = self.syms.len() as u32;
3311            for (i, &sym) in self.labels.iter().enumerate() {
3312                let is_tombstoned = self.ids.is_tombstoned(i as u32);
3313                if sym == u32::MAX {
3314                    // Sentinel is only valid for tombstoned slots.
3315                    if !is_tombstoned {
3316                        return Err(GraphError::Corrupt {
3317                            detail: format!(
3318                                "v8: live node at id slot {i} has sentinel label (u32::MAX)"
3319                            ),
3320                        });
3321                    }
3322                } else if sym >= syms_len {
3323                    return Err(GraphError::Corrupt {
3324                        detail: format!(
3325                            "v8: label at id slot {i} references sym {sym} \
3326                             which is out of interner range ({syms_len})"
3327                        ),
3328                    });
3329                }
3330            }
3331        }
3332        // C3: self.edge_props stays as an empty overlay.  Reads go through
3333        // edge_props_view() which consults the mmap'd base section zero-copy
3334        // via EdgePropsView::with_base.  No heap decode at open time.
3335
3336        // Restore rule engine.
3337        let (rule_def_bytes, rule_tripped, rule_fires) =
3338            archived_rules_meta_to_owned(mapped.rules_meta_section().map_err(|e| {
3339                GraphError::Corrupt {
3340                    detail: format!("v8: rules_meta section: {e:?}"),
3341                }
3342            })?);
3343        let defs: Vec<RuleDef> = rule_def_bytes
3344            .iter()
3345            .map(|b| {
3346                decode_rule_def(b).map_err(|e| GraphError::Corrupt {
3347                    detail: format!("v8: rule_def deserialize: {e}"),
3348                })
3349            })
3350            .collect::<Result<Vec<_>>>()?;
3351        self.engine = RuleEngine::from_persist(defs, BTreeMap::new(), rule_tripped, rule_fires);
3352        // C4+C5: provenance, HNSW, and IVF sections are NOT read here.
3353        // `ensure_v8_base_sections_loaded` reads them on first use from
3354        // `self.base` (set by the caller immediately after this returns).
3355        // A clean open touches only: header + IDS + SYMS + META + RULES_META.
3356
3357        // Restore view definitions.
3358        let view_defs =
3359            archived_views_to_owned(mapped.views_section().map_err(|e| GraphError::Corrupt {
3360                detail: format!("v8: views section: {e:?}"),
3361            })?);
3362        for def_bytes in &view_defs {
3363            let def: ViewDef =
3364                bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
3365                    detail: format!("v8: view_def deserialize: {e}"),
3366                })?;
3367            self.view_store
3368                .restore_view(def)
3369                .map_err(|e| GraphError::Corrupt {
3370                    detail: format!("v8: view restore: {e}"),
3371                })?;
3372        }
3373        // Load the last-change map from section 11 (small section; load eagerly).
3374        // Pre-Task-3 snapshots lack this section; `last_change_bytes` returns &[]
3375        // in that case and `decode_last_change_bytes` returns an empty map.
3376        let last_change_raw = mapped
3377            .last_change_bytes()
3378            .map_err(|e| GraphError::Corrupt {
3379                detail: format!("v8: last_change section: {e:?}"),
3380            })?;
3381        self.last_change = decode_last_change_bytes(last_change_raw);
3382
3383        // Validate that all deferred sections (provenance, HNSW, IVF) fit within
3384        // the file.  Pure bounds check — no bytes read, no page faults triggered.
3385        // Catches truncated snapshots at open time before the lazy deferred reads.
3386        mapped.validate_section_bounds().map_err(|e| match e {
3387            GraphError::Corrupt { detail } => GraphError::Corrupt {
3388                detail: format!("v8: section bounds: {detail}"),
3389            },
3390            other => other,
3391        })?;
3392        Ok(())
3393    }
3394
3395    /// Read provenance, HNSW, and IVF sections from the mmap base into the
3396    /// engine's retained fields on first call.  Subsequent calls are a no-op
3397    /// (AtomicBool fast-path).
3398    ///
3399    /// Must be called before any code path that reads or mutates engine
3400    /// provenance, HNSW, or IVF state:
3401    /// - WAL replay (before `consume_retained_state_eager`)
3402    /// - First mutation (`log_then_apply_with`)
3403    /// - Read-only paths (`stats`, `explain`, `node_edges`)
3404    /// - Snapshot (`snapshot_with`)
3405    ///
3406    /// No-op for fresh stores and V5-V7 opens (`self.base` is `None`).
3407    fn ensure_v8_base_sections_loaded(&self) {
3408        use std::sync::atomic::Ordering;
3409        if self.v8_sections_loaded.load(Ordering::Acquire) {
3410            return;
3411        }
3412        let _guard = self
3413            .v8_sections_mutex
3414            .lock()
3415            .expect("v8 sections mutex poisoned");
3416        if self.v8_sections_loaded.load(Ordering::Acquire) {
3417            return; // another caller populated while we waited
3418        }
3419        let _t = std::time::Instant::now();
3420        if let Some(base) = &self.base {
3421            // Provenance: raw rkyv bytes; CRC validated inside section_bytes.
3422            // Bounds are already validated at open time (restore_v8_base →
3423            // validate_section_bounds) — unreachable post-validate_section_bounds;
3424            // unwrap_or_default is a safety belt against impossible errors.
3425            let prov_bytes = base
3426                .provenance_raw_bytes()
3427                .map(|b| b.to_vec())
3428                .unwrap_or_default();
3429            self.engine.store_provenance_bytes(prov_bytes);
3430            // HNSW: decode rkyv blobs into owned map.
3431            let hnsw_state = base
3432                .hnsw_section()
3433                .map(archived_hnsw_to_owned)
3434                .unwrap_or_default();
3435            // IVF: raw bincode bytes; deserialized on first mutation/query.
3436            let ivf_bytes = base.ivf_bytes().map(|b| b.to_vec()).unwrap_or_default();
3437            // Called before WAL replay on a WAL-present open (`open_with`) and
3438            // before any write on a clean one, so this is the snapshot's count.
3439            let snapshot_ids = self.ids.len() as u32;
3440            self.engine
3441                .store_snapshot_state(hnsw_state, ivf_bytes, snapshot_ids);
3442        }
3443        self.v8_sections_loaded.store(true, Ordering::Release);
3444        if std::env::var("MUSHROOMDB_TRACE_OPEN").is_ok() {
3445            eprintln!(
3446                "[MUSHROOMDB_TRACE_OPEN] ensure_v8_base_sections_loaded: {:>9.3?}",
3447                _t.elapsed()
3448            );
3449        }
3450    }
3451
3452    /// Return a `TopologyView` that merges the mmap'd base (when present) with
3453    /// the in-memory WAL overlay.  Used by all read paths in db.rs that need
3454    /// the full merged topology without going through `self.view()`.
3455    fn topo_view(&self) -> TopologyView<'_> {
3456        match self.base {
3457            None => TopologyView::owned(&self.topo),
3458            Some(ref base) => {
3459                // SAFETY: base lives as long as self; section bounds validated at open.
3460                // topology() uses access_unchecked; all field reads are bounds-checked in seam.rs.
3461                let archived = base
3462                    .topology()
3463                    .expect("base topology section bounds validated at open");
3464                TopologyView::with_base(&self.topo, archived)
3465            }
3466        }
3467    }
3468
3469    /// Return a `ColumnsView` that merges the mmap'd base columns (when a V8
3470    /// snapshot is open) with the in-memory WAL overlay.  Reads consult the
3471    /// overlay first, then fall through to the archived base section zero-copy.
3472    fn props_view(&self) -> core_storage::v8::seam::ColumnsView<'_> {
3473        match self.base {
3474            None => core_storage::v8::seam::ColumnsView::owned(&self.props),
3475            Some(ref base) => {
3476                // columns() uses access_unchecked; field reads are bounds-checked in seam.rs.
3477                let archived = base
3478                    .columns()
3479                    .expect("base columns section bounds validated at open");
3480                core_storage::v8::seam::ColumnsView::with_base_cached(
3481                    &self.props,
3482                    archived,
3483                    base.mixed_cache(),
3484                )
3485                .with_shared_strings(base_string_table(base))
3486            }
3487        }
3488    }
3489
3490    /// Return an `EdgePropsView` that merges the mmap'd base edge-props section
3491    /// (when a V8 snapshot is open) with the in-memory WAL overlay.
3492    ///
3493    /// Reads consult the overlay first (for post-snapshot mutations), then fall
3494    /// through to the archived base section zero-copy.  Tombstones in the
3495    /// overlay mask deleted-from-base entries.
3496    fn edge_props_view(&self) -> EdgePropsView<'_> {
3497        match self.base {
3498            None => EdgePropsView::owned(&self.edge_props),
3499            Some(ref base) => {
3500                // edge_props_section() uses access_unchecked; field reads bounds-checked in seam.rs.
3501                let archived = base
3502                    .edge_props_section()
3503                    .expect("base edge_props section bounds validated at open");
3504                EdgePropsView::with_base(&self.edge_props, archived)
3505            }
3506        }
3507    }
3508
3509    fn open_at_with(fs: F, commit: u64) -> Result<Self> {
3510        // An as-of view never writes and is pinned to one commit: it takes no
3511        // cross-process lock and does not follow later commits.
3512        let mut db = Self::new_empty(
3513            fs,
3514            OpenOptions {
3515                repair_wal: false,
3516                auto_migrate: false,
3517                read_only: true,
3518            },
3519        );
3520        db.pinned = true; // read_only is set after replay, but pinning is immediate
3521        db.wal_horizon_floor = db.fs.read_horizon_floor()?;
3522        db.archive_genesis_chain = db.fs.has_genesis_marker();
3523        // Same orphaned-archive cleanup as open_with: floor was written first
3524        // during pruning, so a crash may have left stale archives below floor.
3525        db.cleanup_orphaned_archives()?;
3526        // Collect archive frames (oldest-first) and live WAL frames.
3527        // Archives represent pre-snapshot history; the snapshot captures the
3528        // cumulative state at the time of archiving.  Crash-window guarantee:
3529        //   A: crash before rename → WAL intact, no archive. Reopen: normal.
3530        //   B: crash after rename, before new WAL → archive present, WAL
3531        //      absent. Reopen: snapshot loaded (full state), no WAL replay.
3532        //   C: crash after new baseline WAL written → normal post-archive.
3533        let archive_ns = db.fs.list_archives()?;
3534        let mut archive_frames_all: Vec<WalRecord> = Vec::new();
3535        for n in &archive_ns {
3536            let arc_bytes = db.fs.read_archive(*n)?;
3537            let (arc_frames, _) = decode_all(&arc_bytes);
3538            archive_frames_all.extend(arc_frames);
3539        }
3540        let total_archive_frames = archive_frames_all.len() as u64;
3541
3542        let live_bytes = db.fs.read(FileId::Wal)?;
3543        let (live_records, _valid_len) = decode_all(&live_bytes);
3544        let total_surviving = total_archive_frames + live_records.len() as u64;
3545        // Global total including any pruned history below the horizon floor.
3546        let total = db.wal_horizon_floor + total_surviving;
3547
3548        // Horizon and range check.
3549        if commit < db.wal_horizon_floor {
3550            return Err(GraphError::CommitOutOfRange {
3551                commit,
3552                total,
3553                floor: db.wal_horizon_floor,
3554            });
3555        }
3556        if commit >= total {
3557            return Err(GraphError::CommitOutOfRange {
3558                commit,
3559                total,
3560                floor: db.wal_horizon_floor,
3561            });
3562        }
3563
3564        // Local index into surviving frames (0 = first frame of oldest archive).
3565        let local = commit - db.wal_horizon_floor;
3566
3567        if local < total_archive_frames {
3568            // Target commit is in an archive.  Correct replay from empty state
3569            // is only possible when the archive chain is an uninterrupted
3570            // genesis chain (first archive taken from a fresh store, no prior
3571            // WAL truncation) and no archives have been pruned (floor == 0).
3572            //
3573            // If either condition is violated the prefix needed to reconstruct
3574            // the requested state is gone; refuse rather than return wrong data.
3575            if db.wal_horizon_floor > 0 || !db.archive_genesis_chain {
3576                return Err(GraphError::CommitOutOfRange {
3577                    commit,
3578                    total,
3579                    floor: db.wal_horizon_floor,
3580                });
3581            }
3582            // Replay all archive frames up to and including the target commit
3583            // from an empty database state.  Archives must be replayed in order
3584            // so that dense-id intern tables are built up correctly.
3585            for rec in archive_frames_all.into_iter().take((local + 1) as usize) {
3586                db.apply(&rec)?;
3587                let _ = db.engine.drain_deltas();
3588            }
3589        } else {
3590            // Target commit is in the live WAL: load snapshot as base, then
3591            // replay the needed live WAL prefix.
3592            //
3593            // Base state: a truncating snapshot (wal_truncated=true) compacts
3594            // all pre-truncation / pre-archive commits.  Dense-id records in
3595            // the live WAL reference ids/interns that the snapshot provides.
3596            //
3597            // A `keep_wal` snapshot records `wal_truncated=false`: it is the
3598            // state at the head, not at the start of the WAL, so it is no base
3599            // for a prefix. That is right when the live WAL reaches the store's
3600            // first commit and can be replayed from nothing. It is wrong when
3601            // the WAL starts above that — the floor has moved, or archives sit
3602            // in front of it — because the snapshot that *was* its base has
3603            // been overwritten. Replaying the prefix over nothing then answers
3604            // with a graph missing everything older than the WAL. The base is
3605            // gone, so the read is refused (ledger row 82).
3606            let base_is_gone = db.wal_horizon_floor > 0 || total_archive_frames > 0;
3607            // A snapshot interrupted before its WAL was replaced is, like a
3608            // `keep_wal` one, the state at the END of the live WAL, whatever
3609            // it says about truncation: no base for a prefix of that WAL.
3610            let live_wal = db.fs.read(FileId::Wal)?;
3611            let snapshot_is_wal_end = db.interrupted_snapshot(&live_wal)?.0.is_some();
3612            // Peek 6 bytes (same pattern as open_with).
3613            let snap_header = db.fs.read_prefix(FileId::Snapshot, 6)?;
3614            let is_v8 = snap_header.len() >= 6
3615                && &snap_header[0..4] == b"GDB1"
3616                && core_storage::snapshot::is_mmap_container(u16::from_le_bytes([
3617                    snap_header[4],
3618                    snap_header[5],
3619                ]));
3620            if is_v8 {
3621                let state = if let Some(snap_path) = db.fs.snapshot_path() {
3622                    let mapped = core_storage::v8::MappedBase::map(&snap_path).map_err(|e| {
3623                        GraphError::Corrupt {
3624                            detail: format!("v8: open_at mmap: {e:?}"),
3625                        }
3626                    })?;
3627                    core_storage::snapshot::decode_v8_from_mapped(&mapped)?
3628                } else {
3629                    let snap_bytes = db.fs.read(FileId::Snapshot)?;
3630                    core_storage::snapshot::decode(&snap_bytes)?
3631                };
3632                if let Some(state) = state {
3633                    if state.wal_truncated && !snapshot_is_wal_end {
3634                        db.restore_snapshot_state(state)?;
3635                        db.drop_edges_of_retired_nodes();
3636                    } else if base_is_gone {
3637                        return Err(GraphError::CommitOutOfRange {
3638                            commit,
3639                            total,
3640                            floor: db.wal_horizon_floor,
3641                        });
3642                    }
3643                }
3644            } else if !snap_header.is_empty() {
3645                let snap_bytes = db.fs.read(FileId::Snapshot)?;
3646                if let Some(state) = core_storage::snapshot::decode(&snap_bytes)? {
3647                    if state.wal_truncated && !snapshot_is_wal_end {
3648                        db.restore_snapshot_state(state)?;
3649                        db.drop_edges_of_retired_nodes();
3650                    } else if base_is_gone {
3651                        return Err(GraphError::CommitOutOfRange {
3652                            commit,
3653                            total,
3654                            floor: db.wal_horizon_floor,
3655                        });
3656                    }
3657                }
3658            }
3659            // else: snap_header empty = no snapshot file.
3660            let live_local = local - total_archive_frames;
3661            for rec in live_records.into_iter().take((live_local + 1) as usize) {
3662                db.apply(&rec)?;
3663                let _ = db.engine.drain_deltas();
3664            }
3665        }
3666        // Pin: pending_delta_count must be 0 after as-of replay, mirroring T1's
3667        // post-loop assert in open_with.
3668        debug_assert_eq!(
3669            db.engine.pending_delta_count(),
3670            0,
3671            "pending_deltas non-empty after open_at replay — \
3672             per-frame drain must run inside the loop to keep memory O(1)"
3673        );
3674        let _ = db.engine.drain_deltas(); // belt-and-braces no-op
3675                                          // Rebuild view values after WAL replay so derived-edge-driven views
3676                                          // reflect the as-of state.  open_at always uses the legacy path (no V8
3677                                          // base), so topo_view is always owned.
3678        {
3679            let topo_view = TopologyView::owned(&db.topo);
3680            db.view_store.rebuild_all(
3681                Arc::make_mut(&mut db.props),
3682                &topo_view,
3683                &db.ids,
3684                &db.syms,
3685                &db.labels,
3686            );
3687        }
3688        // Rebuild full-text index for as-of view (mirrors open_with pattern).
3689        Arc::make_mut(&mut db.fulltext).rebuild_all(
3690            &db.ids,
3691            &db.labels,
3692            &db.syms,
3693            build_props_view(&db.props, &db.base),
3694        );
3695        db.prop_index.rebuild_all(
3696            &db.ids,
3697            &db.labels,
3698            &db.syms,
3699            build_props_view(&db.props, &db.base),
3700        );
3701        // Namespaces on the temporal handle, built by the same pass the live
3702        // open uses, so an as-of mask narrows by the namespaces of that commit.
3703        db.rebuild_node_ns();
3704        // Load roles sidecar (current roles, not point-in-time).
3705        db.roles = Self::load_roles_from_fs(&db.fs)?;
3706        db.read_only = true;
3707        db.total_wal_commits = total;
3708        // Capture initial fold so reader() is immediately usable.
3709        db.fold_now();
3710        Ok(db)
3711    }
3712
3713    /// Whether this instance is a read-only as-of view.
3714    pub fn is_read_only(&self) -> bool {
3715        self.read_only
3716    }
3717
3718    // ── MVCC epoch reader ─────────────────────────────────────────────────────
3719
3720    /// Clone the current overlay state into a new `FrozenOverlay` and reset
3721    /// the delta tail. Called automatically every `FOLD_EVERY_K` commits and at
3722    /// the end of `open_with` / `open_at_with` to prime the reader.
3723    fn fold_now(&mut self) {
3724        // Eight `Arc::clone`s — refcount bumps, O(1). This used to deep-copy the
3725        // whole overlay: `IdMap` alone is a `HashMap<String, u32>` plus a
3726        // `Vec<String>`, so every node key was copied twice, on every open,
3727        // after every snapshot, every FOLD_EVERY_K commits on the write path,
3728        // and — with no commit threshold — on every `refresh()` that applied a
3729        // peer commit. A refreshing reader now pays nothing for a fold.
3730        //
3731        // `props` and `topo` were already cheap for a different reason: on a
3732        // snapshotted store `restore_v8_base` leaves them as empty overlays over
3733        // the zero-copy mmap. `ids`, `syms` and `fulltext` were not, and that
3734        // inconsistency was the defect.
3735        let frozen = crate::reader::FrozenOverlay {
3736            ids: std::sync::Arc::clone(&self.ids),
3737            syms: std::sync::Arc::clone(&self.syms),
3738            topo: std::sync::Arc::clone(&self.topo),
3739            props: std::sync::Arc::clone(&self.props),
3740            labels: std::sync::Arc::clone(&self.labels),
3741            edge_props: std::sync::Arc::clone(&self.edge_props),
3742            roles: self.roles.clone().map(std::sync::Arc::new),
3743            fulltext: std::sync::Arc::clone(&self.fulltext),
3744        };
3745        self.fold_overlay = Some(Arc::new(frozen));
3746        self.delta_tail.clear();
3747        self.commits_since_fold = 0;
3748        // The fold holds every view value as it stands; what replay or a
3749        // refresh touched on the way here is not owed to any delta.
3750        self.view_store.clear_touched();
3751    }
3752
3753    /// The view properties the commit of `rec` left on the nodes it touched,
3754    /// for the reader's delta tail — or `None` when the commit should be
3755    /// folded instead of described.
3756    ///
3757    /// Folded: a commit that creates or deletes a view, which writes its
3758    /// property on every node of a label, and one that moves far more view
3759    /// values than it has records — a write to a hub moves a view on every
3760    /// neighbour. A delta listing those would be held, and replayed by every
3761    /// reader, until the next fold.
3762    ///
3763    /// Not folded: a commit whose view values are in proportion to its
3764    /// records, however many. A bulk load gives each node it inserts a
3765    /// starting value, and the reader replays each of those inserts anyway. A
3766    /// fold is eight `Arc` clones to take, but it shares the store's maps
3767    /// with the reader, so the commit after it copies them in full; a load
3768    /// that folded on every batch paid that on every batch.
3769    fn view_values_of_commit(
3770        &mut self,
3771        rec: &WalRecord,
3772    ) -> Option<Vec<(u32, String, Option<Value>)>> {
3773        /// View values a commit may carry beyond those its own records
3774        /// account for.
3775        const MAX_BEYOND_RECORDS: usize = 1024;
3776
3777        fn declares_a_view(rec: &WalRecord) -> bool {
3778            match rec {
3779                WalRecord::CreateView { .. } | WalRecord::DeleteView { .. } => true,
3780                WalRecord::Batch(inner) => inner.iter().any(declares_a_view),
3781                _ => false,
3782            }
3783        }
3784
3785        fn leaf_records(rec: &WalRecord) -> usize {
3786            match rec {
3787                WalRecord::Batch(inner) => inner.iter().map(leaf_records).sum(),
3788                _ => 1,
3789            }
3790        }
3791
3792        let mut touched = self.view_store.take_touched();
3793        if declares_a_view(rec) {
3794            return None;
3795        }
3796        if touched.is_empty() {
3797            return Some(Vec::new());
3798        }
3799        touched.sort_unstable();
3800        touched.dedup();
3801        let views = self.view_store.views().count();
3802        let budget = MAX_BEYOND_RECORDS + leaf_records(rec).saturating_mul(views);
3803        let cols = build_props_view(&self.props, &self.base);
3804        let mut values = Vec::new();
3805        for &node in &touched {
3806            // A node deleted later in the same commit: the reader's replay of
3807            // the delete clears its properties, the view's among them.
3808            if self.ids.key_of(node).is_none() {
3809                continue;
3810            }
3811            let label = self.labels.get(node as usize).copied();
3812            for def in self.view_store.views() {
3813                if label.is_none() || self.syms.get(&def.label) != label {
3814                    continue;
3815                }
3816                if values.len() == budget {
3817                    return None;
3818                }
3819                let value = cols.get(node, &def.view_prop).map(|v| v.into_value());
3820                values.push((node, def.view_prop.clone(), value));
3821            }
3822        }
3823        Some(values)
3824    }
3825
3826    /// Capture a lock-free reader snapshot of the current db state.
3827    ///
3828    /// The read lock is held only for the duration of this call (to clone a
3829    /// handful of `Arc` handles). Subsequent query operations run without any
3830    /// lock.
3831    pub fn reader(&self) -> crate::reader::ReaderSnapshot {
3832        crate::reader::ReaderSnapshot::new(
3833            self.fold_overlay
3834                .clone()
3835                .expect("fold_overlay is always Some after open_with; call reader() after open"),
3836            self.base.clone(),
3837            self.delta_tail.clone(),
3838            // The snapshot's effective state is exactly this handle's state at
3839            // this commit, so it shares the memo and its version key.
3840            self.commit_seq,
3841            Arc::clone(&self.role_masks),
3842        )
3843    }
3844
3845    /// Append a delta the reader cannot apply, so that a corrupt overlay is
3846    /// reachable from a test.
3847    ///
3848    /// Compiled only under `test-hooks`, which the server's dev-dependency on
3849    /// this crate turns on. One call permanently corrupts every
3850    /// [`ReaderSnapshot`](crate::reader::ReaderSnapshot) taken from the handle,
3851    /// so it must not be in the published surface: `#[doc(hidden)]` hides it
3852    /// from rustdoc and from nothing else. The feature gate — not
3853    /// `#[cfg(test)]` — because its only callers are in `crates/server/tests`,
3854    /// a different crate, exactly as `core_rules`'s index counters are.
3855    ///
3856    /// [`ReaderSnapshot::effective`](crate::reader::ReaderSnapshot) folds the
3857    /// delta tail into a clone of the frozen overlay and answers
3858    /// [`GraphError::Corrupt`] when a record will not apply. Nothing a caller
3859    /// can do produces that state — `apply_one`'s failures are disagreements
3860    /// between the tail and the fold it is applied to, which the write path
3861    /// cannot create — so the `Corrupt` arm of every scoped reader method was
3862    /// reachable only by inspection until this hook existed. An `Intern` record
3863    /// claiming an id the frozen interner will not hand back is the smallest
3864    /// such disagreement.
3865    ///
3866    /// Only the tail is touched. This handle's own state is untouched and
3867    /// `commit_seq` does not move, so a role mask already memoised at this
3868    /// version stays memoised — which is exactly the state in which the HTTP
3869    /// role branches reach a scoped read with a corrupt overlay under them.
3870    #[cfg(any(test, feature = "test-hooks"))]
3871    #[doc(hidden)]
3872    pub fn push_unapplyable_delta_for_test(&mut self) {
3873        self.delta_tail.push(Arc::new(crate::reader::CommitDelta {
3874            records: vec![WalRecord::Intern {
3875                id: u32::MAX,
3876                text: "delta-tail-corruption".into(),
3877            }],
3878            derived_inserts: Vec::new(),
3879            derived_deletes: Vec::new(),
3880            view_values: Vec::new(),
3881        }));
3882    }
3883
3884    /// Total number of WAL commits at the time [`open_at`] was called.
3885    /// Returns 0 for normal (non-as-of) instances.
3886    pub fn total_wal_commits(&self) -> u64 {
3887        self.total_wal_commits
3888    }
3889
3890    /// Apply a record to in-memory state. Used by both live writes and replay,
3891    /// so replay is definitionally identical to the original execution.
3892    fn apply(&mut self, rec: &WalRecord) -> Result<()> {
3893        // Before the record mutates anything: a store restored from a snapshot
3894        // defers building its candidate indexes until the first write, and that
3895        // build is a full node scan. Left where it used to fire — inside the
3896        // engine hook, after `props.set` and the label assignment — the scan
3897        // read the half-applied record and took the in-flight node's vector for
3898        // one the snapshot should have carried, which read as an interrupted
3899        // vector-index build and cost a full `RebuildRule` on the first
3900        // embedded write after every reopen. Hoisted here the scan sees exactly
3901        // the persisted state; the record's own hook then files its vector
3902        // through the ordinary insert path a line later.
3903        //
3904        // A declaration writes no node and no edge, and its arm reads no rule
3905        // index, so it does not start the scan.
3906        if !is_declaration(rec) {
3907            self.populate_indexes_before_write();
3908        }
3909        match rec {
3910            WalRecord::InsertNode { label, key, props } => {
3911                let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
3912                let sym = Arc::make_mut(&mut self.syms).intern(label);
3913                if self.labels.len() <= id as usize {
3914                    // gap slots are sentinels, never valid label symbols
3915                    Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
3916                }
3917                Arc::make_mut(&mut self.labels)[id as usize] = sym;
3918                let mut ns_name = NS_DEFAULT.to_string();
3919                for (field, value) in props {
3920                    if field == NS_PROP {
3921                        ns_name = namespace_of_value(Some(value)).to_string();
3922                    }
3923                    Arc::make_mut(&mut self.props).set(id, field, value.clone());
3924                }
3925                self.set_node_ns(id, &ns_name);
3926                // Initialize view values for the new node before the engine runs so
3927                // delta-based increments start from a known zero baseline.
3928                self.view_store.init_node_views(
3929                    id,
3930                    Arc::make_mut(&mut self.props),
3931                    &self.syms,
3932                    &self.labels,
3933                );
3934                // Fire rules for the newly inserted node.
3935                let cursor = self.engine.pending_delta_count();
3936                let mut eng = std::mem::take(&mut self.engine);
3937                {
3938                    let mut gm = make_graph_mut(
3939                        &self.ids,
3940                        Arc::make_mut(&mut self.syms),
3941                        &self.labels,
3942                        build_props_view(&self.props, &self.base),
3943                        Arc::make_mut(&mut self.topo),
3944                        &self.base,
3945                        Arc::make_mut(&mut self.edge_props),
3946                    );
3947                    eng.on_node_changed(id, None, &mut gm);
3948                }
3949                self.engine = eng;
3950                // Process derived-edge deltas for view maintenance.
3951                // Fast path: skip the O(delta_count) allocation when no views exist.
3952                if !self.view_store.is_empty() {
3953                    #[cfg(test)]
3954                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
3955                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
3956                    for d in &new_deltas {
3957                        self.view_store.on_edge_changed(
3958                            d.etype_sym,
3959                            d.src_id,
3960                            d.dst_id,
3961                            d.fired,
3962                            Arc::make_mut(&mut self.props),
3963                            &build_topo_view(&self.topo, &self.base),
3964                            &self.ids,
3965                            &self.syms,
3966                            &self.labels,
3967                            base_columns(&self.base),
3968                        );
3969                    }
3970                }
3971                // Full-text index maintenance: index enabled fields for this label.
3972                if self.fulltext.has_label(label) {
3973                    for (field, value) in props {
3974                        if self.fulltext.is_enabled(label, field) {
3975                            self.unindex_before_reindexing(id, field);
3976                            Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
3977                        }
3978                    }
3979                }
3980                // Property (equality) index maintenance.
3981                if self.prop_index.has_label(label) {
3982                    for (field, value) in props {
3983                        self.prop_index.set(label, field, id, value);
3984                    }
3985                }
3986            }
3987            WalRecord::InsertEdge {
3988                edge_type,
3989                src_key,
3990                dst_key,
3991            } => {
3992                let src = self.ids.get(src_key).ok_or_else(|| GraphError::Corrupt {
3993                    detail: format!("wal replay references unknown key {src_key}"),
3994                })?;
3995                let dst = self.ids.get(dst_key).ok_or_else(|| GraphError::Corrupt {
3996                    detail: format!("wal replay references unknown key {dst_key}"),
3997                })?;
3998                let etype = Arc::make_mut(&mut self.syms).intern(edge_type);
3999                // Skip if the edge is already visible in the merged base+overlay
4000                // view.  This keeps WAL replay idempotent when the WAL contains
4001                // pre-snapshot records that are already encoded in a V8 base
4002                // (keep_wal=true opens and crash-before-truncation scenarios).
4003                if self.base.is_some()
4004                    && self
4005                        .topo_view()
4006                        .neighbors(etype, Direction::Out, src)
4007                        .contains(&dst)
4008                {
4009                    return Ok(());
4010                }
4011                Arc::make_mut(&mut self.topo).add_edge(etype, src, dst);
4012                // View maintenance for manual edge insert.
4013                self.view_store.on_edge_changed(
4014                    etype,
4015                    src,
4016                    dst,
4017                    true,
4018                    Arc::make_mut(&mut self.props),
4019                    &build_topo_view(&self.topo, &self.base),
4020                    &self.ids,
4021                    &self.syms,
4022                    &self.labels,
4023                    base_columns(&self.base),
4024                );
4025                // Rule engine: via-hop rules must update when user edges change.
4026                let cursor = self.engine.pending_delta_count();
4027                let mut eng = std::mem::take(&mut self.engine);
4028                {
4029                    let mut gm = make_graph_mut(
4030                        &self.ids,
4031                        Arc::make_mut(&mut self.syms),
4032                        &self.labels,
4033                        build_props_view(&self.props, &self.base),
4034                        Arc::make_mut(&mut self.topo),
4035                        &self.base,
4036                        Arc::make_mut(&mut self.edge_props),
4037                    );
4038                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
4039                }
4040                self.engine = eng;
4041                if !self.view_store.is_empty() {
4042                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4043                    for d in &new_deltas {
4044                        self.view_store.on_edge_changed(
4045                            d.etype_sym,
4046                            d.src_id,
4047                            d.dst_id,
4048                            d.fired,
4049                            Arc::make_mut(&mut self.props),
4050                            &build_topo_view(&self.topo, &self.base),
4051                            &self.ids,
4052                            &self.syms,
4053                            &self.labels,
4054                            base_columns(&self.base),
4055                        );
4056                    }
4057                }
4058            }
4059            WalRecord::SetProp { key, field, value } => {
4060                let id = self.ids.get(key).ok_or_else(|| GraphError::Corrupt {
4061                    detail: format!("wal replay references unknown key {key}"),
4062                })?;
4063                let old_value = build_props_view(&self.props, &self.base)
4064                    .get(id, field)
4065                    .map(|vr| vr.into_value());
4066                Arc::make_mut(&mut self.props).set(id, field, value.clone());
4067                // Fire rules for the changed field.
4068                let cursor = self.engine.pending_delta_count();
4069                let mut eng = std::mem::take(&mut self.engine);
4070                {
4071                    let mut gm = make_graph_mut(
4072                        &self.ids,
4073                        Arc::make_mut(&mut self.syms),
4074                        &self.labels,
4075                        build_props_view(&self.props, &self.base),
4076                        Arc::make_mut(&mut self.topo),
4077                        &self.base,
4078                        Arc::make_mut(&mut self.edge_props),
4079                    );
4080                    eng.on_node_changed(id, Some((field, old_value)), &mut gm);
4081                }
4082                self.engine = eng;
4083                // Derived-edge deltas → view updates.
4084                if !self.view_store.is_empty() {
4085                    #[cfg(test)]
4086                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4087                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4088                    for d in &new_deltas {
4089                        self.view_store.on_edge_changed(
4090                            d.etype_sym,
4091                            d.src_id,
4092                            d.dst_id,
4093                            d.fired,
4094                            Arc::make_mut(&mut self.props),
4095                            &build_topo_view(&self.topo, &self.base),
4096                            &self.ids,
4097                            &self.syms,
4098                            &self.labels,
4099                            base_columns(&self.base),
4100                        );
4101                    }
4102                }
4103                // Neighbor-aggregate views that read `field` must also update.
4104                self.view_store.on_prop_changed(
4105                    id,
4106                    field,
4107                    Arc::make_mut(&mut self.props),
4108                    &build_topo_view(&self.topo, &self.base),
4109                    &self.ids,
4110                    &self.syms,
4111                    &self.labels,
4112                    base_columns(&self.base),
4113                );
4114                // Full-text index maintenance: update tokens for this field if indexed.
4115                if self.fulltext.field_indexed(field) {
4116                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
4117                        if sym == u32::MAX {
4118                            None
4119                        } else {
4120                            self.syms.resolve(sym)
4121                        }
4122                    });
4123                    if let Some(label) = label_opt {
4124                        if self.fulltext.is_enabled(label, field) {
4125                            Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4126                            Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4127                        }
4128                    }
4129                }
4130                // Property (equality) index maintenance: re-key this node's value.
4131                if self.prop_index.field_indexed(field) {
4132                    let label_opt = self.labels.get(id as usize).and_then(|&sym| {
4133                        if sym == u32::MAX {
4134                            None
4135                        } else {
4136                            self.syms.resolve(sym)
4137                        }
4138                    });
4139                    if let Some(label) = label_opt {
4140                        self.prop_index.set(label, field, id, value);
4141                    }
4142                }
4143            }
4144            WalRecord::Intern { id, text } => {
4145                if let Some(existing) = self.syms.get(text) {
4146                    if existing != *id {
4147                        return Err(GraphError::Corrupt {
4148                            detail: format!(
4149                                "wal intern mismatch for {text:?}: have {existing}, record {id}"
4150                            ),
4151                        });
4152                    }
4153                } else {
4154                    let got = Arc::make_mut(&mut self.syms).intern(text);
4155                    if got != *id {
4156                        return Err(GraphError::Corrupt {
4157                            detail: format!(
4158                                "wal intern assigned {got} for {text:?}, record wanted {id}"
4159                            ),
4160                        });
4161                    }
4162                }
4163            }
4164            WalRecord::InsertNodeId { label, key, props } => {
4165                let id = Arc::make_mut(&mut self.ids).try_insert(key)?;
4166                if self.labels.len() <= id as usize {
4167                    Arc::make_mut(&mut self.labels).resize(id as usize + 1, u32::MAX);
4168                }
4169                Arc::make_mut(&mut self.labels)[id as usize] = *label;
4170                let label_str = self
4171                    .syms
4172                    .resolve(*label)
4173                    .ok_or_else(|| GraphError::Corrupt {
4174                        detail: format!("wal InsertNodeId unknown label intern {label}"),
4175                    })?
4176                    .to_string();
4177                let mut ns_name = NS_DEFAULT.to_string();
4178                for (field_sym, value) in props {
4179                    let field =
4180                        self.syms
4181                            .resolve(*field_sym)
4182                            .ok_or_else(|| GraphError::Corrupt {
4183                                detail: format!(
4184                                    "wal InsertNodeId unknown field intern {field_sym}"
4185                                ),
4186                            })?;
4187                    if field == NS_PROP {
4188                        ns_name = namespace_of_value(Some(value)).to_string();
4189                    }
4190                    Arc::make_mut(&mut self.props).set(id, field, value.clone());
4191                }
4192                self.set_node_ns(id, &ns_name);
4193                self.view_store.init_node_views(
4194                    id,
4195                    Arc::make_mut(&mut self.props),
4196                    &self.syms,
4197                    &self.labels,
4198                );
4199                let cursor = self.engine.pending_delta_count();
4200                let mut eng = std::mem::take(&mut self.engine);
4201                {
4202                    let mut gm = make_graph_mut(
4203                        &self.ids,
4204                        Arc::make_mut(&mut self.syms),
4205                        &self.labels,
4206                        build_props_view(&self.props, &self.base),
4207                        Arc::make_mut(&mut self.topo),
4208                        &self.base,
4209                        Arc::make_mut(&mut self.edge_props),
4210                    );
4211                    eng.on_node_changed(id, None, &mut gm);
4212                }
4213                self.engine = eng;
4214                if !self.view_store.is_empty() {
4215                    #[cfg(test)]
4216                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4217                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4218                    for d in &new_deltas {
4219                        self.view_store.on_edge_changed(
4220                            d.etype_sym,
4221                            d.src_id,
4222                            d.dst_id,
4223                            d.fired,
4224                            Arc::make_mut(&mut self.props),
4225                            &build_topo_view(&self.topo, &self.base),
4226                            &self.ids,
4227                            &self.syms,
4228                            &self.labels,
4229                            base_columns(&self.base),
4230                        );
4231                    }
4232                }
4233                if self.fulltext.has_label(&label_str) {
4234                    for (field_sym, value) in props {
4235                        let Some(field) = self.syms.resolve(*field_sym).map(str::to_string) else {
4236                            continue;
4237                        };
4238                        let field = field.as_str();
4239                        if self.fulltext.is_enabled(&label_str, field) {
4240                            self.unindex_before_reindexing(id, field);
4241                            Arc::make_mut(&mut self.fulltext).add_tokens(id, field, value);
4242                        }
4243                    }
4244                }
4245                if self.prop_index.has_label(&label_str) {
4246                    for (field_sym, value) in props {
4247                        let Some(field) = self.syms.resolve(*field_sym) else {
4248                            continue;
4249                        };
4250                        self.prop_index.set(&label_str, field, id, value);
4251                    }
4252                }
4253            }
4254            WalRecord::InsertEdgeId { etype, src, dst } => {
4255                // Replay-over-snapshot: dense ids in the pre-snapshot WAL may
4256                // already be tombstoned. Skip rather than attaching edges to
4257                // dead ids (DeleteNode keys the live re-insert, not the old id).
4258                if self.ids.is_tombstoned(*src)
4259                    || self.ids.is_tombstoned(*dst)
4260                    || self.ids.key_of(*src).is_none()
4261                    || self.ids.key_of(*dst).is_none()
4262                {
4263                    return Ok(());
4264                }
4265                // Skip if already visible in the merged view (same idempotency
4266                // guard as InsertEdge above: prevents double-counting when
4267                // pre-snapshot WAL records are replayed over a V8 base).
4268                if self.base.is_some()
4269                    && self
4270                        .topo_view()
4271                        .neighbors(*etype, Direction::Out, *src)
4272                        .contains(dst)
4273                {
4274                    return Ok(());
4275                }
4276                Arc::make_mut(&mut self.topo).add_edge(*etype, *src, *dst);
4277                self.view_store.on_edge_changed(
4278                    *etype,
4279                    *src,
4280                    *dst,
4281                    true,
4282                    Arc::make_mut(&mut self.props),
4283                    &build_topo_view(&self.topo, &self.base),
4284                    &self.ids,
4285                    &self.syms,
4286                    &self.labels,
4287                    base_columns(&self.base),
4288                );
4289                // Rule engine: via-hop rules fire when user via-edges are inserted.
4290                // Resolve etype back to string so on_edge_changed can match rules by name.
4291                if let Some(etype_str) = self.syms.resolve(*etype).map(|s| s.to_string()) {
4292                    let cursor = self.engine.pending_delta_count();
4293                    let mut eng = std::mem::take(&mut self.engine);
4294                    {
4295                        let mut gm = make_graph_mut(
4296                            &self.ids,
4297                            Arc::make_mut(&mut self.syms),
4298                            &self.labels,
4299                            build_props_view(&self.props, &self.base),
4300                            Arc::make_mut(&mut self.topo),
4301                            &self.base,
4302                            Arc::make_mut(&mut self.edge_props),
4303                        );
4304                        eng.on_edge_changed(&etype_str, *src, *dst, &mut gm);
4305                    }
4306                    self.engine = eng;
4307                    if !self.view_store.is_empty() {
4308                        let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4309                        for d in &new_deltas {
4310                            self.view_store.on_edge_changed(
4311                                d.etype_sym,
4312                                d.src_id,
4313                                d.dst_id,
4314                                d.fired,
4315                                Arc::make_mut(&mut self.props),
4316                                &build_topo_view(&self.topo, &self.base),
4317                                &self.ids,
4318                                &self.syms,
4319                                &self.labels,
4320                                base_columns(&self.base),
4321                            );
4322                        }
4323                    }
4324                }
4325            }
4326            WalRecord::SetPropId { id, field, value } => {
4327                if self.ids.is_tombstoned(*id) || self.ids.key_of(*id).is_none() {
4328                    return Ok(());
4329                }
4330                let field_str = self
4331                    .syms
4332                    .resolve(*field)
4333                    .ok_or_else(|| GraphError::Corrupt {
4334                        detail: format!("wal SetPropId unknown field intern {field}"),
4335                    })?
4336                    .to_string();
4337                let old_value = build_props_view(&self.props, &self.base)
4338                    .get(*id, &field_str)
4339                    .map(|vr| vr.into_value());
4340                Arc::make_mut(&mut self.props).set(*id, &field_str, value.clone());
4341                let cursor = self.engine.pending_delta_count();
4342                let mut eng = std::mem::take(&mut self.engine);
4343                {
4344                    let mut gm = make_graph_mut(
4345                        &self.ids,
4346                        Arc::make_mut(&mut self.syms),
4347                        &self.labels,
4348                        build_props_view(&self.props, &self.base),
4349                        Arc::make_mut(&mut self.topo),
4350                        &self.base,
4351                        Arc::make_mut(&mut self.edge_props),
4352                    );
4353                    eng.on_node_changed(*id, Some((field_str.as_str(), old_value)), &mut gm);
4354                }
4355                self.engine = eng;
4356                if !self.view_store.is_empty() {
4357                    #[cfg(test)]
4358                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4359                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4360                    for d in &new_deltas {
4361                        self.view_store.on_edge_changed(
4362                            d.etype_sym,
4363                            d.src_id,
4364                            d.dst_id,
4365                            d.fired,
4366                            Arc::make_mut(&mut self.props),
4367                            &build_topo_view(&self.topo, &self.base),
4368                            &self.ids,
4369                            &self.syms,
4370                            &self.labels,
4371                            base_columns(&self.base),
4372                        );
4373                    }
4374                }
4375                self.view_store.on_prop_changed(
4376                    *id,
4377                    &field_str,
4378                    Arc::make_mut(&mut self.props),
4379                    &build_topo_view(&self.topo, &self.base),
4380                    &self.ids,
4381                    &self.syms,
4382                    &self.labels,
4383                    base_columns(&self.base),
4384                );
4385                if self.fulltext.field_indexed(&field_str) {
4386                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4387                        if sym == u32::MAX {
4388                            None
4389                        } else {
4390                            self.syms.resolve(sym)
4391                        }
4392                    });
4393                    if let Some(label) = label_opt {
4394                        if self.fulltext.is_enabled(label, &field_str) {
4395                            Arc::make_mut(&mut self.fulltext).remove_node_field(*id, &field_str);
4396                            Arc::make_mut(&mut self.fulltext).add_tokens(*id, &field_str, value);
4397                        }
4398                    }
4399                }
4400                if self.prop_index.field_indexed(&field_str) {
4401                    let label_opt = self.labels.get(*id as usize).and_then(|&sym| {
4402                        if sym == u32::MAX {
4403                            None
4404                        } else {
4405                            self.syms.resolve(sym)
4406                        }
4407                    });
4408                    if let Some(label) = label_opt {
4409                        self.prop_index.set(label, &field_str, *id, value);
4410                    }
4411                }
4412            }
4413            WalRecord::CreateRule { def_bytes } => {
4414                let def: RuleDef = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
4415                    detail: format!("CreateRule def_bytes deserialize failed: {e}"),
4416                })?;
4417                // Replay-over-snapshot idempotency: the rule was captured in the snapshot
4418                // so the engine already has it; silently skip to avoid a spurious
4419                // RuleInvalid error in the crash window between snapshot write and WAL
4420                // truncation.
4421                if self.engine.rules().any(|r| r.name == def.name) {
4422                    return Ok(());
4423                }
4424                let cursor = self.engine.pending_delta_count();
4425                let mut eng = std::mem::take(&mut self.engine);
4426                let result = {
4427                    let mut gm = make_graph_mut(
4428                        &self.ids,
4429                        Arc::make_mut(&mut self.syms),
4430                        &self.labels,
4431                        build_props_view(&self.props, &self.base),
4432                        Arc::make_mut(&mut self.topo),
4433                        &self.base,
4434                        Arc::make_mut(&mut self.edge_props),
4435                    );
4436                    eng.create_rule(def, &mut gm)
4437                };
4438                self.engine = eng;
4439                result.map_err(|e| GraphError::RuleInvalid { detail: e })?;
4440                // Derived-edge fires from backfill → view updates.
4441                // Fast path: skip O(edge_count) allocation when no views exist.
4442                if !self.view_store.is_empty() {
4443                    #[cfg(test)]
4444                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4445                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4446                    for d in &new_deltas {
4447                        self.view_store.on_edge_changed(
4448                            d.etype_sym,
4449                            d.src_id,
4450                            d.dst_id,
4451                            d.fired,
4452                            Arc::make_mut(&mut self.props),
4453                            &build_topo_view(&self.topo, &self.base),
4454                            &self.ids,
4455                            &self.syms,
4456                            &self.labels,
4457                            base_columns(&self.base),
4458                        );
4459                    }
4460                }
4461            }
4462            WalRecord::DeleteRule { name } => {
4463                // Replay-over-snapshot idempotency: the snapshot already captured the
4464                // post-delete state so the rule is absent; silently skip to avoid a
4465                // spurious RuleNotFound error in the crash window between snapshot write
4466                // and WAL truncation.
4467                if !self.engine.rules().any(|r| r.name == *name) {
4468                    return Ok(());
4469                }
4470                let cursor = self.engine.pending_delta_count();
4471                let mut eng = std::mem::take(&mut self.engine);
4472                let result = {
4473                    let mut gm = make_graph_mut(
4474                        &self.ids,
4475                        Arc::make_mut(&mut self.syms),
4476                        &self.labels,
4477                        build_props_view(&self.props, &self.base),
4478                        Arc::make_mut(&mut self.topo),
4479                        &self.base,
4480                        Arc::make_mut(&mut self.edge_props),
4481                    );
4482                    eng.delete_rule(name, &mut gm)
4483                };
4484                self.engine = eng;
4485                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4486                // Derived-edge retractions → view updates.
4487                if !self.view_store.is_empty() {
4488                    #[cfg(test)]
4489                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4490                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4491                    for d in &new_deltas {
4492                        self.view_store.on_edge_changed(
4493                            d.etype_sym,
4494                            d.src_id,
4495                            d.dst_id,
4496                            d.fired,
4497                            Arc::make_mut(&mut self.props),
4498                            &build_topo_view(&self.topo, &self.base),
4499                            &self.ids,
4500                            &self.syms,
4501                            &self.labels,
4502                            base_columns(&self.base),
4503                        );
4504                    }
4505                }
4506            }
4507            WalRecord::RemoveProp { key, field } => {
4508                // Recovery-safe: unknown key or already-absent field is a
4509                // clean no-op. Crash-window replay over a snapshot that
4510                // already applied this record must not Err.
4511                let Some(id) = self.ids.get(key) else {
4512                    return Ok(());
4513                };
4514                // Read old value through the seam for rule retraction.
4515                let old = build_props_view(&self.props, &self.base)
4516                    .get(id, field)
4517                    .map(|vr| vr.into_value());
4518                Arc::make_mut(&mut self.props).remove(id, field);
4519                // If the base still supplies the value after the overlay removal,
4520                // record a tombstone so ColumnsView::get does not resurrect it.
4521                // This covers both the base-only case AND the both-resident case:
4522                //   base-only (in_overlay=false): old prop was only in base, remove
4523                //     is a no-op on overlay, base still visible → tombstone needed.
4524                //   both-resident (in_overlay=true): overlay had v2, base has v1;
4525                //     removing overlay uncovers v1 → tombstone needed.
4526                // Idempotent on double-replay: second pass sees the tombstone →
4527                // get() returns None → condition is false → no duplicate tombstone.
4528                if build_props_view(&self.props, &self.base)
4529                    .get(id, field)
4530                    .is_some()
4531                {
4532                    Arc::make_mut(&mut self.props).record_prop_tombstone(id, field);
4533                }
4534                let cursor = self.engine.pending_delta_count();
4535                let mut eng = std::mem::take(&mut self.engine);
4536                {
4537                    let mut gm = make_graph_mut(
4538                        &self.ids,
4539                        Arc::make_mut(&mut self.syms),
4540                        &self.labels,
4541                        build_props_view(&self.props, &self.base),
4542                        Arc::make_mut(&mut self.topo),
4543                        &self.base,
4544                        Arc::make_mut(&mut self.edge_props),
4545                    );
4546                    eng.on_node_changed(id, Some((field, old)), &mut gm);
4547                }
4548                self.engine = eng;
4549                // Derived-edge deltas → view updates.
4550                if !self.view_store.is_empty() {
4551                    #[cfg(test)]
4552                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4553                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4554                    for d in &new_deltas {
4555                        self.view_store.on_edge_changed(
4556                            d.etype_sym,
4557                            d.src_id,
4558                            d.dst_id,
4559                            d.fired,
4560                            Arc::make_mut(&mut self.props),
4561                            &build_topo_view(&self.topo, &self.base),
4562                            &self.ids,
4563                            &self.syms,
4564                            &self.labels,
4565                            base_columns(&self.base),
4566                        );
4567                    }
4568                }
4569                // Neighbor-aggregate views that read `field` must also update.
4570                self.view_store.on_prop_changed(
4571                    id,
4572                    field,
4573                    Arc::make_mut(&mut self.props),
4574                    &build_topo_view(&self.topo, &self.base),
4575                    &self.ids,
4576                    &self.syms,
4577                    &self.labels,
4578                    base_columns(&self.base),
4579                );
4580                // Full-text index maintenance: remove tokens for this field.
4581                if self.fulltext.field_indexed(field) {
4582                    Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
4583                }
4584                // Property (equality) index maintenance: drop this node's entry.
4585                if self.prop_index.field_indexed(field) {
4586                    if let Some(label) = self.labels.get(id as usize).and_then(|&sym| {
4587                        (sym != u32::MAX).then(|| self.syms.resolve(sym)).flatten()
4588                    }) {
4589                        self.prop_index.remove_node(label, field, id);
4590                    }
4591                }
4592            }
4593            WalRecord::DeleteEdge {
4594                edge_type,
4595                src_key,
4596                dst_key,
4597            } => {
4598                // Recovery-safe: unknown keys, unknown etype, or already-
4599                // absent edge is a clean no-op (remove_edge returns false).
4600                let Some(src) = self.ids.get(src_key) else {
4601                    return Ok(());
4602                };
4603                let Some(dst) = self.ids.get(dst_key) else {
4604                    return Ok(());
4605                };
4606                let Some(etype) = self.syms.get(edge_type) else {
4607                    return Ok(());
4608                };
4609                // I3: phantom-tombstone guard.  When a V8 base is present, a
4610                // DeleteEdge WAL record for an edge that was already absorbed into
4611                // the new base (i.e. neither in overlay nor in base) must be skipped.
4612                // Without this guard, remove_edge records a tombstone for an edge
4613                // that no longer exists, incorrectly understating edge_count.
4614                if self.base.is_some()
4615                    && !self
4616                        .topo_view()
4617                        .neighbors(etype, core_storage::topology::Direction::Out, src)
4618                        .contains(&dst)
4619                {
4620                    return Ok(());
4621                }
4622                Arc::make_mut(&mut self.topo).remove_edge(etype, src, dst);
4623                Arc::make_mut(&mut self.edge_props).remove_edge(etype, src, dst);
4624                // View maintenance for manual edge delete (topo already updated above).
4625                self.view_store.on_edge_changed(
4626                    etype,
4627                    src,
4628                    dst,
4629                    false,
4630                    Arc::make_mut(&mut self.props),
4631                    &build_topo_view(&self.topo, &self.base),
4632                    &self.ids,
4633                    &self.syms,
4634                    &self.labels,
4635                    base_columns(&self.base),
4636                );
4637                // Rule engine: via-hop rules must retract when user via-edges are deleted.
4638                let cursor = self.engine.pending_delta_count();
4639                let mut eng = std::mem::take(&mut self.engine);
4640                {
4641                    let mut gm = make_graph_mut(
4642                        &self.ids,
4643                        Arc::make_mut(&mut self.syms),
4644                        &self.labels,
4645                        build_props_view(&self.props, &self.base),
4646                        Arc::make_mut(&mut self.topo),
4647                        &self.base,
4648                        Arc::make_mut(&mut self.edge_props),
4649                    );
4650                    eng.on_edge_changed(edge_type, src, dst, &mut gm);
4651                }
4652                self.engine = eng;
4653                if !self.view_store.is_empty() {
4654                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4655                    for d in &new_deltas {
4656                        self.view_store.on_edge_changed(
4657                            d.etype_sym,
4658                            d.src_id,
4659                            d.dst_id,
4660                            d.fired,
4661                            Arc::make_mut(&mut self.props),
4662                            &build_topo_view(&self.topo, &self.base),
4663                            &self.ids,
4664                            &self.syms,
4665                            &self.labels,
4666                            base_columns(&self.base),
4667                        );
4668                    }
4669                }
4670            }
4671            WalRecord::DeleteNode { key } => {
4672                // Recovery-safe: already-tombstoned / unknown key is a clean
4673                // no-op. Crash-window replay over a snapshot that already
4674                // applied this record cannot recover the retired id from the
4675                // key (`IdMap::get` is None), so every subsequent step is
4676                // skipped. Each step is independently idempotent if invoked
4677                // twice on a still-live id: retraction is a no-op on empty
4678                // provenance, `remove_edge` returns false, `remove_all` is a
4679                // no-op, `ids.delete` returns None, label sentinel is sticky.
4680                let Some(n) = self.ids.get(key) else {
4681                    return Ok(());
4682                };
4683
4684                // (1) Retract derived edges + de-index while props/labels live.
4685                let cursor = self.engine.pending_delta_count();
4686                let mut eng = std::mem::take(&mut self.engine);
4687                {
4688                    let mut gm = make_graph_mut(
4689                        &self.ids,
4690                        Arc::make_mut(&mut self.syms),
4691                        &self.labels,
4692                        build_props_view(&self.props, &self.base),
4693                        Arc::make_mut(&mut self.topo),
4694                        &self.base,
4695                        Arc::make_mut(&mut self.edge_props),
4696                    );
4697                    eng.on_node_removed(n, &mut gm);
4698                }
4699                self.engine = eng;
4700                // Derived-edge retractions → view updates for neighbors.
4701                if !self.view_store.is_empty() {
4702                    #[cfg(test)]
4703                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4704                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4705                    for d in &new_deltas {
4706                        self.view_store.on_edge_changed(
4707                            d.etype_sym,
4708                            d.src_id,
4709                            d.dst_id,
4710                            d.fired,
4711                            Arc::make_mut(&mut self.props),
4712                            &build_topo_view(&self.topo, &self.base),
4713                            &self.ids,
4714                            &self.syms,
4715                            &self.labels,
4716                            base_columns(&self.base),
4717                        );
4718                    }
4719                }
4720
4721                // (2) Sweep ALL remaining edges incident to n, both directions,
4722                // every etype. This cascade is intentionally mask-independent:
4723                // topology integrity requires removing every edge touching the
4724                // deleted node regardless of the caller's visibility scope.
4725                // (The mask limits which nodes a role's read phase can return;
4726                // the WAL delete always executes with full storage authority.)
4727                // Collect then remove so neighbor slices stay valid during
4728                // iteration. Remove from topo first, then call view maintenance
4729                // so Avg/Min/Max recompute sees the correct (reduced) neighbor set.
4730                //
4731                // Through the view of the snapshot and what was written since,
4732                // not `self.topo` alone: that is only what was written since,
4733                // and an edge the snapshot holds was left behind, pointing at
4734                // a node that was gone (ledger row 95).
4735                let mut doomed = Vec::new();
4736                {
4737                    let tv = self.topo_view();
4738                    let etypes: Vec<u32> = tv.etypes().collect();
4739                    for et in &etypes {
4740                        for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
4741                            doomed.push((*et, n, dst));
4742                        }
4743                        for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
4744                            // A self-loop is already listed from its Out side.
4745                            if src != n {
4746                                doomed.push((*et, src, n));
4747                            }
4748                        }
4749                    }
4750                }
4751                for (et, s, d) in doomed {
4752                    Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
4753                    Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
4754                    // View maintenance: n's own view values will be cleared by
4755                    // remove_all below; only update surviving neighbors.
4756                    self.view_store.on_edge_changed_except(
4757                        et,
4758                        s,
4759                        d,
4760                        false,
4761                        Arc::make_mut(&mut self.props),
4762                        &build_topo_view(&self.topo, &self.base),
4763                        &self.ids,
4764                        &self.syms,
4765                        &self.labels,
4766                        base_columns(&self.base),
4767                        Some(n),
4768                    );
4769                }
4770
4771                // (3) Drop every remaining prop (`ColumnStore::remove_all`).
4772                Arc::make_mut(&mut self.props).remove_all(n);
4773                // Full-text index maintenance: remove all tokens for this node.
4774                Arc::make_mut(&mut self.fulltext).remove_node(n);
4775                // Property (equality) index maintenance: drop all entries for n.
4776                self.prop_index.remove_node_all(n);
4777
4778                // (4) Retire the dense id and stamp the label sentinel.
4779                Arc::make_mut(&mut self.ids).delete(key);
4780                if let Some(slot) = Arc::make_mut(&mut self.labels).get_mut(n as usize) {
4781                    *slot = u32::MAX;
4782                }
4783            }
4784            WalRecord::Batch(inner) => {
4785                // Apply each inner record in order through the same apply path.
4786                // Inner records are validated free of nested Batch by encode_record.
4787                for rec in inner {
4788                    self.apply(rec)?;
4789                }
4790            }
4791            WalRecord::RebuildRule { name } => {
4792                // Replay-over-snapshot idempotency: the snapshot may already
4793                // reflect a later delete_rule, so the rule is absent; skip.
4794                if !self.engine.rules().any(|r| r.name == *name) {
4795                    return Ok(());
4796                }
4797                let cursor = self.engine.pending_delta_count();
4798                let mut eng = std::mem::take(&mut self.engine);
4799                let result = {
4800                    let mut gm = make_graph_mut(
4801                        &self.ids,
4802                        Arc::make_mut(&mut self.syms),
4803                        &self.labels,
4804                        build_props_view(&self.props, &self.base),
4805                        Arc::make_mut(&mut self.topo),
4806                        &self.base,
4807                        Arc::make_mut(&mut self.edge_props),
4808                    );
4809                    eng.rebuild(name, &mut gm)
4810                };
4811                self.engine = eng;
4812                result.map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4813                // Derived-edge delta changes → view updates.
4814                if !self.view_store.is_empty() {
4815                    #[cfg(test)]
4816                    DELTA_COPY_COUNT.with(|c| c.set(c.get() + 1));
4817                    let new_deltas: Vec<_> = self.engine.pending_deltas_since(cursor).to_vec();
4818                    for d in &new_deltas {
4819                        self.view_store.on_edge_changed(
4820                            d.etype_sym,
4821                            d.src_id,
4822                            d.dst_id,
4823                            d.fired,
4824                            Arc::make_mut(&mut self.props),
4825                            &build_topo_view(&self.topo, &self.base),
4826                            &self.ids,
4827                            &self.syms,
4828                            &self.labels,
4829                            base_columns(&self.base),
4830                        );
4831                    }
4832                }
4833            }
4834            WalRecord::CreateView { def_bytes } => {
4835                let def: ViewDef =
4836                    bincode::deserialize(def_bytes).map_err(|e| GraphError::Corrupt {
4837                        detail: format!("CreateView def_bytes deserialize failed: {e}"),
4838                    })?;
4839                // Replay-over-snapshot idempotency: view already present → skip.
4840                if self.view_store.has_view(&def.name) {
4841                    return Ok(());
4842                }
4843                self.view_store
4844                    .create_view_over(
4845                        def,
4846                        Arc::make_mut(&mut self.props),
4847                        &build_topo_view(&self.topo, &self.base),
4848                        &self.ids,
4849                        &self.syms,
4850                        &self.labels,
4851                        base_columns(&self.base),
4852                    )
4853                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
4854            }
4855            WalRecord::DeleteView { name } => {
4856                // Replay-over-snapshot idempotency: view already absent → skip.
4857                if !self.view_store.has_view(name) {
4858                    return Ok(());
4859                }
4860                let doomed_prop = self
4861                    .view_store
4862                    .views()
4863                    .find(|v| v.name == *name)
4864                    .map(|v| (v.label.clone(), v.view_prop.clone()));
4865                self.view_store
4866                    .delete_view(
4867                        name,
4868                        Arc::make_mut(&mut self.props),
4869                        &self.ids,
4870                        &self.labels,
4871                        &self.syms,
4872                    )
4873                    .map_err(|_| GraphError::RuleNotFound { name: name.clone() })?;
4874                // `delete_view` clears the view's property from what was
4875                // written since the snapshot. A value the snapshot holds is
4876                // hidden by a tombstone, or it stays readable on every node
4877                // of the label after the view that owned it is gone.
4878                if let (Some((label, prop)), true) = (doomed_prop, self.base.is_some()) {
4879                    if let Some(label_sym) = self.syms.get(&label) {
4880                        for id in 0..self.ids.len() as u32 {
4881                            if self.labels.get(id as usize).copied() == Some(label_sym)
4882                                && build_props_view(&self.props, &self.base)
4883                                    .get(id, &prop)
4884                                    .is_some()
4885                            {
4886                                Arc::make_mut(&mut self.props).record_prop_tombstone(id, &prop);
4887                            }
4888                        }
4889                    }
4890                }
4891            }
4892            WalRecord::EnableFulltext { label, field } => {
4893                // Replay-over-snapshot idempotency: already enabled → skip.
4894                if self.fulltext.is_enabled(label, field) {
4895                    return Ok(());
4896                }
4897                Arc::make_mut(&mut self.fulltext).enable(label, field);
4898                if self.fulltext_rebuild_follows {
4899                    // The open path rebuilds the whole index after replay, which
4900                    // clears every posting this scan would write. Doing it twice
4901                    // costs a full tokenise-and-stem pass over the corpus per
4902                    // enabled pair: measured at 456 ms against 3.8 ms for the
4903                    // same 30,000-entity store with no pair enabled.
4904                    return Ok(());
4905                }
4906                // Backfill: index all live nodes of this label that have the field.
4907                let n = self.ids.len() as u32;
4908                for id in 0..n {
4909                    let Some(&sym) = self.labels.get(id as usize) else {
4910                        continue;
4911                    };
4912                    if sym == u32::MAX {
4913                        continue; // tombstoned
4914                    }
4915                    let Some(lbl) = self.syms.resolve(sym) else {
4916                        continue;
4917                    };
4918                    if lbl != label {
4919                        continue;
4920                    }
4921                    if let Some(value) = build_props_view(&self.props, &self.base)
4922                        .get(id, field)
4923                        .map(|vr| vr.into_value())
4924                    {
4925                        Arc::make_mut(&mut self.fulltext).add_tokens(id, field, &value);
4926                    }
4927                }
4928            }
4929            WalRecord::DisableFulltext { label, field } => {
4930                // Replay-over-snapshot idempotency: already disabled → skip.
4931                if !self.fulltext.is_enabled(label, field) {
4932                    return Ok(());
4933                }
4934                // If another label still indexes this field, the postings column
4935                // is kept — but it must not contain node_ids from the now-disabled
4936                // label.  Remove them before calling disable() so the field_indexed
4937                // guard inside disable() sees the correct post-removal state.
4938                if self.fulltext.field_indexed_by_other(label, field) {
4939                    if let Some(label_sym) = self.syms.get(label) {
4940                        for (node_id, &lsym) in self.labels.iter().enumerate() {
4941                            if lsym == label_sym {
4942                                Arc::make_mut(&mut self.fulltext)
4943                                    .remove_node_field(node_id as u32, field);
4944                            }
4945                        }
4946                    }
4947                }
4948                Arc::make_mut(&mut self.fulltext).disable(label, field);
4949            }
4950            WalRecord::EnableIndex { label, field } => {
4951                // Replay-over-snapshot idempotency: already enabled → skip.
4952                if self.prop_index.is_enabled(label, field) {
4953                    return Ok(());
4954                }
4955                self.prop_index.enable(label, field);
4956                // Backfill: index all live nodes of this label that have the field.
4957                let n = self.ids.len() as u32;
4958                for id in 0..n {
4959                    let Some(&sym) = self.labels.get(id as usize) else {
4960                        continue;
4961                    };
4962                    if sym == u32::MAX {
4963                        continue; // tombstoned
4964                    }
4965                    let Some(lbl) = self.syms.resolve(sym) else {
4966                        continue;
4967                    };
4968                    if lbl != label {
4969                        continue;
4970                    }
4971                    if let Some(value) = build_props_view(&self.props, &self.base)
4972                        .get(id, field)
4973                        .map(|vr| vr.into_value())
4974                    {
4975                        self.prop_index.set(label, field, id, &value);
4976                    }
4977                }
4978            }
4979            WalRecord::DisableIndex { label, field } => {
4980                self.prop_index.disable(label, field);
4981            }
4982            // ── insert-count multiplicity (§5.13) ────────────────────────────
4983            //
4984            // Two shapes, told apart by `count`: the opt-in declaration, and an
4985            // absolute count for one triple. Absolute is what makes this
4986            // idempotent over a snapshot base — a pre-snapshot frame replayed
4987            // over a base that already folded it in lands on the same number
4988            // rather than adding to it, which is the failure a delta (or a count
4989            // derived from `InsertEdgeId` records) would have.
4990            WalRecord::SetEdgeCount {
4991                etype,
4992                src,
4993                dst,
4994                count,
4995            } => {
4996                if rec.is_multiplicity_decl() {
4997                    self.multiplicity = true;
4998                } else {
4999                    Arc::make_mut(&mut self.edge_props).set(
5000                        *etype,
5001                        *src,
5002                        *dst,
5003                        EDGE_COUNT_PROP,
5004                        Value::Int(*count as i64),
5005                    );
5006                }
5007            }
5008            // History markers carry no replay state — rules re-derive edges
5009            // deterministically on open/replay. Skip unconditionally.
5010            WalRecord::DerivedEdgeAdded { .. } | WalRecord::DerivedEdgeRetracted { .. } => {}
5011            // ── rename_node ──────────────────────────────────────────────────
5012            WalRecord::RenameNode { old_key, new_key } => {
5013                // Recovery-safe: if old_key is already gone (key was renamed
5014                // by a snapshot or a prior replay frame), skip cleanly.
5015                if self.ids.get(old_key).is_none() {
5016                    return Ok(());
5017                }
5018                // The rename only updates the key-table; the dense id, all
5019                // topo edges, props, labels, and rule state are id-indexed and
5020                // require no change.
5021                Arc::make_mut(&mut self.ids)
5022                    .rename(old_key, new_key)
5023                    .map_err(|e| GraphError::Corrupt {
5024                        detail: format!("wal replay RenameNode {old_key}→{new_key}: {e}"),
5025                    })?;
5026            }
5027        }
5028        Ok(())
5029    }
5030
5031    /// Intern `s` in `syms` and emit a WAL `Intern` record so `*Id` records
5032    /// replay on WAL-only `open_at` (no snapshot intern table). Apply is
5033    /// idempotent when the string is already bound. Always emit: after
5034    /// `snapshot()` the WAL is truncated and live intern is not on disk.
5035    fn intern_wal(&mut self, s: &str) -> (u32, WalRecord) {
5036        let id = if let Some(id) = self.syms.get(s) {
5037            id
5038        } else {
5039            Arc::make_mut(&mut self.syms).intern(s)
5040        };
5041        (
5042            id,
5043            WalRecord::Intern {
5044                id,
5045                text: s.to_string(),
5046            },
5047        )
5048    }
5049
5050    /// Rewrite user-facing records into dense-id records. On `Err`, no live
5051    /// state is left mutated: speculative interns made while building the
5052    /// output are rolled back, so a later successful mutation cannot log an
5053    /// `Intern` record whose id replay would never reproduce.
5054    fn rewrite_wal_dense(&mut self, recs: Vec<WalRecord>) -> Result<Vec<WalRecord>> {
5055        self.rewrite_wal_dense_planned(recs.into_iter().map(PlannedRec::Rec).collect())
5056    }
5057
5058    /// [`rewrite_wal_dense`](Self::rewrite_wal_dense) for a frame that still
5059    /// carries [`PlannedRec::DuplicateCount`] entries — the shape a batch
5060    /// produces, where a duplicate's count can only be named once this pass has
5061    /// assigned the frame's own ids.
5062    fn rewrite_wal_dense_planned(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
5063        let syms_checkpoint = self.syms.len();
5064        let result = self.rewrite_wal_dense_inner(recs);
5065        if result.is_err() {
5066            Arc::make_mut(&mut self.syms).truncate(syms_checkpoint);
5067        }
5068        result
5069    }
5070
5071    fn rewrite_wal_dense_inner(&mut self, recs: Vec<PlannedRec>) -> Result<Vec<WalRecord>> {
5072        let mut out = Vec::with_capacity(recs.len());
5073        // Node ids allocated by later apply(InsertNodeId) in this same batch.
5074        let mut pending: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5075        // Namespace of each node inserted earlier in this same frame, so a SET
5076        // on a node this frame created is measured against the namespace it was
5077        // created in rather than against the store, where it does not exist yet.
5078        let mut pending_ns: std::collections::HashMap<String, String> =
5079            std::collections::HashMap::new();
5080        let mut interned = std::collections::HashSet::<u32>::new();
5081        let mut next = u32::try_from(self.ids.len()).map_err(|_| GraphError::Corrupt {
5082            detail: "id space exhausted".into(),
5083        })?;
5084        // Insert counts this frame has already raised. `edge_insert_count`
5085        // reads committed state, which cannot see a count queued earlier in
5086        // this same frame, so N duplicates of one pair would otherwise all
5087        // compute `committed + 1` and the last would win.
5088        let mut pending_counts: HashMap<(u32, u32, u32), u64> = HashMap::new();
5089        // Keys this frame has taken off the node the store holds them on: the
5090        // old key of a rename, and a deleted node's. Until the frame inserts
5091        // under such a key again it names nothing, and after that it names the
5092        // new node — never the stored one, which is what the store alone
5093        // would still answer while the frame is being rewritten.
5094        let mut freed: std::collections::HashSet<String> = std::collections::HashSet::new();
5095        let lookup = |ids: &IdMap,
5096                      pending: &std::collections::HashMap<String, u32>,
5097                      freed: &std::collections::HashSet<String>,
5098                      key: &str|
5099         -> Option<u32> {
5100            match pending.get(key) {
5101                Some(&id) => Some(id),
5102                None if freed.contains(key) => None,
5103                None => ids.get(key),
5104            }
5105        };
5106        for rec in recs {
5107            // A duplicate insert's count, resolved here and nowhere else.
5108            //
5109            // This is the only pass that knows the frame's own ids: a node
5110            // created earlier in the same frame has no dense id until the
5111            // `InsertNodeId` above allocates one, and an edge type first used in
5112            // this frame is not in `syms` until `intern_wal` puts it there.
5113            // Resolving the count in the batch's validate pass instead — where
5114            // it used to live — meant that a duplicate whose endpoints or type
5115            // were created in the same frame silently produced no count at all,
5116            // which is exactly the shape a mirror rebuild writes (defect #24).
5117            let rec = match rec {
5118                PlannedRec::Rec(rec) => rec,
5119                PlannedRec::DuplicateCount {
5120                    edge_type,
5121                    src_key,
5122                    dst_key,
5123                } => {
5124                    let (etype, intern) = self.intern_wal(&edge_type);
5125                    if interned.insert(etype) {
5126                        out.push(intern);
5127                    }
5128                    let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5129                        GraphError::Corrupt {
5130                            detail: format!("dense WAL rewrite missing src {src_key}"),
5131                        }
5132                    })?;
5133                    let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5134                        GraphError::Corrupt {
5135                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
5136                        }
5137                    })?;
5138                    let count = pending_counts
5139                        .get(&(etype, src, dst))
5140                        .copied()
5141                        .unwrap_or_else(|| self.edge_insert_count(etype, src, dst))
5142                        .saturating_add(1);
5143                    pending_counts.insert((etype, src, dst), count);
5144                    out.push(WalRecord::SetEdgeCount {
5145                        etype,
5146                        src,
5147                        dst,
5148                        count,
5149                    });
5150                    continue;
5151                }
5152            };
5153            match rec {
5154                WalRecord::InsertNode { label, key, props } => {
5155                    // Namespace validation and normalisation, on the one seam
5156                    // every user-visible node insert passes through: insert_node,
5157                    // a batch, ingest, Cypher CREATE and MERGE all arrive here
5158                    // before the WAL append, and replay never does.
5159                    let (props, ns_name) = Self::normalise_insert_ns(&key, props)?;
5160                    pending_ns.insert(key.clone(), ns_name);
5161                    let (label_id, intern) = self.intern_wal(&label);
5162                    if interned.insert(label_id) {
5163                        out.push(intern);
5164                    }
5165                    let mut props_id = Vec::with_capacity(props.len());
5166                    for (field, value) in props {
5167                        let (field_id, intern) = self.intern_wal(&field);
5168                        if interned.insert(field_id) {
5169                            out.push(intern);
5170                        }
5171                        props_id.push((field_id, value));
5172                    }
5173                    if lookup(&self.ids, &pending, &freed, &key).is_none() {
5174                        pending.insert(key.clone(), next);
5175                        next = next.checked_add(1).ok_or_else(|| GraphError::Corrupt {
5176                            detail: "id space exhausted".into(),
5177                        })?;
5178                    }
5179                    out.push(WalRecord::InsertNodeId {
5180                        label: label_id,
5181                        key,
5182                        props: props_id,
5183                    });
5184                }
5185                WalRecord::SetProp { key, field, value } => {
5186                    // A namespace is set at insert and fixed after: the write is
5187                    // refused when it would move the node, and dropped when it
5188                    // names the namespace the node is already in. Checked here
5189                    // so set_prop, a batch, Cypher SET/MERGE and every upsert
5190                    // that merges props get the same answer.
5191                    if field == NS_PROP {
5192                        let Value::Str(ref to) = value else {
5193                            return Err(GraphError::RuleInvalid {
5194                                detail: format!(
5195                                    "node {key}: {NS_PROP} must be a string naming a namespace, \
5196                                     got {value:?}"
5197                                ),
5198                            });
5199                        };
5200                        let from = pending_ns
5201                            .get(&key)
5202                            .cloned()
5203                            .or_else(|| self.namespace_of(&key))
5204                            .unwrap_or_else(|| NS_DEFAULT.to_string());
5205                        let to = to.clone();
5206                        if to != from {
5207                            return Err(GraphError::NamespaceImmutable {
5208                                key: key.clone(),
5209                                from,
5210                                to,
5211                            });
5212                        }
5213                        continue;
5214                    }
5215                    let id = lookup(&self.ids, &pending, &freed, &key).ok_or_else(|| {
5216                        GraphError::Corrupt {
5217                            detail: format!("dense WAL rewrite missing key {key}"),
5218                        }
5219                    })?;
5220                    let (field_id, intern) = self.intern_wal(&field);
5221                    if interned.insert(field_id) {
5222                        out.push(intern);
5223                    }
5224                    out.push(WalRecord::SetPropId {
5225                        id,
5226                        field: field_id,
5227                        value,
5228                    });
5229                }
5230                WalRecord::InsertEdge {
5231                    edge_type,
5232                    src_key,
5233                    dst_key,
5234                } => {
5235                    let (etype, intern) = self.intern_wal(&edge_type);
5236                    if interned.insert(etype) {
5237                        out.push(intern);
5238                    }
5239                    let src = lookup(&self.ids, &pending, &freed, &src_key).ok_or_else(|| {
5240                        GraphError::Corrupt {
5241                            detail: format!("dense WAL rewrite missing src {src_key}"),
5242                        }
5243                    })?;
5244                    let dst = lookup(&self.ids, &pending, &freed, &dst_key).ok_or_else(|| {
5245                        GraphError::Corrupt {
5246                            detail: format!("dense WAL rewrite missing dst {dst_key}"),
5247                        }
5248                    })?;
5249                    out.push(WalRecord::InsertEdgeId { etype, src, dst });
5250                }
5251                WalRecord::RenameNode {
5252                    ref old_key,
5253                    ref new_key,
5254                } => {
5255                    // Track the rename in `pending` so subsequent InsertEdge /
5256                    // SetProp records in this batch can resolve the new key.
5257                    let id = lookup(&self.ids, &pending, &freed, old_key).ok_or_else(|| {
5258                        GraphError::Corrupt {
5259                            detail: format!(
5260                                "dense WAL rewrite: RenameNode old key {old_key} not found"
5261                            ),
5262                        }
5263                    })?;
5264                    pending.remove(old_key.as_str());
5265                    freed.insert(old_key.clone());
5266                    pending.insert(new_key.clone(), id);
5267                    out.push(rec);
5268                }
5269                // # Symbol-order invariant (load-bearing)
5270                //
5271                // Write-time and replay-time symbol assignment must agree: every
5272                // symbol in a `Batch` frame has to receive the same dense id when
5273                // the frame's records are replayed in order as it received when
5274                // the frame was written.
5275                //
5276                // A rule's backfill interns its `edge_type` lazily
5277                // (`core_rules::engine`, every `g.syms.intern(&def.edge_type)`
5278                // site), and that backfill runs from `apply` — during the
5279                // `CreateRule` record itself, and again from any later
5280                // `InsertNodeId` in the same frame that makes the rule fire. At
5281                // write time the whole batch is rewritten before any of it is
5282                // applied, so a later `InsertEdge` in the same batch would win the
5283                // lower id for its edge type; on replay the rule's lazy intern
5284                // gets there first and steals it, and the `Intern` record fails at
5285                // the `wal intern assigned …` check in `apply`.
5286                //
5287                // Pre-interning the rule's `edge_type` here, and emitting its
5288                // `Intern` record ahead of the `CreateRule` record, makes both
5289                // orders identical. `weight_prop` needs no pre-intern:
5290                // `EdgeProps::set` keys props by `String`, never through the
5291                // interner. `via_edge` needs none either: via-hop rules resolve it
5292                // with `syms.get` and skip when it is absent.
5293                //
5294                // `RebuildRule` and `DeleteRule` need no such handling here:
5295                // `RebuildRule` has no `BatchOp` variant, so it never appears
5296                // inside a `Batch` today — it is only ever issued as its own
5297                // standalone commit (`rebuild_rule`, or the auto-rebuild path
5298                // that logs it as a second commit after the triggering op).
5299                // `DeleteRule` does have a `BatchOp` variant and can appear
5300                // inside a `Batch`, but it carries only a rule `name` — no
5301                // `edge_type` or other symbol that needs pre-interning — so
5302                // only `CreateRule` needs this arm.
5303                WalRecord::CreateRule { ref def_bytes } => {
5304                    let def = decode_rule_def(def_bytes).map_err(|e| GraphError::Corrupt {
5305                        detail: format!("CreateRule def_bytes deserialize failed: {e}"),
5306                    })?;
5307                    let (etype, intern) = self.intern_wal(&def.edge_type);
5308                    if interned.insert(etype) {
5309                        out.push(intern);
5310                    }
5311                    out.push(rec);
5312                }
5313                WalRecord::DeleteNode { ref key } => {
5314                    pending.remove(key.as_str());
5315                    freed.insert(key.clone());
5316                    out.push(rec);
5317                }
5318                other => out.push(other),
5319            }
5320        }
5321        Ok(out)
5322    }
5323
5324    fn log_dense(&mut self, recs: Vec<WalRecord>) -> Result<()> {
5325        let recs = self.rewrite_wal_dense(recs)?;
5326        match recs.len() {
5327            0 => Ok(()),
5328            1 => self.log_then_apply(recs.into_iter().next().unwrap()),
5329            _ => self.log_then_apply(WalRecord::Batch(recs)),
5330        }
5331    }
5332
5333    /// Durable write, then notify the event sink. Replay (`apply` during
5334    /// `open`) never enters this function, so it is the replay-silent seam.
5335    /// Record that the commit occupying `frame_index` happened now, and append
5336    /// those 16 bytes to the sidecar.
5337    ///
5338    /// `frame_index` is the **global 0-based WAL frame index** of the commit's
5339    /// own record — the space every history surface addresses — taken from
5340    /// [`wal_frames_written`](GraphDb::wal_frames_written) before the append
5341    /// that puts the record there.
5342    ///
5343    /// **It is deliberately not derived from `commit_seq`.** A commit is not a
5344    /// frame: one whose rules fire appends a second frame for the derived-edge
5345    /// history marker, so `commit_seq - 1` falls one frame further behind per
5346    /// rule-firing commit and every date resolves to an ever-earlier graph.
5347    /// That was the shipped behaviour through v0.6.11 and it failed silently,
5348    /// because an older graph is a plausible answer rather than an error.
5349    ///
5350    /// Called from exactly one place — `log_then_apply_with`, immediately after
5351    /// `commit_seq` is incremented. Every write path in the engine funnels
5352    /// through that function, and replay deliberately does not: `apply_frames`
5353    /// re-applies commits that already happened, so stamping there would record
5354    /// replay time as commit time.
5355    ///
5356    /// **This is the engine's only wall-clock read.** Everything else uses
5357    /// `Instant`, which is monotonic and not a date.
5358    ///
5359    /// Failure is swallowed on purpose. The sidecar is not part of the WAL or
5360    /// the snapshot, so a failed append must not fail a commit that is already
5361    /// durable — it costs a date, not data. The map is marked poisoned so the
5362    /// gap is reported rather than resolved across.
5363    fn stamp_commit_time(&mut self, frame_index: u64) {
5364        if self.commit_times_poisoned {
5365            return;
5366        }
5367        let unix_ms = match self.commit_time_override {
5368            Some(ms) => ms,
5369            None => {
5370                let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
5371                else {
5372                    // A clock before 1970. Refuse to invent a timestamp.
5373                    self.commit_times_poisoned = true;
5374                    return;
5375                };
5376                now.as_millis() as i64
5377            }
5378        };
5379        let first = self.commit_times.is_empty();
5380        self.commit_times.push(frame_index, unix_ms);
5381
5382        let wrote = if first {
5383            self.fs.write_atomic(
5384                FileId::CommitTimes,
5385                &core_storage::commit_times::encode(&self.commit_times),
5386            )
5387        } else {
5388            self.fs.append(
5389                FileId::CommitTimes,
5390                &core_storage::commit_times::encode_entry(frame_index, unix_ms),
5391            )
5392        };
5393        if wrote.is_err() {
5394            self.commit_times_poisoned = true;
5395        }
5396    }
5397
5398    /// Read the time sidecar from disk into this handle.
5399    ///
5400    /// Absent is the normal case for any store written before v0.6.11 and is
5401    /// not an error; unreadable is recorded so date queries can say "damaged"
5402    /// rather than "none recorded".
5403    ///
5404    /// Called at open **and** by `refresh` when a peer's frames are absorbed.
5405    /// Both, because the map is a file another process appends to: a handle
5406    /// that raises its frame cursor to include a peer's commits while holding a
5407    /// stale map would answer dates from a prefix of the truth — and, if its
5408    /// own map were still empty, would rewrite the whole file with one entry
5409    /// and destroy the peer's.
5410    fn load_commit_times_from_fs(&mut self) {
5411        match self.fs.read(FileId::CommitTimes) {
5412            Ok(bytes) if bytes.is_empty() => {}
5413            Ok(bytes) => {
5414                // A map from an older format version is discarded, not
5415                // reported as damage and not reinterpreted. Its entries were
5416                // written correctly against a different meaning of the number
5417                // — see `COMMIT_TIMES_VERSION` — and reading them in this
5418                // build's space would resolve dates onto unrelated commits.
5419                // Leaving the map empty makes the store answer
5420                // `NoRecordedTime`, which is the truth: it records no times
5421                // this build can use, and the next commit starts a usable map.
5422                if core_storage::commit_times::superseded_version(&bytes).is_some() {
5423                    return;
5424                }
5425                match core_storage::commit_times::decode(&bytes) {
5426                    Ok(t) => self.commit_times = t,
5427                    Err(_) => self.commit_times_poisoned = true,
5428                }
5429            }
5430            Err(_) => {}
5431        }
5432    }
5433
5434    /// Rewrite the sidecar from memory. Used after truncation, which is the one
5435    /// operation that cannot be expressed as an append.
5436    fn rewrite_commit_times(&mut self) {
5437        if self.commit_times_poisoned {
5438            return;
5439        }
5440        if self
5441            .fs
5442            .write_atomic(
5443                FileId::CommitTimes,
5444                &core_storage::commit_times::encode(&self.commit_times),
5445            )
5446            .is_err()
5447        {
5448            self.commit_times_poisoned = true;
5449        }
5450    }
5451
5452    /// The greatest commit whose recorded time is at or before `unix_ms`.
5453    ///
5454    /// Errors name what they can answer instead of guessing a commit:
5455    /// `Corrupt` when the sidecar would not decode, `NoRecordedTime` when the
5456    /// store records none, `TimeBeforeFloor` when the instant predates the
5457    /// oldest entry, and `CommitOutOfRange` when the answer falls below the WAL
5458    /// horizon and so cannot be replayed.
5459    ///
5460    /// The answer is a **0-based frame index**, ready to hand to `edges_at` or
5461    /// `was_linked` without adjustment.
5462    pub fn resolve_instant(&self, unix_ms: i64) -> Result<u64> {
5463        if self.commit_times_poisoned {
5464            return Err(GraphError::Corrupt {
5465                detail: "commit_times.bin will not decode; date queries are \
5466                         unavailable on this store"
5467                    .into(),
5468            });
5469        }
5470        let at = self
5471            .commit_times
5472            .resolve_instant(unix_ms, self.wal_horizon_floor)?;
5473        // The map outlives the history it describes. A truncating snapshot folds
5474        // the WAL and discards it, so entries can name commits the engine can no
5475        // longer replay — the floor check above catches pruning, and this catches
5476        // discarding. Returning an index the caller's next call will reject is a
5477        // two-step error where one will do, and `resolve_date` is public: it
5478        // either hands back a usable index or refuses.
5479        let total = self.wal_total_commits()?;
5480        if at >= total {
5481            return Err(GraphError::CommitOutOfRange {
5482                commit: at,
5483                total,
5484                floor: self.wal_horizon_floor,
5485            });
5486        }
5487        Ok(at)
5488    }
5489
5490    /// Record subsequent commits as having happened at `unix_ms`, or pass
5491    /// `None` to go back to the system clock.
5492    ///
5493    /// For **backfilled history**: a mirror importing rows that already carry
5494    /// their own timestamps, or a replay of events that happened months ago.
5495    /// Without this every such commit is stamped "now", so a store holding a
5496    /// year of imported history answers every date question with
5497    /// `TimeBeforeFloor` — the data is there and no date reaches it.
5498    ///
5499    /// Sticky until changed or cleared, because a day of backfilled rows
5500    /// genuinely shares one instant.
5501    ///
5502    /// **Import in chronological order.** A supplied instant earlier than
5503    /// anything already recorded is refused with
5504    /// [`GraphError::CommitTimeNotMonotonic`], because resolution walks commit
5505    /// order: a later commit carrying an earlier time would silently widen every
5506    /// answer after it. Equal is allowed — that is what a shared day means. The
5507    /// live clock is never held to this, so an NTP step backwards still commits.
5508    ///
5509    /// Deliberately **not** exposed over HTTP or MCP: asserting when a commit
5510    /// happened rewrites the store's apparent history, which is not something a
5511    /// role token models. It is an embedding-caller's operation.
5512    pub fn record_commits_at(&mut self, unix_ms: Option<i64>) -> Result<()> {
5513        if self.read_only {
5514            return Err(GraphError::ReadOnly);
5515        }
5516        if let Some(ms) = unix_ms {
5517            if self.commit_times_poisoned {
5518                return Err(GraphError::Corrupt {
5519                    detail: "commit_times.bin will not decode; this store cannot \
5520                             record an asserted commit time"
5521                        .into(),
5522                });
5523            }
5524            if let Some(newest) = self.commit_times.max_ms() {
5525                if ms < newest {
5526                    return Err(GraphError::CommitTimeNotMonotonic {
5527                        supplied_ms: ms,
5528                        newest_ms: newest,
5529                    });
5530                }
5531            }
5532        }
5533        self.commit_time_override = unix_ms;
5534        Ok(())
5535    }
5536
5537    /// The instant subsequent commits are being recorded at, when one is set.
5538    pub fn commit_time_override(&self) -> Option<i64> {
5539        self.commit_time_override
5540    }
5541
5542    /// [`Self::edges_at`] addressed by an instant rather than a commit index.
5543    ///
5544    /// Resolves through [`Self::resolve_instant`] — the last commit at or
5545    /// before the instant — then answers exactly as the commit-indexed call
5546    /// does. A store that records no times refuses by name; it never guesses.
5547    pub fn edges_at_instant(&self, key: &str, unix_ms: i64) -> Result<Vec<EdgeAt>> {
5548        let commit = self.resolve_instant(unix_ms)?;
5549        self.edges_at(key, commit)
5550    }
5551
5552    /// [`Self::was_linked`] addressed by an instant rather than a commit index.
5553    pub fn was_linked_at_instant(
5554        &self,
5555        a: &str,
5556        b: &str,
5557        edge_type: &str,
5558        unix_ms: i64,
5559    ) -> Result<bool> {
5560        let commit = self.resolve_instant(unix_ms)?;
5561        self.was_linked(a, b, edge_type, commit)
5562    }
5563
5564    /// Parse an RFC 3339 instant (or a bare `YYYY-MM-DD`) and resolve it.
5565    ///
5566    /// The one place every caller-facing surface converts a date string, so
5567    /// HTTP, MCP, Python and the CLI cannot drift in what they accept.
5568    pub fn resolve_date(&self, s: &str) -> Result<u64> {
5569        // The **end** of what the string denotes. A bare date is a day, so it
5570        // resolves to the last commit at or before that day's end — resolving to
5571        // the midnight that starts it would exclude everything that happened on
5572        // the date the caller asked about.
5573        let ms = core_storage::commit_times::parse_rfc3339_end_ms(s).ok_or_else(|| {
5574            GraphError::QueryError {
5575                detail: format!(
5576                    "could not parse {s:?} as a date; expected RFC 3339 \
5577                     (2026-06-19, or 2026-06-19T12:00:00Z)"
5578                ),
5579            }
5580        })?;
5581        self.resolve_instant(ms)
5582    }
5583
5584    /// The recorded wall-clock time of `commit`, when the sidecar holds one.
5585    ///
5586    /// `commit` is a **0-based frame index** — the space `edges_at`,
5587    /// `was_linked` and the history events use, not the 1-based `commit_seq`.
5588    pub fn commit_time_ms(&self, commit: u64) -> Option<i64> {
5589        if self.commit_times_poisoned {
5590            return None;
5591        }
5592        self.commit_times.time_of(commit)
5593    }
5594
5595    fn log_then_apply(&mut self, rec: WalRecord) -> Result<()> {
5596        self.log_then_apply_with(rec, None, self.fsync)
5597    }
5598
5599    /// Whether this frame must fsync under `policy`.
5600    ///
5601    /// Batched contract: user-visible batches (>1 mutation) fsync; single
5602    /// mutations do not. The dense rewrite wraps a single mutation in a
5603    /// `Batch([Intern.., <one *Id record>])`, so `Intern` records are excluded
5604    /// from the count — removing that filter would make every single-op write
5605    /// fsync under Batched (or, if the threshold were raised instead, skip a
5606    /// needed fsync for real two-op batches).
5607    fn wal_needs_sync(policy: FsyncPolicy, rec: &WalRecord) -> bool {
5608        match policy {
5609            FsyncPolicy::Relaxed => false,
5610            FsyncPolicy::Strict => true,
5611            FsyncPolicy::Batched => match rec {
5612                // Intern + one mutation is the single-op rewrite, not a user batch.
5613                WalRecord::Batch(inner) => {
5614                    inner
5615                        .iter()
5616                        .filter(|r| !matches!(r, WalRecord::Intern { .. }))
5617                        .count()
5618                        > 1
5619                }
5620                _ => false,
5621            },
5622        }
5623    }
5624
5625    /// # Apply-infallibility invariant (load-bearing)
5626    ///
5627    /// The ordering is: WAL append → fsync → apply. If `apply` returned `Err`
5628    /// for a `Batch` frame after a successful WAL write, the WAL would contain
5629    /// the full frame while in-memory state would reflect only the ops before
5630    /// the failure. On reopen, WAL replay would then apply the entire batch —
5631    /// diverging permanently from what the pre-crash process had in memory.
5632    ///
5633    /// For `Batch` frames this situation cannot arise because:
5634    /// - All validation runs via `commit_logged_batch`/`MutPreview` **before**
5635    ///   the WAL write. `MutPreview` uses the same `&mut self` that apply will
5636    ///   use, with no concurrent mutation between validation exit and apply entry.
5637    /// - Every `apply` arm for a validated op is either infallible by construction
5638    ///   (`InsertNode`, `RemoveProp`, `DeleteEdge`, `DeleteNode`), has idempotency
5639    ///   guards that return `Ok(())` (`CreateRule`, `DeleteRule`), or is
5640    ///   guaranteed-present by validation (`InsertEdge`/`SetProp` key lookups).
5641    /// - `on_node_changed` and `on_node_removed` return `()` — never `Err`.
5642    ///
5643    /// A `debug_assert!` below fires in debug builds if `apply` ever returns
5644    /// `Err` for a `Batch` frame, making any future regression immediately visible
5645    /// in tests rather than silently diverging crash-recovery behaviour.
5646    fn log_then_apply_with(
5647        &mut self,
5648        rec: WalRecord,
5649        ingest: Option<(String, usize)>,
5650        policy: FsyncPolicy,
5651    ) -> Result<()> {
5652        // Read-only guard: as-of instances must never write the WAL.
5653        if self.read_only {
5654            return Err(GraphError::ReadOnly);
5655        }
5656        // Degraded guard: fsync failure left WAL truncated, or a refresh failed
5657        // partway; in-memory state is ahead of (or out of step with) the
5658        // on-disk WAL, so further mutations would deepen the divergence.
5659        // Reopen the database to recover.  Checked before the lock guard: this
5660        // is the more serious condition and the more useful error.
5661        if self.degraded {
5662            return Err(GraphError::Io(std::io::Error::other(
5663                "database degraded after group-commit fsync failure; reopen required",
5664            )));
5665        }
5666        // Cross-process guard: this write scope asked for the store's write
5667        // lock and did not get it. Writing anyway would append frames on top of
5668        // a WAL another process is extending, so refuse instead.
5669        if self.lock_denied {
5670            return Err(GraphError::Busy { holder: None });
5671        }
5672        // Ensure retained provenance bytes are decoded into the live mutable
5673        // fields before any mutation touches self.engine.provenance.  This is a
5674        // no-op if provenance was never stored (fresh store) or has already been
5675        // consumed (subsequent mutations).  WAL replay calls apply() directly
5676        // and is covered by consume_retained_state_eager before replay.
5677        self.ensure_v8_base_sections_loaded();
5678        self.engine.ensure_provenance_loaded_mut();
5679        // Invariant (I-1): no stale deltas may enter from a previous apply.
5680        // If any engine method ever accumulates deltas before erroring, they would
5681        // contaminate the *next* commit's event stream. This assert fires in debug
5682        // builds, making any future regression visible at the earliest point.
5683        debug_assert_eq!(
5684            self.engine.pending_delta_count(),
5685            0,
5686            "stale engine deltas at log_then_apply_with entry — \
5687             a previous apply arm may have accumulated deltas before erroring; \
5688             the caller must drain_deltas() on any error path before returning"
5689        );
5690        if self.intent_unsettled {
5691            self.settle_snapshot_intent()?;
5692        }
5693        let frame = encode_record(&rec);
5694        self.fs.append(FileId::Wal, &frame)?;
5695        // The cursor advances by exactly the bytes appended: these frames are
5696        // ours and already applied, so a later refresh must not replay them.
5697        self.wal_consumed += frame.len() as u64;
5698        self.wal_frames_written += 1;
5699        if Self::wal_needs_sync(policy, &rec) {
5700            self.fs.sync(FileId::Wal)?;
5701        }
5702        // Marker writing always needs the engine deltas, but the engine only
5703        // accumulates them when emit_deltas is true (normally gated on subscribers
5704        // or views being present).  Enable emission for this apply if it is
5705        // currently off, then restore the original state unconditionally via an
5706        // RAII guard — this prevents a panic in apply() from leaking the flag.
5707        // The same guard resets the engine's transient chaining state. A panic
5708        // unwinding out of a rule hook would otherwise leave `chain_depth`
5709        // non-zero, which makes every later `begin_chain` decide chaining is
5710        // already running and silently switch it off for good.
5711        struct RestoreEmitDeltas(*mut RuleEngine, bool);
5712        impl Drop for RestoreEmitDeltas {
5713            fn drop(&mut self) {
5714                // SAFETY: pointer into self (GraphDb); guard is dropped within
5715                // this frame before log_then_apply_with returns.
5716                unsafe {
5717                    (*self.0).set_emit_deltas(self.1);
5718                    (*self.0).reset_chain_state();
5719                }
5720            }
5721        }
5722        let original_emit = self.engine.emit_deltas();
5723        if !original_emit {
5724            self.engine.set_emit_deltas(true);
5725        }
5726        // SAFETY: raw pointer into self; guard dropped within this frame.
5727        let _emit_guard = RestoreEmitDeltas(&mut self.engine as *mut _, original_emit);
5728
5729        let apply_result = self.apply(&rec);
5730        // For Batch frames, post-validation apply must be infallible (see above).
5731        // A debug_assert here catches any future change that makes apply fallible
5732        // before the caller notices via silent WAL/memory divergence.
5733        if matches!(&rec, WalRecord::Batch(_)) {
5734            debug_assert!(
5735                apply_result.is_ok(),
5736                "Batch apply returned Err after successful WAL write — \
5737                 the validate-then-apply invariant has been violated; \
5738                 see log_then_apply_with invariant doc"
5739            );
5740        }
5741        if apply_result.is_err() {
5742            // Discard any partial deltas accumulated by the failed apply.
5743            // They must not ride the next commit's event stream (I-1).
5744            // _emit_guard restores emit_deltas on drop automatically.
5745            let _ = self.engine.drain_deltas();
5746            let _ = self.engine.take_rebuild_needed();
5747            apply_result?;
5748        }
5749        self.commit_seq += 1;
5750        let seq = self.commit_seq;
5751        // Update per-node last-change map for the committed record.
5752        // Must happen after commit_seq is incremented so the seq is correct.
5753        self.update_last_change_from_rec(&rec, seq);
5754        // Drain engine deltas and distribute to subscribers before the existing
5755        // MutationEvent sink fires — both happen post-fsync, post-apply.
5756        // _emit_guard restores emit_deltas after this line when it drops.
5757        let engine_deltas = self.engine.drain_deltas();
5758
5759        // Append history-marker WAL records for any derived-edge changes so
5760        // that `edge_history` and `was_linked` can surface rule-attributed
5761        // events. Markers are STATE NO-OPS during replay; they are written
5762        // without an additional fsync (the triggering commit's sync already
5763        // happened; the next commit's sync covers these lazily).
5764        if !engine_deltas.is_empty() {
5765            let markers: Vec<WalRecord> = engine_deltas
5766                .iter()
5767                .map(|d| {
5768                    if d.fired {
5769                        WalRecord::DerivedEdgeAdded {
5770                            rule: d.rule.clone(),
5771                            edge_type: d.edge_type.clone(),
5772                            src_key: d.src_key.clone(),
5773                            dst_key: d.dst_key.clone(),
5774                        }
5775                    } else {
5776                        WalRecord::DerivedEdgeRetracted {
5777                            rule: d.rule.clone(),
5778                            edge_type: d.edge_type.clone(),
5779                            src_key: d.src_key.clone(),
5780                            dst_key: d.dst_key.clone(),
5781                        }
5782                    }
5783                })
5784                .collect();
5785            let marker_frame = if markers.len() == 1 {
5786                markers.into_iter().next().unwrap()
5787            } else {
5788                WalRecord::Batch(markers)
5789            };
5790            // Ignore append errors: markers are best-effort history
5791            // annotations. Losing them does not affect state correctness.
5792            // The cursor only advances when the bytes actually landed.
5793            let marker_bytes = encode_record(&marker_frame);
5794            if self.fs.append(FileId::Wal, &marker_bytes).is_ok() {
5795                self.wal_consumed += marker_bytes.len() as u64;
5796                // A marker is a state no-op during replay but it is not an
5797                // index no-op: it occupies a frame that every history surface
5798                // counts. Missing this increment is the whole of defect 1.
5799                self.wal_frames_written += 1;
5800            }
5801        }
5802
5803        // Stamp the commit against the **last** frame it wrote.
5804        //
5805        // `edges_at` and `was_linked` reconstruct derived edges by reading the
5806        // history markers out of the WAL, not by re-running rules over a
5807        // prefix. A commit's complete state — its record *and* the edges its
5808        // rules derived — is therefore only reached at its marker frame, so
5809        // that is the frame a date naming this commit must resolve to. Stamping
5810        // the record's own frame would answer every date with the graph as it
5811        // was one derivation short.
5812        //
5813        // This runs after the marker append for that reason, and it is still
5814        // the single stamping site: one commit, one entry.
5815        self.stamp_commit_time(self.wal_frames_written - 1);
5816
5817        // Record MVCC CommitDelta for the epoch reader.  The WAL record is
5818        // stored as-is (including any nested Batch / Intern records); the
5819        // ReaderSnapshot's apply_one function handles all variants.
5820        {
5821            let derived_inserts = engine_deltas
5822                .iter()
5823                .filter(|d| d.fired)
5824                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5825                .collect();
5826            let derived_deletes = engine_deltas
5827                .iter()
5828                .filter(|d| !d.fired)
5829                .map(|d| (d.etype_sym, d.src_id, d.dst_id))
5830                .collect();
5831            // `None`: the commit moved more view values than a delta should
5832            // carry, so the fold below takes them instead.
5833            let view_values = self.view_values_of_commit(&rec);
5834            let fold = view_values.is_none();
5835            let delta = Arc::new(crate::reader::CommitDelta {
5836                records: vec![rec.clone()],
5837                derived_inserts,
5838                derived_deletes,
5839                view_values: view_values.unwrap_or_default(),
5840            });
5841            self.delta_tail.push(delta);
5842            self.commits_since_fold += 1;
5843            if fold || self.commits_since_fold >= crate::reader::FOLD_EVERY_K {
5844                self.fold_now();
5845            }
5846        }
5847
5848        if self.defer_events {
5849            // Group-commit drain thread: hold events until after the group
5850            // fsync so subscribers only observe durable data (R2).
5851            self.deferred_events.push(DeferredEvent {
5852                rec: rec.clone(),
5853                engine_deltas,
5854                seq,
5855                ingest,
5856            });
5857        } else {
5858            self.distribute_events(&rec, &engine_deltas, seq);
5859            self.emit_committed(&rec, ingest);
5860        }
5861        // Drift is only known after apply, so auto-rebuild cannot join the
5862        // triggering op's WAL frame. Issue RebuildRule as a second commit.
5863        // Skip when `rec` is itself RebuildRule: rebuild resets drift, so a
5864        // retrigger loop is impossible if the fit succeeded, but we still
5865        // drain the flag so a leftover cannot re-enter.
5866        // One slice of any outstanding vector-index build rides here too, so a
5867        // store that is being written to finishes its build without anyone
5868        // calling `pump_index_build`. A rule that becomes whole joins the same
5869        // RebuildRule loop below.
5870        let mut rebuilds = self.engine.take_rebuild_needed();
5871        if !matches!(&rec, WalRecord::RebuildRule { .. }) {
5872            // Not after `CreateRule`: that record's own apply already did the
5873            // rule's first slice, and pumping again here would make one
5874            // `create_rule` call do two slices' work under one lock.
5875            // Nothing pending is the overwhelmingly common case and must cost
5876            // a map lookup, not an engine swap: a store being written to has
5877            // long since populated its indexes, so the `pump_index_build`
5878            // entry point owns the not-yet-populated case on its own.
5879            if !is_create_rule_frame(&rec) && !self.engine.builds_in_progress().is_empty() {
5880                rebuilds.extend(self.pump_one_slice().into_iter().map(|b| b.rule));
5881            }
5882            let mut failed = Vec::new();
5883            for name in rebuilds {
5884                if self.engine.rules().any(|r| r.name == name) {
5885                    // User op is already durable. A failed second commit must
5886                    // not surface as the caller's error.
5887                    if let Err(e) =
5888                        self.log_then_apply(WalRecord::RebuildRule { name: name.clone() })
5889                    {
5890                        eprintln!(
5891                            "auto-rebuild of rule {name:?} failed after durable user commit: {e}"
5892                        );
5893                        failed.push(name);
5894                    }
5895                }
5896            }
5897            for name in failed {
5898                self.engine.queue_rebuild_needed(name);
5899            }
5900        }
5901        Ok(())
5902    }
5903
5904    /// Install a post-commit hook. Replaces any previous sink.
5905    ///
5906    /// The sink runs inside `log_then_apply` after a successful
5907    /// durable commit, while the caller still holds `&mut self`. When this
5908    /// database is behind a [`crate::SharedDb`], that means the **write
5909    /// guard is held**. The sink must never call `read` / `write` (or any
5910    /// other method) on the same `SharedDb` — the `RwLock` is not
5911    /// re-entrant and doing so deadlocks. The sink is `Send + Sync`;
5912    /// `std::sync::mpsc::Sender` is not `Sync` and will not type-check.
5913    /// Intended examples: `std::sync::mpsc::SyncSender`,
5914    /// `tokio::sync::mpsc::Sender`, `tokio::sync::broadcast::Sender`
5915    /// (non-blocking `send`), or `Arc<Mutex<Vec<MutationEvent>>>`.
5916    pub fn set_event_sink(&mut self, sink: Box<dyn Fn(MutationEvent) + Send + Sync>) {
5917        self.event_sink = Some(sink);
5918    }
5919
5920    /// Whether a post-commit event sink is currently installed.
5921    pub fn has_event_sink(&self) -> bool {
5922        self.event_sink.is_some()
5923    }
5924
5925    /// Set WAL fsync cadence. Default [`FsyncPolicy::Strict`].
5926    pub fn set_fsync_policy(&mut self, p: FsyncPolicy) {
5927        self.fsync = p;
5928    }
5929
5930    /// Return the current WAL fsync cadence.
5931    pub fn fsync_policy(&self) -> FsyncPolicy {
5932        self.fsync
5933    }
5934
5935    // ── Group-commit event deferral ───────────────────────────────────────────
5936
5937    /// Enable or disable deferred event mode.
5938    ///
5939    /// When `true`, event notifications (subscription `DbEvent`s and legacy
5940    /// `MutationEvent` sink calls) are buffered rather than fired immediately.
5941    /// Call [`flush_deferred_events`] after the group fsync to deliver them,
5942    /// or [`discard_deferred_events`] if the fsync failed and the group must
5943    /// be treated as lost.
5944    pub fn set_deferred_events_mode(&mut self, defer: bool) {
5945        self.defer_events = defer;
5946    }
5947
5948    /// Fire all buffered events accumulated since [`set_deferred_events_mode`]
5949    /// was set to true.  Clears the buffer.
5950    ///
5951    /// Called by the drain thread AFTER a successful group fsync, so
5952    /// subscribers observe only data that is durably on disk.
5953    pub fn flush_deferred_events(&mut self) {
5954        let events = std::mem::take(&mut self.deferred_events);
5955        for de in events {
5956            self.distribute_events(&de.rec, &de.engine_deltas, de.seq);
5957            self.emit_committed(&de.rec, de.ingest);
5958        }
5959    }
5960
5961    /// Discard all buffered events without firing them.
5962    ///
5963    /// Called by the drain thread when a group fsync fails: the WAL has been
5964    /// truncated back to the pre-group offset, so the committed-but-unsynced
5965    /// ops must not be observable to subscribers.
5966    pub fn discard_deferred_events(&mut self) {
5967        self.deferred_events.clear();
5968    }
5969
5970    // ── Degraded state ────────────────────────────────────────────────────────
5971
5972    /// Mark this database as degraded.
5973    ///
5974    /// Called by the group-commit drain thread after a group fsync failure and
5975    /// WAL truncation: the in-memory state is now ahead of the on-disk WAL, so
5976    /// further mutations would deepen the divergence.  All subsequent calls to
5977    /// [`log_then_apply_with`] return `Err` until the database is reopened.
5978    pub fn set_degraded(&mut self) {
5979        self.degraded = true;
5980    }
5981
5982    fn emit(&self, ev: MutationEvent) {
5983        if let Some(sink) = &self.event_sink {
5984            sink(ev);
5985        }
5986    }
5987
5988    fn emit_committed(&self, rec: &WalRecord, ingest: Option<(String, usize)>) {
5989        match rec {
5990            WalRecord::Batch(inner) => {
5991                for r in inner {
5992                    if let Some(ev) = event_from_record(r, &self.syms, &self.ids) {
5993                        self.emit(ev);
5994                    }
5995                }
5996                match ingest {
5997                    Some((label, inserted)) => {
5998                        self.emit(MutationEvent::Ingested { label, inserted })
5999                    }
6000                    None => {
6001                        let ops = inner
6002                            .iter()
6003                            .filter(|r| !matches!(r, WalRecord::Intern { .. }))
6004                            .count();
6005                        if ops > 1 {
6006                            self.emit(MutationEvent::BatchApplied { ops });
6007                        }
6008                    }
6009                }
6010            }
6011            other => {
6012                if let Some(ev) = event_from_record(other, &self.syms, &self.ids) {
6013                    self.emit(ev);
6014                }
6015            }
6016        }
6017    }
6018
6019    // -----------------------------------------------------------------------
6020    // Subscription API
6021    // -----------------------------------------------------------------------
6022
6023    /// Distribute post-commit events to all live subscribers.
6024    ///
6025    /// Build a row-key → row-data map from a [`ResultSet`].
6026    ///
6027    /// Each row is serialized to JSON to form its key; a debug fallback is used
6028    /// if serialization fails. Used by both the initial-seed path in
6029    /// [`Self::subscribe_query`] and the per-commit diff path in
6030    /// [`Self::distribute_events`] to keep the two in sync.
6031    fn result_to_row_map(
6032        result: &core_query::ResultSet,
6033    ) -> std::collections::HashMap<String, Vec<Option<Value>>> {
6034        (0..result.len())
6035            .map(|i| {
6036                let row = result.row(i).to_vec();
6037                let key = serde_json::to_string(&row).unwrap_or_else(|_| format!("{row:?}"));
6038                (key, row)
6039            })
6040            .collect()
6041    }
6042
6043    /// Collect the set of label syms touched by a WAL record.
6044    ///
6045    /// Returns `Some(set)` when every record in this commit can be attributed to
6046    /// a known label sym. Returns `None` when the commit must not be skipped:
6047    /// edge records, unresolvable key→label lookups, or any record type not in
6048    /// the explicit handled set.
6049    ///
6050    /// Handled record types and their actions:
6051    /// - `InsertNode`   → look up label in interner (fails → None)
6052    /// - `InsertNodeId` → label sym is carried directly
6053    /// - `SetProp`      → resolve key→id→label (fails → None)
6054    /// - `DeleteNode`   → resolve key→id→label (fails → None)
6055    /// - `Batch`        → recurse into every inner record
6056    /// - `InsertEdge`, `DeleteEdge`, `InsertEdgeId` → always None (edge records)
6057    /// - everything else → None (conservative)
6058    fn commit_touched_labels(
6059        rec: &WalRecord,
6060        syms: &Interner,
6061        ids: &IdMap,
6062        labels: &[u32],
6063    ) -> Option<BTreeSet<u32>> {
6064        let mut out = BTreeSet::new();
6065        if Self::collect_touched_labels(rec, syms, ids, labels, &mut out) {
6066            Some(out)
6067        } else {
6068            None
6069        }
6070    }
6071
6072    fn collect_touched_labels(
6073        rec: &WalRecord,
6074        syms: &Interner,
6075        ids: &IdMap,
6076        labels: &[u32],
6077        out: &mut BTreeSet<u32>,
6078    ) -> bool {
6079        match rec {
6080            // String-key insert: the dense rewrite converts this to
6081            // [Intern, InsertNodeId], so this arm fires only for legacy WAL
6082            // records written before the dense path was added.
6083            WalRecord::InsertNode { label, .. } => {
6084                if let Some(sym) = syms.get(label) {
6085                    out.insert(sym);
6086                    true
6087                } else {
6088                    false
6089                }
6090            }
6091            // Dense-id insert (produced by rewrite_wal_dense for every
6092            // insert_node call in the current codebase).
6093            WalRecord::InsertNodeId { label, .. } => {
6094                out.insert(*label);
6095                true
6096            }
6097            // String-key prop set: dense path converts to [Intern, SetPropId].
6098            WalRecord::SetProp { key, .. } => {
6099                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
6100                    out.insert(sym);
6101                    true
6102                } else {
6103                    false
6104                }
6105            }
6106            // Dense-id prop set (produced by rewrite_wal_dense for set_prop).
6107            WalRecord::SetPropId { id, .. } => {
6108                if let Some(sym) = labels.get(*id as usize).copied().filter(|&s| s != u32::MAX) {
6109                    out.insert(sym);
6110                    true
6111                } else {
6112                    false
6113                }
6114            }
6115            WalRecord::DeleteNode { key } => {
6116                if let Some(sym) = Self::resolve_key_label_sym(key, ids, labels) {
6117                    out.insert(sym);
6118                    true
6119                } else {
6120                    false
6121                }
6122            }
6123            WalRecord::Batch(inner) => inner
6124                .iter()
6125                .all(|r| Self::collect_touched_labels(r, syms, ids, labels, out)),
6126            // Intern is a pure metadata record — it does not touch any node's
6127            // label and is safe to skip for the label-skip predicate.
6128            WalRecord::Intern { .. } => true,
6129            // Edge records: always re-execute (edges can change join results).
6130            WalRecord::InsertEdge { .. }
6131            | WalRecord::DeleteEdge { .. }
6132            | WalRecord::InsertEdgeId { .. } => false,
6133            _ => false,
6134        }
6135    }
6136
6137    /// Resolve a node key to its label sym via the dense id table.
6138    /// Returns `None` if the key is unknown or the label is a tombstone sentinel.
6139    fn resolve_key_label_sym(key: &str, ids: &IdMap, labels: &[u32]) -> Option<u32> {
6140        let id = ids.get(key)?;
6141        let sym = labels.get(id as usize).copied()?;
6142        (sym != u32::MAX).then_some(sym)
6143    }
6144
6145    /// Distribute post-commit events to all live subscribers.
6146    ///
6147    /// Called from `log_then_apply_with` after apply + fsync, before the
6148    /// legacy MutationEvent sink. Prunes dead `Weak` entries in-place.
6149    ///
6150    /// Query subscriptions (subscribe_query) re-execute their plan on every
6151    /// call and diff the result against the previous run. Zero overhead when
6152    /// no query subscriptions are active.
6153    fn distribute_events(&mut self, rec: &WalRecord, engine_deltas: &[EngineEdgeDelta], seq: u64) {
6154        if self.subscriptions.is_empty() && self.query_subscriptions.is_empty() {
6155            return;
6156        }
6157
6158        if !self.subscriptions.is_empty() {
6159            // Build write events from the WAL record.
6160            let write_events: Vec<DbEvent> =
6161                Self::write_events_from_record(rec, seq, &self.syms, &self.ids);
6162
6163            // Build edge events from engine deltas.  Weight is looked up from
6164            // edge_props at distribution time (after apply), so it's always fresh.
6165            let edge_events: Vec<DbEvent> = engine_deltas
6166                .iter()
6167                .map(|d| {
6168                    if d.fired {
6169                        // The score lives under the rule's declared weight_prop,
6170                        // which is not always the literal "weight".
6171                        let prop = self
6172                            .engine
6173                            .rules()
6174                            .find(|r| r.name == d.rule)
6175                            .and_then(|r| r.weight_prop.as_deref());
6176                        let weight = prop.and_then(|p| {
6177                            self.edge_props
6178                                .get(d.etype_sym, d.src_id, d.dst_id, p)
6179                                .and_then(|v| {
6180                                    if let core_storage::Value::Float(f) = v {
6181                                        Some(*f)
6182                                    } else {
6183                                        None
6184                                    }
6185                                })
6186                        });
6187                        DbEvent::EdgeFired {
6188                            rule: d.rule.clone(),
6189                            src_key: d.src_key.clone(),
6190                            dst_key: d.dst_key.clone(),
6191                            edge_type: d.edge_type.clone(),
6192                            weight,
6193                            commit_seq: seq,
6194                        }
6195                    } else {
6196                        DbEvent::EdgeRetracted {
6197                            rule: d.rule.clone(),
6198                            src_key: d.src_key.clone(),
6199                            dst_key: d.dst_key.clone(),
6200                            edge_type: d.edge_type.clone(),
6201                            commit_seq: seq,
6202                        }
6203                    }
6204                })
6205                .collect();
6206
6207            // Prune dead entries; push matching events to live ones.
6208            self.subscriptions.retain(|entry| {
6209                let Some(inner) = entry.inner.upgrade() else {
6210                    return false;
6211                };
6212                for ev in &write_events {
6213                    if event_matches(ev, &entry.filter) {
6214                        inner.push(ev.clone());
6215                    }
6216                }
6217                for ev in &edge_events {
6218                    if event_matches(ev, &entry.filter) {
6219                        inner.push(ev.clone());
6220                    }
6221                }
6222                true
6223            });
6224
6225            // Turn off delta accumulation if all subscribers dropped and no views remain.
6226            if self.subscriptions.is_empty() && self.view_store.is_empty() {
6227                self.engine.set_emit_deltas(false);
6228            }
6229        }
6230
6231        // Query subscriptions: full re-run per commit, then diff rows.
6232        // IMPORTANT: full re-execution on every commit — use LIMIT to bound cost.
6233        // Differential evaluation is roadmap / Phase 5.
6234        if !self.query_subscriptions.is_empty() {
6235            // Take the list out so we can call self.view() without borrow conflict.
6236            let mut query_subs = std::mem::take(&mut self.query_subscriptions);
6237            let empty_params = BTreeMap::new();
6238            query_subs.retain_mut(|entry| {
6239                let Some(inner) = entry.inner.upgrade() else {
6240                    return false; // subscriber dropped — prune
6241                };
6242                // Label-skip: if the plan has a known scan label and this commit
6243                // can be proven to touch only different labels (and no rule-derived
6244                // edge deltas fired), the result set cannot have changed — skip.
6245                if let Some(scan_sym) = entry.scan_label {
6246                    if engine_deltas.is_empty() {
6247                        let touched =
6248                            Self::commit_touched_labels(rec, &self.syms, &self.ids, &self.labels);
6249                        if touched.map(|t| !t.contains(&scan_sym)).unwrap_or(false) {
6250                            return true; // safe to skip — result set unchanged
6251                        }
6252                    }
6253                }
6254                QUERY_SUB_EXECS_TL.with(|c| c.set(c.get() + 1));
6255                let result = match execute(&self.view(), &entry.ops, &Params(&empty_params)) {
6256                    Ok(r) => r,
6257                    Err(e) => {
6258                        // Keep the subscription alive; skip the diff for this commit.
6259                        // Re-run errors are transient (e.g., planner change) and
6260                        // self-heal when the next commit succeeds.
6261                        eprintln!("[mushroomdb] subscribe_query re-run failed: {e}");
6262                        return true;
6263                    }
6264                };
6265                // Build new row map: serialized-key → row data.
6266                let new_row_map = Self::result_to_row_map(&result);
6267                // Removed rows: in prev but not in new.
6268                for (key, row) in &entry.prev_row_map {
6269                    if !new_row_map.contains_key(key) {
6270                        inner.push(DbEvent::QueryRowRemoved {
6271                            columns: entry.columns.clone(),
6272                            row: row.clone(),
6273                        });
6274                    }
6275                }
6276                // Added rows: in new but not in prev.
6277                for (key, row) in &new_row_map {
6278                    if !entry.prev_row_map.contains_key(key) {
6279                        inner.push(DbEvent::QueryRowAdded {
6280                            columns: entry.columns.clone(),
6281                            row: row.clone(),
6282                        });
6283                    }
6284                }
6285                entry.prev_row_map = new_row_map;
6286                true
6287            });
6288            self.query_subscriptions = query_subs;
6289        }
6290    }
6291
6292    /// Returns `true` if any live subscriber or view definition requires delta
6293    /// accumulation. Used to set `engine.emit_deltas` on subscribe/view DDL.
6294    fn needs_emit_deltas(&self) -> bool {
6295        !self.view_store.is_empty()
6296            || self
6297                .subscriptions
6298                .iter()
6299                .any(|e| e.inner.upgrade().is_some())
6300    }
6301
6302    /// Convert a WAL record into `DbEvent` write events with the given seq.
6303    fn write_events_from_record(
6304        rec: &WalRecord,
6305        seq: u64,
6306        intern: &Interner,
6307        ids: &IdMap,
6308    ) -> Vec<DbEvent> {
6309        match rec {
6310            WalRecord::InsertNode { label, key, .. } => vec![DbEvent::NodeInserted {
6311                label: label.clone(),
6312                key: key.clone(),
6313                commit_seq: seq,
6314            }],
6315            // *Id arms run after a successful apply, so resolution can only
6316            // fail on a programming error. Skip the event rather than emit a
6317            // fabricated "" that clients can't tell from a real empty value
6318            // (mirrors event_from_record returning None).
6319            WalRecord::InsertNodeId { label, key, .. } => intern
6320                .resolve(*label)
6321                .map(|label| DbEvent::NodeInserted {
6322                    label: label.to_string(),
6323                    key: key.clone(),
6324                    commit_seq: seq,
6325                })
6326                .into_iter()
6327                .collect(),
6328            WalRecord::SetProp { key, field, .. } => vec![DbEvent::PropSet {
6329                key: key.clone(),
6330                field: field.clone(),
6331                commit_seq: seq,
6332            }],
6333            WalRecord::SetPropId { id, field, .. } => ids
6334                .key_of(*id)
6335                .zip(intern.resolve(*field))
6336                .map(|(key, field)| DbEvent::PropSet {
6337                    key: key.to_string(),
6338                    field: field.to_string(),
6339                    commit_seq: seq,
6340                })
6341                .into_iter()
6342                .collect(),
6343            WalRecord::RemoveProp { key, field } => vec![DbEvent::PropRemoved {
6344                key: key.clone(),
6345                field: field.clone(),
6346                commit_seq: seq,
6347            }],
6348            WalRecord::InsertEdge {
6349                edge_type,
6350                src_key,
6351                dst_key,
6352            } => vec![DbEvent::EdgeInserted {
6353                edge_type: edge_type.clone(),
6354                src: src_key.clone(),
6355                dst: dst_key.clone(),
6356                commit_seq: seq,
6357            }],
6358            WalRecord::InsertEdgeId { etype, src, dst } => (|| {
6359                Some(DbEvent::EdgeInserted {
6360                    edge_type: intern.resolve(*etype)?.to_string(),
6361                    src: ids.key_of(*src)?.to_string(),
6362                    dst: ids.key_of(*dst)?.to_string(),
6363                    commit_seq: seq,
6364                })
6365            })()
6366            .into_iter()
6367            .collect(),
6368            WalRecord::DeleteEdge {
6369                edge_type,
6370                src_key,
6371                dst_key,
6372            } => vec![DbEvent::EdgeDeleted {
6373                edge_type: edge_type.clone(),
6374                src: src_key.clone(),
6375                dst: dst_key.clone(),
6376                commit_seq: seq,
6377            }],
6378            WalRecord::DeleteNode { key } => vec![DbEvent::NodeDeleted {
6379                key: key.clone(),
6380                commit_seq: seq,
6381            }],
6382            WalRecord::Batch(inner) => inner
6383                .iter()
6384                .flat_map(|r| Self::write_events_from_record(r, seq, intern, ids))
6385                .collect(),
6386            WalRecord::CreateRule { .. }
6387            | WalRecord::DeleteRule { .. }
6388            | WalRecord::RebuildRule { .. }
6389            | WalRecord::CreateView { .. }
6390            | WalRecord::DeleteView { .. }
6391            | WalRecord::EnableFulltext { .. }
6392            | WalRecord::DisableFulltext { .. }
6393            | WalRecord::EnableIndex { .. }
6394            | WalRecord::DisableIndex { .. }
6395            | WalRecord::Intern { .. }
6396            // History markers produce no DbEvent — the engine delta already
6397            // fired the EdgeFired/EdgeRetracted subscription events.
6398            | WalRecord::DerivedEdgeAdded { .. }
6399            | WalRecord::DerivedEdgeRetracted { .. }
6400            // A count is not an edge event: the pair it counts already fired one
6401            // when it was first inserted.
6402            | WalRecord::SetEdgeCount { .. }
6403            | WalRecord::RenameNode { .. } => vec![],
6404        }
6405    }
6406
6407    /// Subscribe to edge-fire and edge-retract events for one named rule.
6408    ///
6409    /// Returns `Err(GraphError::RuleNotFound)` if `rule_name` is not
6410    /// currently registered. Dropping the returned [`Subscription`] handle
6411    /// unregisters the subscriber — no further events are queued, no
6412    /// resources leak.
6413    pub fn subscribe_rule(&mut self, rule_name: &str) -> core_storage::Result<Subscription> {
6414        if self.read_only {
6415            return Err(core_storage::GraphError::ReadOnly);
6416        }
6417        if !self.engine.rules().any(|r| r.name == rule_name) {
6418            return Err(core_storage::GraphError::RuleNotFound {
6419                name: rule_name.to_string(),
6420            });
6421        }
6422        let inner = SubInner::new(self.sub_capacity());
6423        self.subscriptions.push(SubEntry {
6424            filter: SubFilter::Rule(rule_name.to_string()),
6425            inner: std::sync::Arc::downgrade(&inner),
6426        });
6427        self.engine.set_emit_deltas(true);
6428        Ok(Subscription(inner))
6429    }
6430
6431    /// Subscribe to edge-fire and edge-retract events for **all** rules.
6432    ///
6433    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6434    /// as-of instances never commit, so `distribute_events` never runs and the
6435    /// subscription would never deliver events.
6436    pub fn subscribe_all_rules(&mut self) -> core_storage::Result<Subscription> {
6437        if self.read_only {
6438            return Err(core_storage::GraphError::ReadOnly);
6439        }
6440        let inner = SubInner::new(self.sub_capacity());
6441        self.subscriptions.push(SubEntry {
6442            filter: SubFilter::AllRules,
6443            inner: std::sync::Arc::downgrade(&inner),
6444        });
6445        self.engine.set_emit_deltas(true);
6446        Ok(Subscription(inner))
6447    }
6448
6449    /// Subscribe to write events: node insert/delete, prop set/remove.
6450    ///
6451    /// Does not include edge-fire / edge-retract (rule-derived edge events).
6452    ///
6453    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6454    /// as-of instances never commit, so `distribute_events` never runs and the
6455    /// subscription would never deliver events.
6456    pub fn subscribe_writes(&mut self) -> core_storage::Result<Subscription> {
6457        if self.read_only {
6458            return Err(core_storage::GraphError::ReadOnly);
6459        }
6460        let inner = SubInner::new(self.sub_capacity());
6461        self.subscriptions.push(SubEntry {
6462            filter: SubFilter::Writes,
6463            inner: std::sync::Arc::downgrade(&inner),
6464        });
6465        self.engine.set_emit_deltas(true);
6466        Ok(Subscription(inner))
6467    }
6468
6469    /// Subscribe to incremental Cypher query results.
6470    ///
6471    /// Parses and plans `cypher`; rejects the query if the plan is not in the
6472    /// allowlisted subset (see [`core_query::cypher::is_subscribable`]):
6473    ///   - `MATCH (n:Label) WHERE … RETURN … [LIMIT n]`
6474    ///   - `MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n]`  (exactly one hop)
6475    ///
6476    /// SKIP is not supported — it shifts the result window on every commit,
6477    /// causing spurious Added/Removed churn for rows whose data never changed.
6478    /// Multi-hop Expand chains are not supported; each additional MATCH clause
6479    /// widens scope beyond the documented single-scan / single-hop subset.
6480    ///
6481    /// After each successful commit, the plan is **fully re-executed** and the
6482    /// result is diffed against the previous run. Added rows produce
6483    /// [`DbEvent::QueryRowAdded`]; removed rows produce
6484    /// [`DbEvent::QueryRowRemoved`].
6485    ///
6486    /// **Full re-run per commit; use LIMIT to bound execution cost.**
6487    /// The existing 1 M intermediate-row cap applies. Differential evaluation
6488    /// is roadmap / Phase 5.
6489    ///
6490    /// Returns `Err(GraphError::ReadOnly)` if called on an as-of instance —
6491    /// as-of instances never commit, so `distribute_events` never runs and the
6492    /// subscription would never deliver events.
6493    ///
6494    /// Returns `Err(GraphError::QueryError)` if the query fails to parse, plan,
6495    /// or if the plan shape is not in the allowlist.
6496    pub fn subscribe_query(&mut self, cypher: &str) -> Result<Subscription> {
6497        if self.read_only {
6498            return Err(GraphError::ReadOnly);
6499        }
6500        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
6501            detail: format!("lex: {e}"),
6502        })?;
6503        let ast = parse(&tokens).map_err(|e| GraphError::QueryError {
6504            detail: format!("parse: {e}"),
6505        })?;
6506        let ops = plan(&ast).map_err(|e| GraphError::QueryError {
6507            detail: format!("plan: {e}"),
6508        })?;
6509        if !is_subscribable(&ops) {
6510            return Err(GraphError::QueryError {
6511                detail: "subscribe_query only supports allowlisted plan shapes: \
6512                         MATCH (n:Label) WHERE … RETURN … [LIMIT n] or \
6513                         MATCH (a)-[r:TYPE]->(b) RETURN … [LIMIT n] (exactly one hop). \
6514                         Not supported: multi-hop Expand chains, SKIP (creates \
6515                         unstable offset windows), ORDER BY, DISTINCT, aggregates, \
6516                         variable-length paths, OPTIONAL MATCH, WITH, UNWIND. \
6517                         Use LIMIT to bound re-execution cost."
6518                    .to_string(),
6519            });
6520        }
6521        // Execute once to capture initial state (initial rows are not emitted as
6522        // events — the subscriber learns the baseline via the first query call).
6523        let empty_params = BTreeMap::new();
6524        let initial = execute(&self.view(), &ops, &Params(&empty_params)).map_err(|e| {
6525            GraphError::QueryError {
6526                detail: format!("execute: {e}"),
6527            }
6528        })?;
6529        let columns = initial.columns().to_vec();
6530        let prev_row_map = Self::result_to_row_map(&initial);
6531        let inner = SubInner::new(self.sub_capacity());
6532        // Derive the scan-label sym for the commit-skip fast-path.  Any Expand op
6533        // or unrecognized leading scan → None (always re-execute).
6534        let scan_label = extract_scan_label(&ops, Arc::make_mut(&mut self.syms));
6535        self.query_subscriptions.push(QuerySubEntry {
6536            ops,
6537            columns,
6538            prev_row_map,
6539            inner: std::sync::Arc::downgrade(&inner),
6540            scan_label,
6541        });
6542        Ok(Subscription(inner))
6543    }
6544
6545    /// Queue capacity used for new subscriptions.
6546    fn sub_capacity(&self) -> usize {
6547        self.sub_capacity
6548    }
6549
6550    /// Override per-subscriber queue capacity for subsequently created
6551    /// subscriptions on this db instance.
6552    ///
6553    /// Default is [`DEFAULT_SUB_CAPACITY`] (65,536 events). Use a smaller
6554    /// value in tests to exercise the [`DbEvent::Lagged`] path without
6555    /// generating tens of thousands of events.
6556    ///
6557    /// This is a test-support escape hatch. Calling it in production reduces
6558    /// subscriber reliability (more Lagged events). It is hidden from rustdoc
6559    /// to discourage accidental production use.
6560    #[doc(hidden)]
6561    pub fn set_sub_capacity(&mut self, capacity: usize) {
6562        self.sub_capacity = capacity;
6563    }
6564
6565    // -----------------------------------------------------------------------
6566
6567    /// Start an atomic batch.
6568    ///
6569    /// The returned [`BatchBuilder`] borrows `self` mutably until
6570    /// [`BatchBuilder::commit`]. Builder methods queue ops only — no
6571    /// validation, no WAL I/O. `commit` validates every queued op against
6572    /// live state plus preceding ops in this batch (duplicate key inside
6573    /// the batch is `Err`; an edge between two nodes created earlier in
6574    /// the batch is valid; `delete_node` then insert of the same key is a
6575    /// fresh identity). Validation never mutates the database. Any failure
6576    /// leaves WAL bytes and in-memory state identical to before `commit`.
6577    /// On success, one `WalRecord::Batch` frame is appended (one fsync)
6578    /// and each inner record is applied in order so rules fire per record.
6579    /// An empty batch, or a batch of only no-ops, writes zero WAL bytes.
6580    ///
6581    /// **Rule-window limitation:** batch validation cannot see edges that a
6582    /// rule created earlier in the *same* batch will derive at apply time, so
6583    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
6584    /// where sequential calls would return `Err(RuleOwned)`. State integrity
6585    /// is unaffected (idempotent apply, provenance intact). Create rules in
6586    /// their own batch, or sequentially, when later ops may touch derived
6587    /// edges.
6588    pub fn batch(&mut self) -> BatchBuilder<'_, F> {
6589        BatchBuilder {
6590            db: self,
6591            ops: Vec::new(),
6592        }
6593    }
6594
6595    /// Closure-style atomic write batch.
6596    ///
6597    /// Equivalent to calling [`GraphDb::batch`], invoking `build` to queue ops,
6598    /// then committing. All ops queued inside `build` are validated in order and
6599    /// committed as a single `WalRecord::Batch` frame (one fsync). Rules fire
6600    /// once per inner record, in order, after commit — semantically identical to
6601    /// sequential single-op writes.
6602    ///
6603    /// **Error semantics — validate-then-apply.** `build` queues ops without
6604    /// touching the database. [`BatchBuilder::commit`] validates every op against
6605    /// live state plus earlier ops in this batch before writing anything. If op N
6606    /// fails validation (duplicate key, unknown key, rule-owned edge, …) the
6607    /// entire batch is rejected: no WAL bytes are written and no in-memory state
6608    /// changes. The database is identical to its state before `write_batch` was
6609    /// called.
6610    ///
6611    /// **Atomicity is crash-level, NOT isolation-level.** On replay after a crash,
6612    /// a partial (torn) `Batch` frame applies NONE of its ops — the frame is
6613    /// either fully applied or not at all. However, while applying a committed
6614    /// batch, concurrent readers may observe intermediate states as ops are applied
6615    /// sequentially in memory. There is no interactive transaction isolation in v1.
6616    /// This is documented as "crash-atomic write batches; no interactive
6617    /// transactions or read isolation."
6618    ///
6619    /// **Returns** `(nodes_inserted, edges_inserted)`. An empty or all-noop batch
6620    /// writes zero WAL bytes and returns `(0, 0)`.
6621    ///
6622    /// # Example
6623    ///
6624    /// ```rust,ignore
6625    /// let (nodes, edges) = db.write_batch(|b| {
6626    ///     b.insert_node("Person", "alice", vec![("age".into(), Value::Int(30))]);
6627    ///     b.insert_node("Person", "bob", vec![]);
6628    ///     b.insert_edge("KNOWS", "alice", "bob");
6629    ///     b.set_prop("alice", "role", Value::Str("admin".into()));
6630    ///     b.delete_node("old_key");
6631    /// })?;
6632    /// // One fsync; on crash replay: all five ops land or none do.
6633    /// ```
6634    pub fn write_batch<C>(&mut self, build: C) -> Result<(usize, usize)>
6635    where
6636        C: FnOnce(&mut BatchBuilder<'_, F>),
6637    {
6638        let mut b = self.batch();
6639        build(&mut b);
6640        b.commit()
6641    }
6642
6643    /// Insert `rows` as nodes of `label`. One call is one atomic batch:
6644    /// auto-declared KeyMatch rules (if any) first, then the accepted node
6645    /// inserts, so incremental fire sees the new rules. Per-row key problems
6646    /// are collected in [`IngestReport::row_errors`] and skipped; a commit
6647    /// `Err` means nothing was applied.
6648    ///
6649    /// Auto-FK rule names are `auto_fk_<src_label_lowercase>_<field>` so
6650    /// distinct source labels sharing an FK field each get their own rule.
6651    pub fn ingest(
6652        &mut self,
6653        label: &str,
6654        rows: Vec<BTreeMap<String, Value>>,
6655        opts: &IngestOptions,
6656    ) -> Result<IngestReport> {
6657        self.ingest_with_edges(label, rows, opts, &[])
6658    }
6659
6660    /// [`ingest`] plus user edges in the **same** previewed WAL batch.
6661    /// A failing edge rejects the whole request; nothing is applied.
6662    pub fn ingest_with_edges(
6663        &mut self,
6664        label: &str,
6665        rows: Vec<BTreeMap<String, Value>>,
6666        opts: &IngestOptions,
6667        edges: &[(String, String, String)],
6668    ) -> Result<IngestReport> {
6669        crate::ingest::run(self, label, rows, opts, edges)
6670    }
6671
6672    /// Parse `json` as an array of objects and ingest via [`GraphDb::ingest`].
6673    ///
6674    /// JSON `null` fields are silently omitted (not stored, not a row error).
6675    /// Nested objects and arrays-of-objects are a per-row error (row skipped).
6676    /// Parse failures and a top-level value that is not an array of objects
6677    /// return [`GraphError::IngestError`].
6678    pub fn ingest_json(
6679        &mut self,
6680        label: &str,
6681        json: &str,
6682        opts: &IngestOptions,
6683    ) -> Result<IngestReport> {
6684        crate::ingest::run_json(self, label, json, opts)
6685    }
6686
6687    fn commit_logged_batch(
6688        &mut self,
6689        ops: Vec<BatchOp>,
6690        ingest: Option<(String, usize)>,
6691        // Two-source rule: write_batch_authz threads authz here directly (never
6692        // touches pending_write_authz); query_write_authz sets the field instead
6693        // and passes None.  Only one source is non-None per call.
6694        param_authz: Option<WriteAuthz>,
6695    ) -> Result<BatchOutcome> {
6696        // Read-only guard: catches empty-batch calls before the early-return
6697        // that skips log_then_apply_with, ensuring all mutation entry points fail.
6698        if self.read_only {
6699            return Err(GraphError::ReadOnly);
6700        }
6701        // Ensure provenance is decoded before MutPreview accesses it
6702        // (note_delete_rule / is_rule_owned may call engine.provenance()).
6703        self.engine.ensure_provenance_loaded_mut();
6704
6705        // ── Authz pre-check ──────────────────────────────────────────────────
6706        // Evaluate the decision table per-op BEFORE MutPreview so that a denial
6707        // produces no WAL frame (all-or-nothing at the authz boundary extends
6708        // the existing validate-then-apply contract to role-scope checks).
6709        //
6710        // `batch_created` tracks key→label for nodes created by earlier ops in
6711        // THIS batch, so InsertEdgeUpsert can count same-batch placeholder nodes
6712        // as visible without needing to call `self.ids.get` on not-yet-committed
6713        // keys (they won't be there yet).
6714        //
6715        // Two-source rule: param_authz (write_batch_authz path) takes precedence;
6716        // fall back to self.pending_write_authz (query_write_authz/Cypher path).
6717        // Cloning the field copy avoids a simultaneous borrow of self.ids below.
6718        let authz_opt = param_authz.or_else(|| self.pending_write_authz.clone());
6719        if let Some(ref authz) = authz_opt {
6720            let mut batch_created: BTreeMap<String, String> = BTreeMap::new();
6721            for op in &ops {
6722                self.check_single_op_authz(authz, op, &batch_created)?;
6723                // Update batch_created after a passing authz check so that
6724                // subsequent ops in this batch see the nodes as "about to exist".
6725                match op {
6726                    BatchOp::InsertNode { label, key, .. } => {
6727                        // Only track genuinely new nodes (absent from the
6728                        // snapshot at authz-check time). A pre-existing visible
6729                        // key would be a DuplicateKey — not a real creation —
6730                        // so MutPreview handles it. Letting it into batch_created
6731                        // would allow a later SetProp to bypass update_labels
6732                        // via the "batch-created → always updatable" ruling
6733                        // (delete+recreate exploit, fix for I1 review round 2).
6734                        //
6735                        // Accepted edge: for a delete+recreate-with-different-
6736                        // label batch, node_status resolves the pre-delete
6737                        // (store) label for any subsequent update checks. This
6738                        // grants no net-new capability — a role that can delete+
6739                        // create can already place arbitrary props via
6740                        // InsertNode's own props field.
6741                        if self.ids.get(key.as_str()).is_none() {
6742                            batch_created.insert(key.clone(), label.clone());
6743                        }
6744                    }
6745                    BatchOp::InsertEdgeUpsert {
6746                        placeholder_label,
6747                        src_key,
6748                        dst_key,
6749                        ..
6750                    } => {
6751                        // Both endpoints will be created if not already in store.
6752                        for ep_key in [src_key, dst_key] {
6753                            if self.ids.get(ep_key.as_str()).is_none()
6754                                && !batch_created.contains_key(ep_key.as_str())
6755                            {
6756                                batch_created.insert(ep_key.clone(), placeholder_label.clone());
6757                            }
6758                        }
6759                    }
6760                    _ => {}
6761                }
6762            }
6763        }
6764
6765        let mut outcome = BatchOutcome::default();
6766        let recs = {
6767            let mut preview = MutPreview::new(self);
6768            let mut recs = Vec::with_capacity(ops.len());
6769            // Which node row we are on, counted over the node-insert ops only.
6770            // A caller that queues its rows in order reads this straight back
6771            // as the index into its own list.
6772            let mut node_row = 0usize;
6773            // Every field name the store knows, which a `Replace` needs to work
6774            // out what it removes. Resolved on the first `Replace` in the frame
6775            // and reused, so N replaces read the field list once, not N times.
6776            let mut store_fields: Option<Vec<String>> = None;
6777            // Duplicate inserts this frame has to count, each paired with the
6778            // position in `recs` it belongs at. The count itself is named in the
6779            // dense rewrite and not here: a duplicate's endpoints and edge type
6780            // may all be created by earlier ops in this same frame, and nothing
6781            // in the frame has a dense id yet. See [`PlannedRec`].
6782            let mut deferred_counts: Vec<(usize, String, String, String)> = Vec::new();
6783            for op in ops {
6784                match op {
6785                    BatchOp::InsertNode { label, key, props } => {
6786                        node_row += 1;
6787                        preview.check_insert_node(&key, &props)?;
6788                        preview.note_insert_node(&label, &key, &props);
6789                        recs.push(WalRecord::InsertNode { label, key, props });
6790                    }
6791                    BatchOp::InsertNodeOnConflict {
6792                        label,
6793                        key,
6794                        props,
6795                        on_conflict,
6796                    } => {
6797                        let row = node_row;
6798                        node_row += 1;
6799                        if !preview.has_key(&key) {
6800                            // No conflict: an ordinary insert on any policy —
6801                            // except that a supplied view-owned field is the
6802                            // same mistake here as on a taken key, and gets the
6803                            // same row error rather than a frame error. Without
6804                            // this, one op answered one request two ways
6805                            // depending on whether the store already had the
6806                            // key (defect #19).
6807                            if let Some(why) = preview.supplied_view_owned_prop(&key, &props) {
6808                                outcome.row_errors.push((row, why));
6809                                continue;
6810                            }
6811                            preview.note_insert_node(&label, &key, &props);
6812                            recs.push(WalRecord::InsertNode { label, key, props });
6813                            continue;
6814                        }
6815                        match on_conflict {
6816                            OnConflict::Error => {
6817                                return Err(GraphError::DuplicateKey { key });
6818                            }
6819                            OnConflict::Skip => outcome.skipped += 1,
6820                            OnConflict::Replace => {
6821                                if store_fields.is_none() {
6822                                    store_fields = Some(preview.db.props_view().field_names());
6823                                }
6824                                let fields = store_fields.as_deref().unwrap_or_default();
6825                                match preview.plan_replace(&label, &key, &props, fields) {
6826                                    Ok((writes, kept_view_owned)) => {
6827                                        outcome.kept_view_owned += kept_view_owned;
6828                                        for (field, value) in writes {
6829                                            match value {
6830                                                Some(value) => {
6831                                                    preview.note_set_prop(&key, &field, &value);
6832                                                    recs.push(WalRecord::SetProp {
6833                                                        key: key.clone(),
6834                                                        field,
6835                                                        value,
6836                                                    });
6837                                                }
6838                                                None => {
6839                                                    preview.note_remove_prop(&key, &field);
6840                                                    recs.push(WalRecord::RemoveProp {
6841                                                        key: key.clone(),
6842                                                        field,
6843                                                    });
6844                                                }
6845                                            }
6846                                        }
6847                                        outcome.replaced += 1;
6848                                    }
6849                                    Err(why) => outcome.row_errors.push((row, why)),
6850                                }
6851                            }
6852                        }
6853                    }
6854                    BatchOp::InsertEdge {
6855                        edge_type,
6856                        src_key,
6857                        dst_key,
6858                    } => {
6859                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6860                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6861                            recs.push(WalRecord::InsertEdge {
6862                                edge_type,
6863                                src_key,
6864                                dst_key,
6865                            });
6866                        } else if preview.db.multiplicity {
6867                            // A duplicate inside a batch counts the way a
6868                            // duplicate through `insert_edge` does: `ingest` and
6869                            // Cypher `CREATE` reach this choke-point and not
6870                            // that one, and a count only one entry point keeps
6871                            // would be worse than no count at all.
6872                            //
6873                            // This is the one gate on discriminant 23 from the
6874                            // batch path: a store that never opted in queues
6875                            // nothing here and so writes no such record.
6876                            deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6877                        }
6878                    }
6879                    BatchOp::SetProp { key, field, value } => {
6880                        if let Some(view_name) = preview.db.view_store.view_for_prop(&field) {
6881                            return Err(GraphError::ViewPropReadOnly {
6882                                view_name: view_name.to_string(),
6883                            });
6884                        }
6885                        preview.check_live_key(&key)?;
6886                        preview.note_set_prop(&key, &field, &value);
6887                        recs.push(WalRecord::SetProp { key, field, value });
6888                    }
6889                    BatchOp::RemoveProp { key, field } => {
6890                        if preview.prepare_remove_prop(&key, &field)? {
6891                            preview.note_remove_prop(&key, &field);
6892                            recs.push(WalRecord::RemoveProp { key, field });
6893                        }
6894                    }
6895                    BatchOp::DeleteEdge {
6896                        edge_type,
6897                        src_key,
6898                        dst_key,
6899                    } => {
6900                        if preview.prepare_delete_edge(&edge_type, &src_key, &dst_key)? {
6901                            preview.note_delete_edge(&edge_type, &src_key, &dst_key);
6902                            recs.push(WalRecord::DeleteEdge {
6903                                edge_type,
6904                                src_key,
6905                                dst_key,
6906                            });
6907                        }
6908                    }
6909                    BatchOp::DeleteNode { key } => {
6910                        preview.check_live_key(&key)?;
6911                        preview.note_delete_node(&key);
6912                        recs.push(WalRecord::DeleteNode { key });
6913                    }
6914                    BatchOp::CreateRule(def) => {
6915                        preview.check_create_rule(&def)?;
6916                        let def_bytes =
6917                            bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
6918                                detail: format!("serialize rule: {e}"),
6919                            })?;
6920                        preview.note_create_rule(&def);
6921                        recs.push(WalRecord::CreateRule { def_bytes });
6922                    }
6923                    BatchOp::DeleteRule { name } => {
6924                        preview.check_delete_rule(&name)?;
6925                        preview.note_delete_rule(&name);
6926                        recs.push(WalRecord::DeleteRule { name });
6927                    }
6928                    BatchOp::RenameNode { old_key, new_key } => {
6929                        preview.check_rename_node(&old_key, &new_key)?;
6930                        // A stored `id` that equals the key is the key, written
6931                        // as a property by `upsert_entity`'s create path and by
6932                        // Cypher `CREATE {id: …}`. It moves with the key, in
6933                        // this frame; left behind, `WHERE n.id = '<new key>'`
6934                        // finds nothing (ledger row 72). An `id` holding
6935                        // anything else is the caller's and stays.
6936                        let id_follows = preview.prop_value(&old_key, "id")
6937                            == Some(Value::Str(old_key.clone()))
6938                            && preview.db.view_store.view_for_prop("id").is_none();
6939                        preview.note_rename_node(&old_key, &new_key);
6940                        recs.push(WalRecord::RenameNode {
6941                            old_key,
6942                            new_key: new_key.clone(),
6943                        });
6944                        if id_follows {
6945                            let value = Value::Str(new_key.clone());
6946                            preview.note_set_prop(&new_key, "id", &value);
6947                            recs.push(WalRecord::SetProp {
6948                                key: new_key,
6949                                field: "id".into(),
6950                                value,
6951                            });
6952                        }
6953                    }
6954                    BatchOp::InsertEdgeUpsert {
6955                        edge_type,
6956                        src_key,
6957                        dst_key,
6958                        placeholder_label,
6959                    } => {
6960                        // Auto-create any missing endpoints as plain InsertNode ops.
6961                        // Rules fire and last-change is updated for each created node.
6962                        for key in [&src_key, &dst_key] {
6963                            if !preview.has_key(key) {
6964                                // A placeholder endpoint carries no props, so
6965                                // the view-owned check has nothing to refuse.
6966                                preview.check_insert_node(key, &[])?;
6967                                preview.note_insert_node(&placeholder_label, key, &[]);
6968                                recs.push(WalRecord::InsertNode {
6969                                    label: placeholder_label.clone(),
6970                                    key: key.clone(),
6971                                    props: vec![],
6972                                });
6973                            }
6974                        }
6975                        if preview.prepare_insert_edge(&edge_type, &src_key, &dst_key)? {
6976                            preview.note_insert_edge(&edge_type, &src_key, &dst_key);
6977                            recs.push(WalRecord::InsertEdge {
6978                                edge_type,
6979                                src_key,
6980                                dst_key,
6981                            });
6982                        } else if preview.db.multiplicity {
6983                            // Same choke-point, same gate as `BatchOp::InsertEdge`
6984                            // above: an upsert that finds the pair already there
6985                            // is a duplicate insert and counts as one.
6986                            deferred_counts.push((recs.len(), edge_type, src_key, dst_key));
6987                        }
6988                    }
6989                }
6990            }
6991            // Splice the deferred counts back into the positions they were
6992            // raised at, so a count still sits exactly where the duplicate did
6993            // — before any later op in the frame that deletes the pair.
6994            let mut planned: Vec<PlannedRec> =
6995                Vec::with_capacity(recs.len() + deferred_counts.len());
6996            let mut deferred = deferred_counts.into_iter().peekable();
6997            for (i, rec) in recs.into_iter().enumerate() {
6998                while deferred.peek().is_some_and(|(at, ..)| *at == i) {
6999                    let (_, edge_type, src_key, dst_key) = deferred.next().expect("just peeked");
7000                    planned.push(PlannedRec::DuplicateCount {
7001                        edge_type,
7002                        src_key,
7003                        dst_key,
7004                    });
7005                }
7006                planned.push(PlannedRec::Rec(rec));
7007            }
7008            for (_, edge_type, src_key, dst_key) in deferred {
7009                planned.push(PlannedRec::DuplicateCount {
7010                    edge_type,
7011                    src_key,
7012                    dst_key,
7013                });
7014            }
7015            planned
7016        };
7017        // A frame that is nothing but skips or refused rows writes no WAL, but
7018        // it still has counts to report, so the early returns carry `outcome`
7019        // rather than zeros.
7020        if recs.is_empty() {
7021            return Ok(outcome);
7022        }
7023        // rewrite_wal_dense converts every InsertNode/InsertEdge into its
7024        // *Id form, so only the dense variants can appear in `recs` here.
7025        let recs = self.rewrite_wal_dense_planned(recs)?;
7026        // The rewrite can empty a non-empty batch: a `SET n.ns` naming the
7027        // namespace the node is already in is a no-op and is dropped there. An
7028        // empty `Batch` frame would still take a commit sequence and a WAL
7029        // record, so a batch that turns out to be nothing writes nothing.
7030        if recs.is_empty() {
7031            return Ok(outcome);
7032        }
7033        outcome.nodes_inserted = recs
7034            .iter()
7035            .filter(|r| matches!(r, WalRecord::InsertNodeId { .. }))
7036            .count();
7037        outcome.edges_inserted = recs
7038            .iter()
7039            .filter(|r| matches!(r, WalRecord::InsertEdgeId { .. }))
7040            .count();
7041        // Ingest / write_batch / query_write: one Batch frame, one fsync per call
7042        // under Strict.  Pass self.fsync directly so Strict stays Strict —
7043        // wal_needs_sync(Strict, _) always returns true regardless of op count.
7044        // Mapping Strict → Batched (the prior bug) caused wal_needs_sync to
7045        // short-circuit on single-op batches and silently skip the fsync.
7046        // Batched fsyncs only for multi-op batches; Relaxed always skips.
7047        self.log_then_apply_with(WalRecord::Batch(recs), ingest, self.fsync)?;
7048        Ok(outcome)
7049    }
7050
7051    fn commit_batch(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
7052        self.commit_logged_batch(ops, None, None).map(inserted_pair)
7053    }
7054
7055    /// Commit one submission WITHOUT an fsync — for use inside `commit_group`
7056    /// and the group-commit drain thread, which do a single group fsync later.
7057    fn commit_batch_nosync(&mut self, ops: Vec<BatchOp>) -> Result<(usize, usize)> {
7058        // Restore fsync policy even on panic via a raw-pointer drop guard.
7059        // A panic here would poison the RwLock anyway, but the correct policy
7060        // must be in place if the guard is ever unwrapped.
7061        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
7062        impl Drop for RestoreFsync {
7063            fn drop(&mut self) {
7064                // SAFETY: the pointer is valid for the full duration of
7065                // commit_batch_nosync; the guard is dropped before the frame
7066                // returns, and GraphDb outlives this frame.
7067                unsafe {
7068                    *self.0 = self.1;
7069                }
7070            }
7071        }
7072        let saved = self.fsync;
7073        // SAFETY: raw pointer into self; guard dropped within this frame.
7074        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
7075        self.fsync = FsyncPolicy::Relaxed;
7076        self.commit_logged_batch(ops, None, None).map(inserted_pair)
7077    }
7078
7079    /// Commit multiple op-batches as a **group**: each submission gets its own
7080    /// WAL `Batch` frame, but there is exactly **one** `Fs::sync` for the whole
7081    /// group (under `Strict` / `Batched` policy; `Relaxed` skips all syncs).
7082    ///
7083    /// # Durability semantics
7084    ///
7085    /// A crash before the group fsync may lose **all** submissions in the group.
7086    /// A crash after the group fsync preserves all of them.  No submission is
7087    /// ever torn: each WAL frame is either fully applied on replay or dropped
7088    /// in its entirety (CRC-protected frame boundaries).
7089    ///
7090    /// Events and subscription notifications fire per-submission immediately
7091    /// after apply, which may be before the group fsync.  From a subscriber's
7092    /// perspective this is equivalent to the `Relaxed` durability window.
7093    /// Submitters using [`SharedDb::submit_batch`] only unblock after the group
7094    /// fsync, so from their perspective durability is fully guaranteed.
7095    ///
7096    /// # MVCC interplay
7097    ///
7098    /// Each submission records its own `CommitDelta`; the fold-every-K counter
7099    /// increments per submission (not per group), preserving existing reader
7100    /// snapshot semantics.
7101    ///
7102    /// # Returns
7103    ///
7104    /// One `Result<(nodes_inserted, edges_inserted)>` per input group element,
7105    /// in order.  Failures are per-submission (validation errors); the group
7106    /// fsync error (if any) is returned as the second tuple element.
7107    pub fn commit_group(
7108        &mut self,
7109        groups: Vec<Vec<BatchOp>>,
7110    ) -> (Vec<Result<(usize, usize)>>, Option<GraphError>) {
7111        let mut results = Vec::with_capacity(groups.len());
7112        for ops in groups {
7113            results.push(self.commit_batch_nosync(ops));
7114        }
7115        let any_ok = results.iter().any(|r| r.is_ok());
7116        let sync_err = if self.fsync != FsyncPolicy::Relaxed && any_ok {
7117            self.fs
7118                .sync(core_storage::fs::FileId::Wal)
7119                .map_err(GraphError::Io)
7120                .err()
7121        } else {
7122            None
7123        };
7124        (results, sync_err)
7125    }
7126
7127    /// Like [`commit_group`] but skips the group fsync entirely.
7128    ///
7129    /// Used by the drain thread to apply submissions under the write lock and
7130    /// then perform the single fsync OUTSIDE the lock (via
7131    /// `core_storage::sync_wal_at`), reducing the write-lock hold time visible
7132    /// to concurrent readers.
7133    pub fn commit_group_nosync(
7134        &mut self,
7135        groups: Vec<Vec<BatchOp>>,
7136    ) -> Vec<Result<(usize, usize)>> {
7137        let mut results = Vec::with_capacity(groups.len());
7138        for ops in groups {
7139            results.push(self.commit_batch_nosync(ops));
7140        }
7141        results
7142    }
7143
7144    pub fn insert_node(
7145        &mut self,
7146        label: &str,
7147        key: &str,
7148        props: Vec<(String, Value)>,
7149    ) -> Result<()> {
7150        if self.read_only {
7151            return Err(GraphError::ReadOnly);
7152        }
7153        MutPreview::new(self).check_insert_node(key, &props)?;
7154        self.log_dense(vec![WalRecord::InsertNode {
7155            label: label.into(),
7156            key: key.into(),
7157            props,
7158        }])
7159    }
7160
7161    /// Insert a user edge. `Ok(true)` when the pair was new, `Ok(false)` when it
7162    /// was already there — the question is "was this pair new", and a duplicate
7163    /// does not make it so.
7164    ///
7165    /// On a store that called [`enable_multiplicity`](Self::enable_multiplicity)
7166    /// a duplicate is no longer a total no-op: it raises the pair's insert count
7167    /// (§5.13). Adjacency is still a set, so [`degree`](Self::degree) is
7168    /// unchanged and the return value is still `Ok(false)`; the count is visible
7169    /// only through [`degree_multiplicity`](Self::degree_multiplicity) and the
7170    /// reserved [`EDGE_COUNT_PROP`]. On every other store a duplicate writes
7171    /// nothing at all, as it always has.
7172    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7173        if self.read_only {
7174            return Err(GraphError::ReadOnly);
7175        }
7176        if !MutPreview::new(self).prepare_insert_edge(edge_type, src_key, dst_key)? {
7177            // The pair exists. The only thing left to record is that it was
7178            // asked for again, and only where the store asked to be told.
7179            if let Some(rec) = self.edge_count_record(edge_type, src_key, dst_key) {
7180                self.log_then_apply(rec)?;
7181            }
7182            return Ok(false);
7183        }
7184        self.log_dense(vec![WalRecord::InsertEdge {
7185            edge_type: edge_type.into(),
7186            src_key: src_key.into(),
7187            dst_key: dst_key.into(),
7188        }])?;
7189        Ok(true)
7190    }
7191
7192    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> Result<()> {
7193        if self.read_only {
7194            return Err(GraphError::ReadOnly);
7195        }
7196        if let Some(view_name) = self.view_store.view_for_prop(field) {
7197            return Err(GraphError::ViewPropReadOnly {
7198                view_name: view_name.to_string(),
7199            });
7200        }
7201        MutPreview::new(self).check_live_key(key)?;
7202        self.log_dense(vec![WalRecord::SetProp {
7203            key: key.into(),
7204            field: field.into(),
7205            value,
7206        }])
7207    }
7208
7209    /// Set several properties on one live node in a single WAL commit.
7210    ///
7211    /// Every per-property check [`set_prop`](Self::set_prop) runs — view-owned
7212    /// names, live key, the `ns` immutability rule and its type — is evaluated
7213    /// for the whole list before any record is logged. The first refusal
7214    /// returns and the node is unchanged. An empty list writes nothing.
7215    pub fn set_props(&mut self, key: &str, props: Vec<(String, Value)>) -> Result<()> {
7216        if self.read_only {
7217            return Err(GraphError::ReadOnly);
7218        }
7219        MutPreview::new(self).check_live_key(key)?;
7220        for (field, _) in &props {
7221            if let Some(view_name) = self.view_store.view_for_prop(field) {
7222                return Err(GraphError::ViewPropReadOnly {
7223                    view_name: view_name.to_string(),
7224                });
7225            }
7226        }
7227        if props.is_empty() {
7228            return Ok(());
7229        }
7230        self.write_batch(|b| {
7231            for (field, value) in props {
7232                b.set_prop(key, &field, value);
7233            }
7234        })
7235        .map(|_| ())
7236    }
7237
7238    /// Remove a property. Returns `Ok(false)` (and does not log) if the field
7239    /// is already absent. Unknown or tombstoned keys are `Err(KeyNotFound)`.
7240    /// A field a view owns is `Err(ViewPropReadOnly)` — stated once, in
7241    /// [`MutPreview::prepare_remove_prop`], so that the batch ops reaching that
7242    /// same choke-point cannot miss it.
7243    pub fn remove_prop(&mut self, key: &str, field: &str) -> Result<bool> {
7244        if self.read_only {
7245            return Err(GraphError::ReadOnly);
7246        }
7247        if !MutPreview::new(self).prepare_remove_prop(key, field)? {
7248            return Ok(false);
7249        }
7250        self.log_then_apply(WalRecord::RemoveProp {
7251            key: key.into(),
7252            field: field.into(),
7253        })?;
7254        Ok(true)
7255    }
7256
7257    /// Delete a user edge. Returns `Ok(false)` (and does not log) if the edge
7258    /// is absent. Unknown keys are `Err(KeyNotFound)`. Rule-owned edges — in
7259    /// provenance, or a pair a live rule would derive — are `Err(RuleOwned)`
7260    /// (the rule would just put the edge back; delete or change the rule).
7261    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
7262        if self.read_only {
7263            return Err(GraphError::ReadOnly);
7264        }
7265        if !MutPreview::new(self).prepare_delete_edge(edge_type, src_key, dst_key)? {
7266            return Ok(false);
7267        }
7268        self.log_then_apply(WalRecord::DeleteEdge {
7269            edge_type: edge_type.into(),
7270            src_key: src_key.into(),
7271            dst_key: dst_key.into(),
7272        })?;
7273        Ok(true)
7274    }
7275
7276    /// Delete a live node. Unknown or already-tombstoned keys are
7277    /// `Err(KeyNotFound)` and are not logged. Validation runs before the WAL
7278    /// write; `apply` of a logged `DeleteNode` for an already-tombstoned key
7279    /// (crash window) is a clean no-op.
7280    ///
7281    /// Returns a [`DeleteReport`] with counts of manual and derived edges
7282    /// removed (computed from live state before the deletion is applied).
7283    pub fn delete_node(&mut self, key: &str) -> Result<DeleteReport> {
7284        if self.read_only {
7285            return Err(GraphError::ReadOnly);
7286        }
7287        // Provenance must be loaded before we query provenance_touching.
7288        self.engine.ensure_provenance_loaded_mut();
7289        let id = self
7290            .ids
7291            .get(key)
7292            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
7293
7294        // Count edges before the delete is applied so we can report counts.
7295        let derived_set: BTreeSet<(u32, u32, u32)> = self
7296            .engine
7297            .provenance_touching(id)
7298            .map(|(_, etype, src, dst)| (etype, src, dst))
7299            .collect();
7300        let derived_edges = derived_set.len() as u64;
7301
7302        let mut total_topo = 0u64;
7303        let tv = self.topo_view();
7304        for et in tv.etypes() {
7305            total_topo += tv.neighbors(et, Direction::Out, id).len() as u64
7306                + tv.neighbors(et, Direction::In, id).len() as u64;
7307        }
7308        // For symmetric rules (e.g. Overlap), a→b and b→a are two separate directed
7309        // triples in both the topo scan (Out and In from id) and in provenance_touching.
7310        // The subtraction remains correct because both counts include both directions.
7311        let manual_edges = total_topo.saturating_sub(derived_edges);
7312
7313        self.log_then_apply(WalRecord::DeleteNode { key: key.into() })?;
7314        Ok(DeleteReport {
7315            manual_edges,
7316            derived_edges,
7317        })
7318    }
7319
7320    /// Rename a live node's key.  The dense id (and therefore all edges,
7321    /// props, history, and last-change tracking) is unaffected.
7322    ///
7323    /// A stored `id` property that equals `old` is rewritten to `new` in the
7324    /// same commit, so `n.id` and `key(n)` keep agreeing; an `id` holding any
7325    /// other value, and a node that stores none, are left as they are.
7326    ///
7327    /// Returns `Err(KeyNotFound)` if `old` is not a live key.
7328    /// Returns `Err(DuplicateKey)` if `new` is already live.
7329    pub fn rename_node(&mut self, old: &str, new: &str) -> Result<()> {
7330        if self.read_only {
7331            return Err(GraphError::ReadOnly);
7332        }
7333        // The `id` rewrite lives in the batch arm, which HTTP reaches without
7334        // this function. A node with no such `id` keeps the bare frame.
7335        if self.get_prop(old, "id") == Some(Value::Str(old.to_string())) {
7336            return self
7337                .commit_batch(vec![BatchOp::RenameNode {
7338                    old_key: old.into(),
7339                    new_key: new.into(),
7340                }])
7341                .map(|_| ());
7342        }
7343        MutPreview::new(self).check_rename_node(old, new)?;
7344        self.log_then_apply(WalRecord::RenameNode {
7345            old_key: old.into(),
7346            new_key: new.into(),
7347        })
7348    }
7349
7350    /// Return the IVF drift counter for the dst-side candidate index of `rule`.
7351    /// `None` if the rule does not exist or is not approximate.
7352    ///
7353    /// The drift counter increments on IVF insert/remove after the last fit.
7354    /// When dst-side drift exceeds [`core_rules::IVF_DRIFT_REBUILD`], apply
7355    /// WAL-logs `RebuildRule` as a second commit (rebuild resets the counter).
7356    pub fn ivf_dst_drift(&self, rule: &str) -> Option<u64> {
7357        // SideIvfExport = (centroids, node→cluster, drift)
7358        self.engine
7359            .export_ivf_state()
7360            .remove(rule)
7361            .map(|(_src, dst)| dst.2)
7362    }
7363
7364    /// Validate and WAL-log a new rule, then backfill derived edges inside apply.
7365    /// Validation and duplicate-name check run before logging so invalid rules
7366    /// never enter the WAL.
7367    pub fn create_rule(&mut self, def: RuleDef) -> Result<()> {
7368        if self.read_only {
7369            return Err(GraphError::ReadOnly);
7370        }
7371        MutPreview::new(self).check_create_rule(&def)?;
7372        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7373            detail: format!("serialize rule: {e}"),
7374        })?;
7375        self.log_dense(vec![WalRecord::CreateRule { def_bytes }])
7376    }
7377
7378    /// Override this handle's HNSW build-slice size, or `None` to restore
7379    /// [`core_rules::HNSW_BUILD_BATCH`].
7380    ///
7381    /// Exposed for tests that need a small slice without a large corpus; not
7382    /// part of the stable surface.
7383    #[doc(hidden)]
7384    pub fn set_hnsw_build_batch(&mut self, batch: Option<usize>) {
7385        self.engine.set_hnsw_build_batch(batch);
7386    }
7387
7388    /// Rules whose vector index is still being built, in name order.
7389    ///
7390    /// The same list [`GraphDb::stats`] reports per rule in `building`.
7391    /// After a clean open this includes a build a snapshot cut short, so
7392    /// `serve`'s ticker can pump it without a write.
7393    pub fn builds_in_progress(&self) -> Vec<BuildProgress> {
7394        self.engine.builds_in_progress()
7395    }
7396
7397    /// Advance any vector index still building and backfill each rule that
7398    /// finishes. Returns what is still outstanding.
7399    ///
7400    /// A map lookup when nothing is pending, so it is cheap to call on a timer.
7401    /// One write lock and at most [`core_rules::HNSW_BUILD_BATCH`] vector
7402    /// inserts per pending rule per call, so a caller can drive a large build
7403    /// to completion without ever holding the lock for more than a slice.
7404    ///
7405    /// A rule that finishes here is backfilled through the same
7406    /// `WalRecord::RebuildRule` second commit that IVF drift already uses, so
7407    /// its derived edges are produced by [`GraphDb::rebuild_rule`]'s code path
7408    /// and appear all at once.
7409    ///
7410    /// Every ordinary write pumps one slice on its own (see the post-commit
7411    /// hook in `log_then_apply_with`), so this is for quiescent stores and for
7412    /// operators who want the build finished before traffic arrives.
7413    pub fn pump_index_build(&mut self) -> Result<Vec<BuildProgress>> {
7414        Ok(self.pump_index_build_reporting()?.1)
7415    }
7416
7417    /// [`GraphDb::pump_index_build`], also reporting the builds that **this**
7418    /// call finished, so a progress display can say so.
7419    ///
7420    /// A build can be registered and completed inside a single call — that is
7421    /// what a mid-build snapshot looks like on reopen, where the index scan
7422    /// finishes the graph and only the backfill is outstanding — and the
7423    /// outstanding list alone cannot show that anything happened.
7424    pub fn pump_index_build_reporting(
7425        &mut self,
7426    ) -> Result<(Vec<BuildProgress>, Vec<BuildProgress>)> {
7427        // A read-only handle cannot issue the `RebuildRule` a finished build
7428        // needs, so it would advance the index and then silently fail to
7429        // produce the edges. Refusing is the honest answer.
7430        if self.read_only {
7431            return Err(GraphError::ReadOnly);
7432        }
7433        let finished = self.pump_one_slice();
7434        for done in &finished {
7435            // The index is whole but the rule still owns no edges. A failed
7436            // second commit must leave the rule re-pumpable rather than
7437            // silently edge-less, so the error is surfaced here — unlike the
7438            // post-commit hook, this call is not riding someone else's commit.
7439            self.log_then_apply(WalRecord::RebuildRule {
7440                name: done.rule.clone(),
7441            })?;
7442        }
7443        Ok((finished, self.engine.builds_in_progress()))
7444    }
7445
7446    /// Run the deferred candidate-index build, if it is still owed, against the
7447    /// graph as it stands *now* — before the caller applies anything.
7448    ///
7449    /// A no-op bool test once the indexes are populated, which is after the
7450    /// first write of the handle's life, and for a store with no rules at all.
7451    fn populate_indexes_before_write(&mut self) {
7452        if !self.engine.needs_index_population() {
7453            return;
7454        }
7455        // The retained snapshot blobs arrive with the V8 base sections; without
7456        // them the scan would rebuild every graph the snapshot already holds.
7457        self.ensure_v8_base_sections_loaded();
7458        if !self.engine.needs_index_population() {
7459            return;
7460        }
7461        let mut eng = std::mem::take(&mut self.engine);
7462        {
7463            let gm = make_graph_mut(
7464                &self.ids,
7465                Arc::make_mut(&mut self.syms),
7466                &self.labels,
7467                build_props_view(&self.props, &self.base),
7468                Arc::make_mut(&mut self.topo),
7469                &self.base,
7470                Arc::make_mut(&mut self.edge_props),
7471            );
7472            eng.populate_indexes(&gm);
7473        }
7474        self.engine = eng;
7475    }
7476
7477    /// One slice of build work for every pending rule. Returns the rules whose
7478    /// index just became whole, which the caller must `RebuildRule`.
7479    ///
7480    /// Goes through the engine even with nothing pending when the indexes have
7481    /// not been populated yet: that call adopts the persisted graphs and, for
7482    /// an incomplete blob already registered at open, leaves the remainder to
7483    /// this slice rather than inserting it inline.
7484    fn pump_one_slice(&mut self) -> Vec<BuildProgress> {
7485        // The retained snapshot blobs — and the id count an interrupted build
7486        // is recognised against — arrive with the V8 base sections, which a
7487        // clean open reads lazily. Without this a freshly opened handle pumps
7488        // against empty retained state and concludes there is nothing to do,
7489        // which is precisely the store `build-index` exists for.
7490        self.ensure_v8_base_sections_loaded();
7491        let mut eng = std::mem::take(&mut self.engine);
7492        let finished = {
7493            let mut gm = make_graph_mut(
7494                &self.ids,
7495                Arc::make_mut(&mut self.syms),
7496                &self.labels,
7497                build_props_view(&self.props, &self.base),
7498                Arc::make_mut(&mut self.topo),
7499                &self.base,
7500                Arc::make_mut(&mut self.edge_props),
7501            );
7502            eng.pump_index_build(&mut gm)
7503        };
7504        self.engine = eng;
7505        finished
7506    }
7507
7508    /// Register a sliced build a snapshot cut short, from blobs with
7509    /// `complete == false`.
7510    ///
7511    /// Peeks the V8 mmap for incomplete entries without copying complete
7512    /// graphs. V5–V7 already hold the blobs in the engine from restore.
7513    fn register_outstanding_index_builds(&mut self) {
7514        if self.engine.indexes_populated() {
7515            return;
7516        }
7517        let extra = self.collect_incomplete_hnsw_blobs();
7518        let mut eng = std::mem::take(&mut self.engine);
7519        {
7520            let gm = make_graph_mut(
7521                &self.ids,
7522                Arc::make_mut(&mut self.syms),
7523                &self.labels,
7524                build_props_view(&self.props, &self.base),
7525                Arc::make_mut(&mut self.topo),
7526                &self.base,
7527                Arc::make_mut(&mut self.edge_props),
7528            );
7529            eng.register_incomplete_hnsw_builds(&extra, &gm);
7530        }
7531        self.engine = eng;
7532    }
7533
7534    /// Incomplete `(src, dst)` HNSW blobs from the V8 mmap, copied only when
7535    /// `complete` is false. Empty when there is no mmap base (V5–V7 uses the
7536    /// engine's retained map instead).
7537    fn collect_incomplete_hnsw_blobs(&self) -> BTreeMap<String, (Vec<u8>, Vec<u8>)> {
7538        let Some(base) = &self.base else {
7539            return BTreeMap::new();
7540        };
7541        let Ok(archived) = base.hnsw_section() else {
7542            return BTreeMap::new();
7543        };
7544        archived
7545            .rules
7546            .iter()
7547            .filter_map(|e| {
7548                let src = e.src_blob.as_slice();
7549                let dst = e.dst_blob.as_slice();
7550                if core_rules::hnsw::hnsw_blob_complete(src) == Some(false)
7551                    || core_rules::hnsw::hnsw_blob_complete(dst) == Some(false)
7552                {
7553                    Some((e.name.as_str().to_string(), (src.to_vec(), dst.to_vec())))
7554                } else {
7555                    None
7556                }
7557            })
7558            .collect()
7559    }
7560
7561    /// WAL-log rule deletion. Returns RuleNotFound if the rule does not exist.
7562    pub fn delete_rule(&mut self, name: &str) -> Result<()> {
7563        if self.read_only {
7564            return Err(GraphError::ReadOnly);
7565        }
7566        MutPreview::new(self).check_delete_rule(name)?;
7567        self.log_then_apply(WalRecord::DeleteRule { name: name.into() })
7568    }
7569
7570    /// Return a snapshot of all registered rules.
7571    pub fn rules(&self) -> Vec<RuleDef> {
7572        self.engine.rules().cloned().collect()
7573    }
7574
7575    // -----------------------------------------------------------------------
7576    // Rule suggestion API
7577    // -----------------------------------------------------------------------
7578
7579    /// Profile the database and suggest linking rules with previewed edge counts.
7580    ///
7581    /// Uses the default seed ([`core_rules::SUGGEST_DEFAULT_SEED`]) for deterministic
7582    /// sampling. Suggestions are sorted by estimated edge count (descending).
7583    /// **NO auto-accept** — call [`GraphDb::create_rule`] explicitly to apply.
7584    pub fn suggest_rules(&self) -> Vec<core_rules::RuleSuggestion> {
7585        self.suggest_rules_seeded(core_rules::SUGGEST_DEFAULT_SEED)
7586    }
7587
7588    /// Like [`suggest_rules`] but with a caller-supplied RNG seed for
7589    /// reproducibility. Same seed + same data = identical output.
7590    pub fn suggest_rules_seeded(&self, seed: u64) -> Vec<core_rules::RuleSuggestion> {
7591        self.suggest_rules_with_config(&core_rules::suggest::SuggestConfig::default(), seed)
7592            .suggestions
7593    }
7594
7595    /// [`suggest_rules_seeded`] with a fully custom [`SuggestConfig`].
7596    ///
7597    /// Returns a [`core_rules::SuggestReport`] that includes both the candidate list
7598    /// and a `truncated` flag indicating whether the global budget fired before all
7599    /// candidates were evaluated.
7600    pub fn suggest_rules_with_config(
7601        &self,
7602        config: &core_rules::suggest::SuggestConfig,
7603        seed: u64,
7604    ) -> core_rules::SuggestReport {
7605        use std::collections::BTreeMap;
7606
7607        // Collect (node_id, key) pairs per label, skipping tombstoned nodes.
7608        let mut label_nodes: BTreeMap<String, Vec<(u32, String)>> = BTreeMap::new();
7609        for id in 0..self.ids.len() as u32 {
7610            let Some(key) = self.ids.key_of(id) else {
7611                continue;
7612            };
7613            let Some(&sym) = self.labels.get(id as usize) else {
7614                continue;
7615            };
7616            if sym == u32::MAX {
7617                continue; // tombstoned
7618            }
7619            let Some(label) = self.syms.resolve(sym) else {
7620                continue;
7621            };
7622            label_nodes
7623                .entry(label.to_string())
7624                .or_default()
7625                .push((id, key.to_string()));
7626        }
7627
7628        let existing = self.rules();
7629        let pv = build_props_view(&self.props, &self.base);
7630        let all_fields: Vec<String> = pv.field_names();
7631
7632        core_rules::suggest::suggest_rules(
7633            &label_nodes,
7634            &|id, field| pv.get(id, field).map(|vr| vr.into_value()),
7635            &all_fields,
7636            &existing,
7637            config,
7638            seed,
7639        )
7640    }
7641
7642    /// Recompute a rule's derived edges from scratch. WAL-logged so un-trip
7643    /// plus later mutations replay identically (rebuild is a pure function
7644    /// of state).
7645    ///
7646    /// Only exit from the tripped latch: if the full desired set fits the
7647    /// budget, it is applied completely and `tripped` clears; if it still
7648    /// exceeds the budget, provenance is left untouched and `tripped` stays
7649    /// true. Counts as a fire evaluation (see [`RuleStats::fires`]).
7650    /// Unknown rule → `RuleNotFound`, nothing logged.
7651    pub fn rebuild_rule(&mut self, name: &str) -> Result<()> {
7652        if self.read_only {
7653            return Err(GraphError::ReadOnly);
7654        }
7655        if !self.engine.rules().any(|r| r.name == name) {
7656            return Err(GraphError::RuleNotFound { name: name.into() });
7657        }
7658        self.log_then_apply(WalRecord::RebuildRule { name: name.into() })
7659    }
7660
7661    // -----------------------------------------------------------------------
7662    // Materialized view API
7663    // -----------------------------------------------------------------------
7664
7665    /// Register a new materialized property view, backfill its values for all
7666    /// existing nodes, and WAL-log the definition.
7667    ///
7668    /// # Errors
7669    /// - `ReadOnly`: called on an as-of instance.
7670    /// - `RuleInvalid`: name collision, view_prop collision, or invalid def.
7671    pub fn create_view(&mut self, def: ViewDef) -> Result<()> {
7672        if self.read_only {
7673            return Err(GraphError::ReadOnly);
7674        }
7675        // Pre-validate before WAL write.
7676        def.validate()
7677            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
7678        if self.view_store.has_view(&def.name) {
7679            return Err(GraphError::RuleInvalid {
7680                detail: format!("view {:?} already exists", def.name),
7681            });
7682        }
7683        if let Some(existing) = self.view_store.view_for_prop(&def.view_prop) {
7684            return Err(GraphError::RuleInvalid {
7685                detail: format!(
7686                    "view_prop {:?} is already used by view {:?}",
7687                    def.view_prop, existing
7688                ),
7689            });
7690        }
7691        // A view cannot take the name of a property nodes already carry —
7692        // in the snapshot or written since. Refused here, before the WAL
7693        // write: `apply` refuses some of it too, but by then the record is
7694        // logged, and the next open replays it and fails.
7695        //
7696        // "Carry" is a node that reads a value under the name. The column
7697        // alone does not count: a snapshot keeps the column of a view that
7698        // has since been deleted, with every value in it hidden, and that
7699        // name is free.
7700        let taken = {
7701            let pv = build_props_view(&self.props, &self.base);
7702            pv.field_names().contains(&def.view_prop)
7703                && (0..self.ids.len() as u32)
7704                    .any(|id| !self.ids.is_tombstoned(id) && pv.get(id, &def.view_prop).is_some())
7705        };
7706        if taken {
7707            return Err(GraphError::RuleInvalid {
7708                detail: format!(
7709                    "view_prop {:?} conflicts with an existing node property",
7710                    def.view_prop
7711                ),
7712            });
7713        }
7714        let def_bytes = bincode::serialize(&def).map_err(|e| GraphError::Corrupt {
7715            detail: format!("serialize view: {e}"),
7716        })?;
7717        // Enable delta accumulation before the view is registered so subsequent
7718        // incremental edge events reach view maintenance from this point onward.
7719        // (The backfill inside create_view reads topo directly; it does not rely
7720        // on pending deltas.)
7721        self.engine.set_emit_deltas(true);
7722        self.log_then_apply(WalRecord::CreateView { def_bytes })
7723    }
7724
7725    /// Remove a named view and delete its values from every node.
7726    ///
7727    /// # Errors
7728    /// - `ReadOnly`: called on an as-of instance.
7729    /// - `RuleNotFound`: view does not exist.
7730    pub fn delete_view(&mut self, name: &str) -> Result<()> {
7731        if self.read_only {
7732            return Err(GraphError::ReadOnly);
7733        }
7734        if !self.view_store.has_view(name) {
7735            return Err(GraphError::RuleNotFound { name: name.into() });
7736        }
7737        let result = self.log_then_apply(WalRecord::DeleteView { name: name.into() });
7738        // After deletion, disable accumulation if no listeners remain.
7739        if !self.needs_emit_deltas() {
7740            self.engine.set_emit_deltas(false);
7741        }
7742        result
7743    }
7744
7745    /// Snapshot of all registered view definitions.
7746    pub fn views(&self) -> Vec<ViewDef> {
7747        self.view_store.views().cloned().collect()
7748    }
7749
7750    // -----------------------------------------------------------------------
7751    // Full-text-lite API
7752    // -----------------------------------------------------------------------
7753
7754    /// Enable full-text indexing for all nodes of `label` on property `field`.
7755    ///
7756    /// After this call, every subsequent write to `(label, field)` is reflected
7757    /// in the index incrementally.  Existing nodes are backfilled immediately.
7758    /// The declaration is persisted as a WAL record. The index is carried by
7759    /// the next snapshot and read from it at open; until then it is rebuilt
7760    /// at open from the stored values.
7761    ///
7762    /// # Errors
7763    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7764    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
7765    pub fn enable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7766        if self.read_only {
7767            return Err(GraphError::ReadOnly);
7768        }
7769        if self.fulltext.is_enabled(label, field) {
7770            return Err(GraphError::RuleInvalid {
7771                detail: format!("full-text index for ({label:?}, {field:?}) already enabled"),
7772            });
7773        }
7774        self.log_then_apply(WalRecord::EnableFulltext {
7775            label: label.into(),
7776            field: field.into(),
7777        })
7778    }
7779
7780    /// Disable full-text indexing for `(label, field)` and drop its postings.
7781    ///
7782    /// # Errors
7783    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
7784    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
7785    pub fn disable_fulltext(&mut self, label: &str, field: &str) -> Result<()> {
7786        if self.read_only {
7787            return Err(GraphError::ReadOnly);
7788        }
7789        if !self.fulltext.is_enabled(label, field) {
7790            return Err(GraphError::RuleNotFound {
7791                name: format!("fulltext({label},{field})"),
7792            });
7793        }
7794        self.log_then_apply(WalRecord::DisableFulltext {
7795            label: label.into(),
7796            field: field.into(),
7797        })
7798    }
7799
7800    /// Whether this handle read its full-text index from the snapshot it
7801    /// opened, and kept it, where it would otherwise have built it from the
7802    /// stored values. False for a store with no snapshot or no declared pair,
7803    /// for a snapshot written before the index was carried, and for an open
7804    /// that had to fall back. The answers are the same either way; this is
7805    /// how a test or an operator tells which open they got.
7806    pub fn fulltext_loaded_from_snapshot(&self) -> bool {
7807        self.fulltext_loaded
7808    }
7809
7810    /// Called by the insert arms of `apply` before they index `field` on
7811    /// `id`. `add_tokens` appends, so a node already indexed under the field
7812    /// has to be cleared first or its old words stay searchable.
7813    ///
7814    /// Live, that is one insert naming a property twice: the last value is
7815    /// what is stored, and it is what is indexed. At open it means the WAL
7816    /// holds a write the loaded index already has; the index is rebuilt after
7817    /// the replay, and clearing one node at a time here would only add a walk
7818    /// of the vocabulary for every such insert, so it is counted and left.
7819    fn unindex_before_reindexing(&mut self, id: u32, field: &str) {
7820        if !self.fulltext.has_doc(id, field) {
7821            return;
7822        }
7823        if self.replaying_at_open {
7824            self.fulltext_reindexed_a_node = true;
7825            self.fulltext_reindexed_at_open += 1;
7826        } else {
7827            Arc::make_mut(&mut self.fulltext).remove_node_field(id, field);
7828        }
7829    }
7830
7831    /// How many times the replay at open indexed a node that was already
7832    /// indexed under that field. Non-zero means the WAL held writes the
7833    /// loaded index already had, and the open rebuilt it. Exposed for tests;
7834    /// not part of the stable surface.
7835    #[doc(hidden)]
7836    pub fn fulltext_reindexed_during_open(&self) -> u64 {
7837        self.fulltext_reindexed_at_open
7838    }
7839
7840    /// Read the full-text index out of the snapshot this handle just mapped,
7841    /// if it carries one that reads back and was built over this id space.
7842    /// Anything else leaves the index empty for the rebuild that follows.
7843    fn load_fulltext_from_base(&mut self) {
7844        let Some(base) = self.base.as_ref() else {
7845            return;
7846        };
7847        // A snapshot that kept its WAL and carries no mark of how much of it
7848        // it holds is replayed over in full. Every insert in that WAL is
7849        // already in the blob, so reading it would end in a rebuild anyway —
7850        // after making each replayed insert tokenize first. With a mark, the
7851        // frames the snapshot holds are not applied again and the index is as
7852        // good as any other snapshot's.
7853        if !self.base_wal_truncated && base.wal_mark().is_none() {
7854            return;
7855        }
7856        let bytes = match base.fulltext_bytes() {
7857            Ok(bytes) if !bytes.is_empty() => bytes,
7858            _ => return,
7859        };
7860        match core_storage::fulltext::FulltextIndex::from_blob(bytes) {
7861            Some((index, node_count)) if node_count as usize == self.ids.len() => {
7862                self.fulltext = Arc::new(index);
7863                self.fulltext_loaded = true;
7864            }
7865            _ => eprintln!(
7866                "mushroomdb: the snapshot's full-text index could not be read; rebuilding it"
7867            ),
7868        }
7869    }
7870
7871    /// The full-text index as the bytes a snapshot carries, or nothing when
7872    /// no pair is declared.
7873    fn fulltext_blob(&self) -> Vec<u8> {
7874        if self.fulltext.enabled_pairs().next().is_none() {
7875            return Vec::new();
7876        }
7877        self.fulltext.to_blob(self.ids.len() as u32)
7878    }
7879
7880    /// Remove every edge the snapshot holds to or from a node that has been
7881    /// deleted.
7882    ///
7883    /// There should be none. Through 0.7.1 a node deleted after a snapshot
7884    /// kept the edges that snapshot held (ledger row 95), and the next
7885    /// snapshot wrote them into its own base, where they answered a
7886    /// neighbour read with `topology id N has no key`. This is what repairs
7887    /// such a store. It asks for the neighbours of every retired id under
7888    /// every edge type, which on a store with half a million deleted nodes is
7889    /// over a second, so it runs only over a snapshot that does not say its
7890    /// writer sweeps them (`MappedBase::edges_swept`). The removal is in
7891    /// memory until the next snapshot, which no longer carries them and says
7892    /// so.
7893    fn drop_edges_of_retired_nodes(&mut self) {
7894        let mut doomed = Vec::new();
7895        {
7896            let tv = self.topo_view();
7897            let etypes: Vec<u32> = tv.etypes().collect();
7898            for n in self.ids.tombstoned_ids() {
7899                for et in &etypes {
7900                    for &dst in tv.neighbors(*et, Direction::Out, n).as_ref() {
7901                        doomed.push((*et, n, dst));
7902                    }
7903                    for &src in tv.neighbors(*et, Direction::In, n).as_ref() {
7904                        if src != n {
7905                            doomed.push((*et, src, n));
7906                        }
7907                    }
7908                }
7909            }
7910        }
7911        for (et, s, d) in doomed {
7912            Arc::make_mut(&mut self.topo).remove_edge(et, s, d);
7913            Arc::make_mut(&mut self.edge_props).remove_edge(et, s, d);
7914            self.view_store.on_edge_changed(
7915                et,
7916                s,
7917                d,
7918                false,
7919                Arc::make_mut(&mut self.props),
7920                &build_topo_view(&self.topo, &self.base),
7921                &self.ids,
7922                &self.syms,
7923                &self.labels,
7924                base_columns(&self.base),
7925            );
7926        }
7927    }
7928
7929    /// What `snapshot.intent` says about the snapshot and the WAL on disk.
7930    ///
7931    /// The first value is the WAL that the snapshot already holds, when the
7932    /// intent names this snapshot and `wal` begins with exactly the WAL it
7933    /// names: a snapshot that was to replace its WAL and did not get that
7934    /// far. The second is `true` when the file holds anything else — an
7935    /// intent left over, which must not outlive this handle's next append.
7936    fn interrupted_snapshot(
7937        &self,
7938        wal: &[u8],
7939    ) -> Result<(Option<core_storage::v8::WalMark>, bool)> {
7940        let (standing, left_over) = self.standing_intent(wal)?;
7941        Ok((standing.map(|i| i.wal), left_over))
7942    }
7943
7944    /// The intent in `snapshot.intent` that is true of the snapshot and the
7945    /// WAL on disk, if one is, and whether the file holds anything that is
7946    /// not.
7947    ///
7948    /// A file that cannot be read is an error, not an absent intent: taking
7949    /// it for absent would replay the WAL over the very snapshot the intent
7950    /// is there to protect.
7951    fn standing_intent(
7952        &self,
7953        wal: &[u8],
7954    ) -> Result<(Option<core_storage::v8::SnapshotIntent>, bool)> {
7955        let bytes = self.fs.read(FileId::SnapshotIntent)?;
7956        if bytes.is_empty() {
7957            return Ok((None, false));
7958        }
7959        // The WAL is in hand, so it is asked first. Only a record whose mark
7960        // it matches is worth reading the whole snapshot for — which leaves a
7961        // left-over intent, the usual kind, costing nothing at each open.
7962        let candidates: Vec<_> = core_storage::v8::SnapshotIntent::decode_all(&bytes)
7963            .into_iter()
7964            .filter(|i| {
7965                i.wal.matches(wal)
7966                    // The mark was taken at the end of a whole WAL.
7967                    && decode_all(&wal[..i.wal.len as usize]).1 as u64 == i.wal.len
7968            })
7969            .collect();
7970        let standing = if candidates.is_empty() {
7971            None
7972        } else {
7973            let snapshot = self.fs.read(FileId::Snapshot)?;
7974            candidates.into_iter().find(|i| i.names(&snapshot))
7975        };
7976        // Anything in the file beyond the one standing intent is left over:
7977        // a record of a snapshot that finished or never landed.
7978        let left_over = bytes.len() != if standing.is_some() { 32 } else { 0 };
7979        Ok((standing, left_over))
7980    }
7981
7982    /// Empty `snapshot.intent` unless it is still true. Called with the write
7983    /// lock held, before an append.
7984    fn settle_snapshot_intent(&mut self) -> Result<()> {
7985        let wal = self.fs.read(FileId::Wal)?;
7986        let (standing, left_over) = self.standing_intent(&wal)?;
7987        if left_over {
7988            // Keep the one that is still true, drop the rest.
7989            let keep = standing.map(|i| i.encode()).unwrap_or_default();
7990            self.fs.write_atomic(FileId::SnapshotIntent, &keep)?;
7991        }
7992        self.intent_unsettled = false;
7993        Ok(())
7994    }
7995
7996    /// Make the next snapshot on this handle fail at `at`, leaving the files
7997    /// as a crash there would. For tests; not part of the stable surface.
7998    #[doc(hidden)]
7999    pub fn stop_next_snapshot(&mut self, at: SnapshotStop) {
8000        self.snapshot_stop = Some(at);
8001    }
8002
8003    /// Whether `(label, field)` is currently indexed for full-text search.
8004    pub fn is_fulltext_enabled(&self, label: &str, field: &str) -> bool {
8005        self.fulltext.is_enabled(label, field)
8006    }
8007
8008    /// Every `(label, field)` pair with a live full-text index, sorted.
8009    ///
8010    /// Note that [`GraphDb::search`] is keyed by field alone — a pair only
8011    /// declares which nodes are *indexed*, so callers that want to search
8012    /// everything indexed should query each distinct field once.
8013    pub fn fulltext_pairs(&self) -> Vec<(String, String)> {
8014        let mut v: Vec<(String, String)> = self.fulltext.enabled_pairs().cloned().collect();
8015        v.sort();
8016        v
8017    }
8018
8019    /// Enable an equality index for all nodes of `label` on scalar property
8020    /// `field`. Subsequent `WHERE n.field = value` lookups become O(matches)
8021    /// instead of an O(N_label) scan. Existing nodes are backfilled; the
8022    /// declaration persists via WAL and the postings rebuild on re-open.
8023    ///
8024    /// # Errors
8025    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8026    /// - [`GraphError::RuleInvalid`]: `(label, field)` is already indexed.
8027    pub fn enable_index(&mut self, label: &str, field: &str) -> Result<()> {
8028        if self.read_only {
8029            return Err(GraphError::ReadOnly);
8030        }
8031        if self.prop_index.is_enabled(label, field) {
8032            return Err(GraphError::RuleInvalid {
8033                detail: format!("property index for ({label:?}, {field:?}) already enabled"),
8034            });
8035        }
8036        self.log_then_apply(WalRecord::EnableIndex {
8037            label: label.into(),
8038            field: field.into(),
8039        })
8040    }
8041
8042    /// Disable the equality index for `(label, field)` and drop its postings.
8043    ///
8044    /// # Errors
8045    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8046    /// - [`GraphError::RuleNotFound`]: `(label, field)` is not currently indexed.
8047    pub fn disable_index(&mut self, label: &str, field: &str) -> Result<()> {
8048        if self.read_only {
8049            return Err(GraphError::ReadOnly);
8050        }
8051        if !self.prop_index.is_enabled(label, field) {
8052            return Err(GraphError::RuleNotFound {
8053                name: format!("index({label},{field})"),
8054            });
8055        }
8056        self.log_then_apply(WalRecord::DisableIndex {
8057            label: label.into(),
8058            field: field.into(),
8059        })
8060    }
8061
8062    /// Whether `(label, field)` currently has an equality index.
8063    pub fn is_index_enabled(&self, label: &str, field: &str) -> bool {
8064        self.prop_index.is_enabled(label, field)
8065    }
8066
8067    /// Every `(label, field)` pair with a live equality index, sorted.
8068    ///
8069    /// The enumerator [`is_index_enabled`](Self::is_index_enabled) never had:
8070    /// the MCP `schema` tool lists a store's indexes rather than probing them
8071    /// one guess at a time.
8072    pub fn index_pairs(&self) -> Vec<(String, String)> {
8073        self.prop_index.enabled_pairs().cloned().collect()
8074    }
8075
8076    /// The dense id of a live key, `None` for an unknown or deleted one.
8077    ///
8078    /// Ids are allocated in insertion order and never reused, so the lowest
8079    /// live id among a set of keys is the oldest node — which is how identity
8080    /// resolution picks a canonical node without a timestamp field
8081    /// (`memory::identity`). Crate-private: an id is an engine detail, not
8082    /// something a caller should hold.
8083    pub(crate) fn dense_id(&self, key: &str) -> Option<u32> {
8084        self.ids.get(key)
8085    }
8086
8087    /// Start recording insert-count multiplicity on this store (§5.13).
8088    ///
8089    /// Adjacency stays a set and nothing about an existing read changes: a
8090    /// duplicate [`insert_edge`](Self::insert_edge) still returns `Ok(false)`
8091    /// and still leaves [`degree`](Self::degree) alone. What it gains is that
8092    /// the duplicate is *counted*, as the reserved edge property
8093    /// [`EDGE_COUNT_PROP`], readable through
8094    /// [`degree_multiplicity`](Self::degree_multiplicity).
8095    ///
8096    /// # This is a one-way step, and that is why it is a call
8097    ///
8098    /// The count is durable, so it is written to the WAL — as discriminant 23,
8099    /// which no release before v0.6.10 knows. A reader meeting an unknown WAL
8100    /// discriminant cannot know what the record would have changed, so it
8101    /// cannot degrade the way an unreadable index blob can. **After this call
8102    /// the store can no longer be read by an older binary, and there is no call
8103    /// that undoes it.** Gating the record behind this method is what keeps
8104    /// that step a decision an operator makes when they want the feature,
8105    /// rather than one everybody takes by upgrading.
8106    ///
8107    /// # It fails loudly, and that costs a snapshot
8108    ///
8109    /// An older binary does not refuse discriminant 23 — it truncates the WAL
8110    /// at it and, with `repair_wal`, persists the truncation. So this call also
8111    /// writes a **V10 snapshot**, a version no earlier release knows, and it
8112    /// writes it *first*: the snapshot is read before the WAL, so an older
8113    /// binary stops at `snapshot: unsupported version 10` with the WAL
8114    /// untouched. Taking the snapshot before appending the record is what makes
8115    /// the guard unconditional — the store is never, at any interruption point,
8116    /// carrying the record without the stamp that announces it.
8117    ///
8118    /// The snapshot keeps the WAL (`keep_wal: true`): opting in is not a
8119    /// compaction, and history reachable by [`open_at`](Self::open_at) stays
8120    /// reachable. On a large store the call therefore costs one full snapshot
8121    /// write.
8122    ///
8123    /// # What it costs a store that archives
8124    ///
8125    /// Writing `snapshot.bin` is also how the archive path decides whether the
8126    /// store may have a *genesis chain* — whether `open_at` can replay
8127    /// archive-resident commits from empty state. The rule is conservative: a
8128    /// snapshot that was already on disk might have been a truncating one, and
8129    /// once the handle that took it is gone this binary cannot tell. A
8130    /// `keep_wal` snapshot taken by **this** handle is the case where it can, so
8131    /// opting in and then archiving **in the same session** keeps the chain.
8132    ///
8133    /// Opting in, closing the store, and archiving in a *later* session does
8134    /// not — but that is the answer any store with a prior snapshot gets, not
8135    /// something this call causes. A store that wants the chain should take its
8136    /// first archive in the session that opted in.
8137    ///
8138    /// Calling it on a store that has already opted in writes nothing and
8139    /// returns `Ok(())`: an operator should not have to ask first.
8140    ///
8141    /// # This call is not atomic, and an `Err` does not undo it
8142    ///
8143    /// There is no rollback here, and there never was one. An `Err` means this
8144    /// handle stopped believing the store is opted in — `self.multiplicity` is
8145    /// reset, so this handle reports `false` from then on — and nothing more. It
8146    /// says nothing about what reached disk. Two reachable failures leave the
8147    /// opt-in standing:
8148    ///
8149    /// * **The declaration landed and only its fsync failed.** `log_then_apply`
8150    ///   appends, then syncs; a failed barrier leaves `MULTIPLICITY_ENABLED`
8151    ///   already in `wal.bin`. The next open replays it and the store is opted
8152    ///   in. No archive is involved — this one predates the recovery below.
8153    /// * **The declaration never landed, but the V10 snapshot did, on a store
8154    ///   that already had an archive.** The open-time recovery in
8155    ///   `load_from_disk` reads V10-beside-an-archive as an interrupted archive
8156    ///   sequence and opts the store in.
8157    ///
8158    /// So a failed call may leave the opt-in on disk immediately (the first
8159    /// case) or conjure it at the next open (the second), and nothing puts the
8160    /// store back out. Treat `Err` as "the outcome is unknown", not as "nothing
8161    /// happened".
8162    ///
8163    /// **This is safe, and the ordering is the reason.** The V10 stamp is
8164    /// written *before* the declaration, so every one of these intermediate
8165    /// states is one an older binary refuses by name rather than truncates at.
8166    /// The failure direction costs a refusal, never a commit. That ordering is
8167    /// the property worth protecting, not the atomicity this call never had.
8168    ///
8169    /// **To know where the store stands, ask the store.** Reopen it and call
8170    /// [`is_multiplicity_enabled`](Self::is_multiplicity_enabled); that is the
8171    /// only answer that accounts for what reached disk.
8172    ///
8173    /// The one case that really does leave the store opted out is a failure with
8174    /// no archive present and no record written: a stray V10 snapshot remains,
8175    /// costing an older reader a refusal it did not strictly need, and *that*
8176    /// store's next snapshot rewrites at V9.
8177    ///
8178    /// # Errors
8179    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8180    /// - Anything [`snapshot_with`](Self::snapshot_with) can return, and
8181    ///   anything the WAL append or its fsync can return. See the atomicity
8182    ///   section above for what the store is left holding.
8183    pub fn enable_multiplicity(&mut self) -> Result<()> {
8184        if self.read_only {
8185            return Err(GraphError::ReadOnly);
8186        }
8187        if self.multiplicity {
8188            return Ok(());
8189        }
8190        // The snapshot goes first, and the order is the guard.
8191        //
8192        // An older reader refuses a V10 snapshot by name and stops; it does not
8193        // refuse discriminant 23, it truncates the WAL at it. So the store must
8194        // never hold the record without the snapshot that announces it — not
8195        // even for the width of one fsync. Writing the snapshot before the
8196        // record makes the only reachable intermediate state "V10 snapshot, no
8197        // record", which is merely conservative: this binary reads it as a
8198        // store that has not opted in, and an older one refuses it.
8199        //
8200        // `keep_wal: true` because opting in is not a compaction: an operator
8201        // asking for multiplicity has not asked to lose the history `open_at`
8202        // can reach.
8203        self.multiplicity = true;
8204        let forced = self
8205            .snapshot_with(SnapshotOptions {
8206                keep_wal: true,
8207                ..SnapshotOptions::default()
8208            })
8209            .and_then(|()| self.log_then_apply(core_storage::wal::MULTIPLICITY_ENABLED));
8210        if forced.is_err() {
8211            // This handle stops believing it is opted in. That is all this line
8212            // does — it is not a rollback, and cannot be one: the declaration
8213            // may already be in `wal.bin` (the append succeeded and only the
8214            // fsync failed), and even when it is not, the V10 snapshot beside an
8215            // existing archive is enough for the open-time recovery to opt the
8216            // store in. See the "not atomic" section on this method.
8217            //
8218            // It fails in the safe direction either way: the V10 stamp reached
8219            // disk before anything a v0.6.9 reader would truncate at, so the
8220            // worst an interruption costs that reader is a refusal by name.
8221            self.multiplicity = false;
8222        }
8223        forced
8224    }
8225
8226    /// Whether this store records insert-count multiplicity.
8227    ///
8228    /// `false` on every store that has not called
8229    /// [`enable_multiplicity`](Self::enable_multiplicity) — which is every
8230    /// store that did not ask for it, including one upgraded from an earlier
8231    /// release.
8232    pub fn is_multiplicity_enabled(&self) -> bool {
8233        self.multiplicity
8234    }
8235
8236    /// How many times `(etype, src, dst)` has been inserted: the reserved
8237    /// `count` edge property, or 1 when it is absent.
8238    ///
8239    /// Answers 1 for a pair on a store that never opted in, which is the truth
8240    /// available there — the pair was inserted at least once, and the store
8241    /// kept no record of any second insert.
8242    fn edge_insert_count(&self, etype: u32, src: u32, dst: u32) -> u64 {
8243        match self.edge_props_view().get(etype, src, dst, EDGE_COUNT_PROP) {
8244            Some(Value::Int(n)) if n > 0 => n as u64,
8245            _ => 1,
8246        }
8247    }
8248
8249    /// The `SetEdgeCount` record a duplicate insert of `(edge_type, src_key,
8250    /// dst_key)` should log, or `None` when nothing should be written.
8251    ///
8252    /// `None` when the store has not opted in, so **no discriminant-23 record
8253    /// is written at all** — the gate the whole feature rests on.
8254    ///
8255    /// The other two `None`s are unreachable from the one caller. This is the
8256    /// single-mutation path, where `prepare_insert_edge` has already refused a
8257    /// missing endpoint and an existing pair's edge type is necessarily
8258    /// interned. A batch is the case where a pair's endpoints and type can all
8259    /// be created by the same frame, and it does not come through here: it
8260    /// queues a [`PlannedRec::DuplicateCount`] and names the count in the dense
8261    /// rewrite, which is the only pass that knows the frame's own ids.
8262    fn edge_count_record(
8263        &self,
8264        edge_type: &str,
8265        src_key: &str,
8266        dst_key: &str,
8267    ) -> Option<WalRecord> {
8268        if !self.multiplicity {
8269            return None;
8270        }
8271        let etype = self.syms.get(edge_type)?;
8272        let src = self.ids.get(src_key)?;
8273        let dst = self.ids.get(dst_key)?;
8274        Some(WalRecord::SetEdgeCount {
8275            etype,
8276            src,
8277            dst,
8278            count: self.edge_insert_count(etype, src, dst).saturating_add(1),
8279        })
8280    }
8281
8282    /// Search a full-text-indexed field.
8283    ///
8284    /// Returns `(node_key, match_count)` pairs sorted by match_count descending,
8285    /// ties broken by key (lexicographic).  Tombstoned nodes are excluded.
8286    ///
8287    /// **Query syntax:**
8288    /// - Space-separated terms are AND'd: `"foo bar"` requires both.
8289    /// - `OR` between terms forms disjunction: `"foo OR bar"` matches either.
8290    /// - Trailing `*` on a term is a prefix match: `"rust*"` matches `rustlang`, `rusty`.
8291    /// - `AND` keyword is accepted explicitly and is the default.
8292    /// - Tokenization is unicode-alphanumeric (same as index time); case-insensitive.
8293    ///
8294    /// **Unindexed field:** returns `Ok(vec![])` if `field` is not indexed.
8295    /// Pin: this is the documented, tested, stable behavior for v1.
8296    ///
8297    /// **Memory / performance:** O(postings) lookup; no scan.  The index is
8298    /// in-memory and proportional to total indexed text across all enabled fields.
8299    ///
8300    /// **v2 grammar:** supports `"phrase"`, `-negation`, `prefix*`, `OR`, `AND`.
8301    /// Results are BM25-scored (k1=1.2, b=0.75) and sorted by score descending,
8302    /// key ascending for deterministic tiebreaking.
8303    pub fn search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8304        // Resolve node_ids to keys (excluding tombstones) then re-sort by
8305        // (score DESC, key ASC) to give a deterministic, key-lexicographic
8306        // tiebreak.  FulltextIndex::search sorts by (score DESC, node_id ASC)
8307        // which diverges from key order when nodes were not inserted in key-lex order.
8308        let mut results: Vec<(String, f64)> = self
8309            .fulltext
8310            .search(field, query, 0)
8311            .into_iter()
8312            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
8313            .collect();
8314        results.sort_by(|a, b| {
8315            b.1.partial_cmp(&a.1)
8316                .unwrap_or(std::cmp::Ordering::Equal)
8317                .then(a.0.cmp(&b.0))
8318        });
8319        results
8320    }
8321
8322    /// [`search`](Self::search), stopping at the `k` best hits.
8323    ///
8324    /// Same ranking and the same deterministic tiebreak, but the index drops
8325    /// everything past `k` before any key is resolved, so a caller that wants
8326    /// the top few out of a field that matched thousands does not pay to
8327    /// materialise and re-sort the tail. `k == 0` means no limit, exactly as
8328    /// [`search`](Self::search) behaves.
8329    ///
8330    /// The BM25 scoring itself is not bounded by `k` — every candidate is
8331    /// scored either way — so this trims the resolve and the sort, not the
8332    /// search.
8333    pub fn search_top(&self, field: &str, query: &str, k: usize) -> Vec<(String, f64)> {
8334        // A tombstoned id resolves to nothing, so asking the index for exactly
8335        // `k` could return fewer. Over-fetching a little and truncating after
8336        // the filter keeps the count right without unbounding the call.
8337        let want = if k == 0 { 0 } else { k.saturating_mul(2) };
8338        let mut results: Vec<(String, f64)> = self
8339            .fulltext
8340            .search(field, query, want)
8341            .into_iter()
8342            .filter_map(|(id, score)| self.ids.key_of(id).map(|key| (key.to_string(), score)))
8343            .collect();
8344        results.sort_by(|a, b| {
8345            b.1.partial_cmp(&a.1)
8346                .unwrap_or(std::cmp::Ordering::Equal)
8347                .then(a.0.cmp(&b.0))
8348        });
8349        if k > 0 {
8350            results.truncate(k);
8351        }
8352        results
8353    }
8354
8355    /// Hybrid search: Reciprocal Rank Fusion (RRF) over fulltext + vector results.
8356    ///
8357    /// Takes up to `4*k` fulltext hits for `(text_field, query_text)` and up to
8358    /// `4*k` vector hits for `(vector_field, query_vec, min=0.0)`, then fuses
8359    /// them with RRF using a fixed constant of 60.
8360    ///
8361    /// ```text
8362    /// score(d) = Σ  1 / (60 + rank_i(d))    (rank 1-based per list)
8363    /// ```
8364    ///
8365    /// Returns the top `k` nodes by fused score, ties broken by node key
8366    /// ascending (deterministic).
8367    ///
8368    /// # Vector leg fallback
8369    ///
8370    /// When `query_vec` is empty the vector leg is skipped entirely and
8371    /// results are ranked by the text list alone through the same RRF path
8372    /// (each text result scores `1/(60 + rank)` from that single list).
8373    ///
8374    /// When `label` is `None`, the vector leg **always** returns empty results.
8375    /// Internally `label` is mapped to `""`, which does not match any rule-created
8376    /// HNSW index (all such indexes are keyed to a specific non-empty label), and
8377    /// the brute-force fallback finds no nodes with an empty label.  The fused
8378    /// ranking is therefore text-only in this case.
8379    pub fn search_hybrid(
8380        &self,
8381        text_field: &str,
8382        query_text: &str,
8383        vector_field: &str,
8384        query_vec: &[f64],
8385        label: Option<&str>,
8386        k: usize,
8387    ) -> Vec<(String, f64)> {
8388        self.search_hybrid_inner(
8389            text_field,
8390            query_text,
8391            vector_field,
8392            query_vec,
8393            label,
8394            k,
8395            None,
8396        )
8397    }
8398
8399    /// [`search_hybrid`](Self::search_hybrid) with **each leg** filtered to the
8400    /// mask before the fusion.
8401    ///
8402    /// Filtering the fused list afterwards would quietly return fewer than `k`.
8403    /// Each leg over-fetches `4*k` candidates, so when the visible nodes rank
8404    /// below `4*k` hidden ones neither leg carries them into the fusion at all
8405    /// and the post-filter has nothing left to keep. Filtering first spends the
8406    /// `4*k` on **visible** hits, so a scoped call is as long as the corpus it
8407    /// can see allows.
8408    ///
8409    /// The ranks that enter RRF are therefore the ranks of the visible corpus,
8410    /// not the visible entries of the store-wide ranking. The constant stays 60
8411    /// and the tiebreak stays key-ascending.
8412    #[allow(clippy::too_many_arguments)]
8413    pub fn search_hybrid_scoped(
8414        &self,
8415        text_field: &str,
8416        query_text: &str,
8417        vector_field: &str,
8418        query_vec: &[f64],
8419        label: Option<&str>,
8420        k: usize,
8421        mask: &crate::mask::NodeMask,
8422    ) -> Vec<(String, f64)> {
8423        self.search_hybrid_inner(
8424            text_field,
8425            query_text,
8426            vector_field,
8427            query_vec,
8428            label,
8429            k,
8430            Some(mask),
8431        )
8432    }
8433
8434    /// The body shared by [`search_hybrid`](Self::search_hybrid) and
8435    /// [`search_hybrid_scoped`](Self::search_hybrid_scoped). `mask = None` is
8436    /// the unscoped contract unchanged: the filter below is then a no-op and
8437    /// the vector leg is the same unmasked call it has always been.
8438    #[allow(clippy::too_many_arguments)]
8439    fn search_hybrid_inner(
8440        &self,
8441        text_field: &str,
8442        query_text: &str,
8443        vector_field: &str,
8444        query_vec: &[f64],
8445        label: Option<&str>,
8446        k: usize,
8447        mask: Option<&crate::mask::NodeMask>,
8448    ) -> Vec<(String, f64)> {
8449        use std::collections::HashMap;
8450
8451        const RRF_K: f64 = 60.0;
8452        let pool = 4 * k;
8453
8454        // Accumulate per-node RRF scores.
8455        let mut scores: HashMap<String, f64> = HashMap::new();
8456
8457        // Text leg. The mask bites on the candidates, before `take(pool)`, so
8458        // the over-fetch is a budget of visible hits rather than one a hidden
8459        // prefix can exhaust.
8460        let text_hits = self.search(text_field, query_text);
8461        let visible_text = text_hits
8462            .into_iter()
8463            .filter(|(key, _count)| mask.is_none_or(|m| m.contains_node(self, key)));
8464        for (rank0, (key, _count)) in visible_text.take(pool).enumerate() {
8465            let rank = (rank0 + 1) as f64;
8466            *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8467        }
8468
8469        // Vector leg (skipped when query_vec is empty). The masked variant
8470        // applies the mask before its own k-truncation, for the same reason.
8471        if !query_vec.is_empty() {
8472            // `ExactnessCaller::Hybrid`: the leg is the same one
8473            // `find_similar_vector_masked` runs, but the advice its warning
8474            // gives has to fit *this* signature, which has no `exact`.
8475            let vec_hits = self
8476                .find_similar_vector_as(
8477                    vector_field,
8478                    label,
8479                    query_vec,
8480                    pool,
8481                    0.0,
8482                    mask,
8483                    None,
8484                    false,
8485                    ExactnessCaller::Hybrid,
8486                )
8487                .expect("find_similar_vector_as is infallible without where_");
8488            for (rank0, (key, _sim)) in vec_hits.into_iter().enumerate() {
8489                let rank = (rank0 + 1) as f64;
8490                *scores.entry(key).or_insert(0.0) += 1.0 / (RRF_K + rank);
8491            }
8492        }
8493
8494        // Sort: score DESC, then key ASC for deterministic tie-breaking.
8495        let mut ranked: Vec<(String, f64)> = scores.into_iter().collect();
8496        ranked.sort_by(|a, b| {
8497            b.1.partial_cmp(&a.1)
8498                .unwrap_or(std::cmp::Ordering::Equal)
8499                .then(a.0.cmp(&b.0))
8500        });
8501        ranked.truncate(k);
8502        ranked
8503    }
8504
8505    /// For DST/testing: scratch BM25 search over live nodes without the index.
8506    /// Walks every live node, re-stems field tokens, computes corpus stats, and
8507    /// returns BM25-ranked results.
8508    ///
8509    /// The oracle: the ordered key list of `search(field, q)` must equal that of
8510    /// `scratch_search(field, q)` at every quiescent state.
8511    #[doc(hidden)]
8512    pub fn scratch_search(&self, field: &str, query: &str) -> Vec<(String, f64)> {
8513        use core_storage::fulltext::{parse_query, value_tokens_stemmed_with_positions};
8514        use std::collections::BTreeMap;
8515
8516        let groups = parse_query(query);
8517        if groups.is_empty() {
8518            return vec![];
8519        }
8520
8521        // --- Pass 1: collect all live indexed nodes with stemmed token data ---
8522        struct NodeData {
8523            key: String,
8524            /// stemmed_token → positions (sorted)
8525            tokens: BTreeMap<String, Vec<u32>>,
8526            dl: u32,
8527        }
8528
8529        let mut nodes: Vec<NodeData> = Vec::new();
8530        for id in 0..self.ids.len() as u32 {
8531            let Some(key) = self.ids.key_of(id) else {
8532                continue;
8533            };
8534            let Some(&sym) = self.labels.get(id as usize) else {
8535                continue;
8536            };
8537            if sym == u32::MAX {
8538                continue;
8539            }
8540            let label = match self.syms.resolve(sym) {
8541                Some(l) => l,
8542                None => continue,
8543            };
8544            if !self.fulltext.is_enabled(label, field) {
8545                continue;
8546            }
8547            let Some(value) = self.props_view().get(id, field).map(|vr| vr.into_value()) else {
8548                continue;
8549            };
8550            // Use value_tokens_stemmed_with_positions so list elements are
8551            // separated by POSITION_GAP — identical to the index path, which
8552            // prevents phrase queries from matching across element boundaries.
8553            let stemmed_with_pos = match &value {
8554                Value::Str(_) | Value::List(_) => value_tokens_stemmed_with_positions(&value),
8555                _ => continue,
8556            };
8557            let dl = stemmed_with_pos.len() as u32;
8558            let mut tok_map: BTreeMap<String, Vec<u32>> = BTreeMap::new();
8559            for (tok, pos) in stemmed_with_pos {
8560                tok_map.entry(tok).or_default().push(pos);
8561            }
8562            nodes.push(NodeData {
8563                key: key.to_string(),
8564                tokens: tok_map,
8565                dl,
8566            });
8567        }
8568
8569        if nodes.is_empty() {
8570            return vec![];
8571        }
8572
8573        // --- BM25 corpus stats ---
8574        let n = nodes.len() as f64;
8575        let avg_dl: f64 = nodes.iter().map(|nd| nd.dl as f64).sum::<f64>() / n;
8576        // df per stemmed token across all live indexed nodes.
8577        let mut df_map: BTreeMap<&str, f64> = BTreeMap::new();
8578        for nd in &nodes {
8579            for tok in nd.tokens.keys() {
8580                *df_map.entry(tok.as_str()).or_insert(0.0) += 1.0;
8581            }
8582        }
8583
8584        const K1: f64 = 1.2;
8585        const B: f64 = 0.75;
8586
8587        // --- Pass 2: score each node against each OR-group ---
8588        let mut results: Vec<(String, f64)> = Vec::new();
8589        for nd in &nodes {
8590            let dl = nd.dl as f64;
8591            let mut total_score = 0.0f64;
8592
8593            'group: for group in &groups {
8594                let mut group_score = 0.0f64;
8595
8596                for term in group {
8597                    if term.negated {
8598                        // Negated: if doc has this stemmed token → group fails.
8599                        let present = if term.prefix {
8600                            nd.tokens.keys().any(|t| t.starts_with(term.token.as_str()))
8601                        } else {
8602                            nd.tokens.contains_key(term.token.as_str())
8603                        };
8604                        if present {
8605                            continue 'group;
8606                        }
8607                        continue;
8608                    }
8609                    if term.prefix {
8610                        // Prefix: sum BM25 for all matching stemmed tokens.
8611                        let mut prefix_matched = false;
8612                        for (tok, positions) in &nd.tokens {
8613                            if tok.starts_with(term.token.as_str()) {
8614                                let tf = positions.len() as f64;
8615                                let df = df_map.get(tok.as_str()).copied().unwrap_or(1.0);
8616                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8617                                let tf_norm =
8618                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8619                                group_score += idf * tf_norm;
8620                                prefix_matched = true;
8621                            }
8622                        }
8623                        if !prefix_matched {
8624                            continue 'group;
8625                        }
8626                    } else {
8627                        // term.token is already stemmed by parse_query; use directly.
8628                        match nd.tokens.get(term.token.as_str()) {
8629                            None => continue 'group,
8630                            Some(positions) => {
8631                                let tf = positions.len() as f64;
8632                                let df = df_map.get(term.token.as_str()).copied().unwrap_or(1.0);
8633                                let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
8634                                let tf_norm =
8635                                    tf * (K1 + 1.0) / (tf + K1 * (1.0 - B + B * dl / avg_dl));
8636                                group_score += idf * tf_norm;
8637                            }
8638                        }
8639                    }
8640                }
8641
8642                if group_score > 0.0 {
8643                    total_score += group_score;
8644                }
8645            }
8646
8647            if total_score > 0.0 {
8648                results.push((nd.key.clone(), total_score));
8649            }
8650        }
8651
8652        results.sort_by(|a, b| {
8653            b.1.partial_cmp(&a.1)
8654                .unwrap_or(std::cmp::Ordering::Equal)
8655                .then(a.0.cmp(&b.0))
8656        });
8657        results
8658    }
8659
8660    /// Return the current view-maintained value of `view_prop` for node `key`.
8661    /// Equivalent to `get_prop` but documents that it reads a view-managed column.
8662    pub fn get_view_prop(&self, key: &str, view_prop: &str) -> Option<Value> {
8663        let id = self.ids.get(key)?;
8664        self.props_view()
8665            .get(id, view_prop)
8666            .map(|vr| vr.into_value())
8667    }
8668
8669    /// For testing / DST oracle: scratch recompute of a view value for one node.
8670    ///
8671    /// Returns `None` if the node does not exist, the view does not exist, or
8672    /// the view has no result for the node (e.g. Avg with no qualifying neighbors).
8673    #[doc(hidden)]
8674    pub fn scratch_view_value(&self, key: &str, view_name: &str) -> Option<Value> {
8675        let node = self.ids.get(key)?;
8676        let def = self.view_store.views().find(|v| v.name == view_name)?;
8677        // Use TopologyView so that NeighborAgg sees base + overlay edges
8678        // without materialising a temporary Topology (I1).
8679        let topo_view = self.topo_view();
8680        core_rules::views::compute_view_value(
8681            def,
8682            node,
8683            self.props_view(),
8684            &topo_view,
8685            &self.ids,
8686            &self.syms,
8687            &self.labels,
8688        )
8689    }
8690
8691    // -----------------------------------------------------------------------
8692    // Graph algorithm API
8693    // -----------------------------------------------------------------------
8694
8695    /// Run PageRank over the unified topology (manual + derived edges).
8696    ///
8697    /// Returns a [`PageRankReport`] with scores sorted descending (ties: key
8698    /// ascending).  Set `config.edge_type` to restrict to one edge type.
8699    /// `config.converged` is `true` only when the power iteration converged
8700    /// within `config.max_iters` and within any time budget.
8701    pub fn pagerank(&self, config: &crate::algo::PageRankConfig) -> crate::algo::PageRankReport {
8702        let topo = build_topo_view(&self.topo, &self.base);
8703        let edge_props = self.edge_props_view();
8704        crate::algo::pagerank(
8705            &topo,
8706            &self.ids,
8707            &self.syms,
8708            &self.labels,
8709            &edge_props,
8710            config,
8711        )
8712    }
8713
8714    /// Weakly-connected components over the unified topology (treated as
8715    /// undirected regardless of how edges were inserted).
8716    ///
8717    /// Component IDs are the key of the smallest member in the component
8718    /// (deterministic).  Result sorted by (component_id, key).
8719    pub fn connected_components(&self, config: &crate::algo::WccConfig) -> crate::algo::WccReport {
8720        let topo = build_topo_view(&self.topo, &self.base);
8721        let edge_props = self.edge_props_view();
8722        crate::algo::wcc(
8723            &topo,
8724            &self.ids,
8725            &self.syms,
8726            &self.labels,
8727            &edge_props,
8728            config,
8729        )
8730    }
8731
8732    /// Degree centrality for every live node.
8733    ///
8734    /// `direction`: `AlgoDir::Out` = out-degree, `AlgoDir::In` = in-degree,
8735    /// `AlgoDir::Both` = out + in (total directed degree).
8736    ///
8737    /// For one-shot ranking use this; for a live property updated on every
8738    /// write, create a Degree materialized view instead (see `docs/site/algorithms.md`).
8739    pub fn degree_centrality(
8740        &self,
8741        config: &crate::algo::DegreeConfig,
8742    ) -> crate::algo::DegreeReport {
8743        let topo = build_topo_view(&self.topo, &self.base);
8744        let edge_props = self.edge_props_view();
8745        crate::algo::degree_centrality(
8746            &topo,
8747            &self.ids,
8748            &self.syms,
8749            &self.labels,
8750            &edge_props,
8751            config,
8752        )
8753    }
8754
8755    /// Louvain community detection over the unified topology (undirected).
8756    ///
8757    /// See [`crate::algo::LouvainConfig`] for edge-type/weight/label
8758    /// restriction and [`crate::algo::CommunityReport`] for the shape of the
8759    /// result (communities sorted size-desc, then smallest member key asc).
8760    pub fn communities(&self, config: &crate::algo::LouvainConfig) -> crate::algo::CommunityReport {
8761        let topo = build_topo_view(&self.topo, &self.base);
8762        let edge_props = self.edge_props_view();
8763        crate::algo::louvain(
8764            &topo,
8765            &self.ids,
8766            &self.syms,
8767            &self.labels,
8768            &edge_props,
8769            config,
8770        )
8771    }
8772
8773    /// Write a vector of `(node_key, score)` pairs as `prop_name` on each node,
8774    /// atomically via a single write-batch (one WAL frame, one fsync).
8775    ///
8776    /// # Errors
8777    /// - [`GraphError::ReadOnly`]: called on an as-of instance.
8778    /// - [`GraphError::RuleInvalid`]: `prop_name` is managed by an existing view
8779    ///   (collision check mirrors `create_view`).
8780    /// - [`GraphError::KeyNotFound`]: a key in `scores` does not exist as a live node.
8781    pub fn write_scores(&mut self, prop_name: &str, scores: &[(String, f64)]) -> Result<()> {
8782        if self.read_only {
8783            return Err(GraphError::ReadOnly);
8784        }
8785        // Collision check: refuse if prop_name is view-managed.
8786        if let Some(view_name) = self.view_store.view_for_prop(prop_name) {
8787            return Err(GraphError::RuleInvalid {
8788                detail: format!(
8789                    "prop {:?} is managed by view {:?} and cannot be written as scores",
8790                    prop_name, view_name
8791                ),
8792            });
8793        }
8794        // Refuse if prop_name is a view name itself (confusing namespace collision).
8795        if self.view_store.has_view(prop_name) {
8796            return Err(GraphError::RuleInvalid {
8797                detail: format!(
8798                    "prop_name {:?} collides with an existing view name",
8799                    prop_name
8800                ),
8801            });
8802        }
8803        // Write all scores in a single crash-atomic batch.
8804        self.write_batch(|b| {
8805            for (key, score) in scores {
8806                b.set_prop(key, prop_name, Value::Float(*score));
8807            }
8808        })?;
8809        Ok(())
8810    }
8811
8812    /// Return the value of `field` for the node with key `key`, or `None` if
8813    /// the node or field is absent.  Reads through the overlay-over-base
8814    /// `ColumnsView`, materialising base values on demand (zero heap cost for
8815    /// overlay hits; one clone per base hit).
8816    pub fn get_prop(&self, key: &str, field: &str) -> Option<Value> {
8817        let id = self.ids.get(key)?;
8818        self.props_view().get(id, field).map(|vr| vr.into_value())
8819    }
8820
8821    pub fn has_node(&self, key: &str) -> bool {
8822        self.ids.get(key).is_some()
8823    }
8824
8825    /// One human line for a node: the first of `text`, `summary` or `name` it
8826    /// carries, truncated. Used by the recall digest.
8827    pub fn node_summary_line(&self, key: &str) -> Option<String> {
8828        for field in ["text", "summary", "name"] {
8829            if let Some(Value::Str(s)) = self.get_prop(key, field) {
8830                let s = s.trim();
8831                if !s.is_empty() {
8832                    return Some(s.chars().take(120).collect());
8833                }
8834            }
8835        }
8836        None
8837    }
8838
8839    /// Borrow the raw id map. Used by `NodeMask::from_keys` to resolve keys.
8840    pub(crate) fn ids(&self) -> &IdMap {
8841        &self.ids
8842    }
8843
8844    // -----------------------------------------------------------------------
8845    // Namespaces
8846    // -----------------------------------------------------------------------
8847
8848    /// The index `name` already has in `ns_names`, if any.
8849    fn ns_index_of(&self, name: &str) -> Option<u32> {
8850        self.ns_names
8851            .iter()
8852            .position(|n| n == name)
8853            .map(|i| i as u32)
8854    }
8855
8856    /// The index for `name`, appending it to `ns_names` when it is new.
8857    ///
8858    /// The table holds one entry per distinct namespace in the store — a
8859    /// tenant count, not a node count — so the linear scan is cheaper than a
8860    /// map and keeps `namespaces()` allocation-free of a second index.
8861    fn ns_index_for(&mut self, name: &str) -> u32 {
8862        match self.ns_index_of(name) {
8863            Some(i) => i,
8864            None => {
8865                self.ns_names.push(name.to_string());
8866                (self.ns_names.len() - 1) as u32
8867            }
8868        }
8869    }
8870
8871    /// The namespace name at `idx`, or [`NS_DEFAULT`] for an index this handle
8872    /// does not know (unreachable; the default is the narrowing answer).
8873    fn ns_name(&self, idx: u32) -> &str {
8874        self.ns_names
8875            .get(idx as usize)
8876            .map(String::as_str)
8877            .unwrap_or(NS_DEFAULT)
8878    }
8879
8880    /// The namespace index of dense node `id`, defaulting for an id with no
8881    /// entry (a node inserted before this handle rebuilt the array cannot
8882    /// exist: every insert path maintains it).
8883    fn node_ns_idx(&self, id: u32) -> u32 {
8884        self.node_ns
8885            .get(id as usize)
8886            .copied()
8887            .unwrap_or(NS_DEFAULT_IDX)
8888    }
8889
8890    /// File node `id` under namespace `name`, growing `node_ns` as `labels`
8891    /// grows. Called from `apply` for every node insert, live and replayed.
8892    fn set_node_ns(&mut self, id: u32, name: &str) {
8893        let idx = if name == NS_DEFAULT {
8894            NS_DEFAULT_IDX
8895        } else {
8896            self.ns_index_for(name)
8897        };
8898        if self.node_ns.len() <= id as usize {
8899            self.node_ns.resize(id as usize + 1, NS_DEFAULT_IDX);
8900        }
8901        self.node_ns[id as usize] = idx;
8902    }
8903
8904    /// Rebuild `node_ns` from the `ns` column — one pass, at the end of an
8905    /// open or a reload, after the snapshot is restored and the WAL replayed.
8906    ///
8907    /// A store with no `ns` column reads nothing: the column-name check fails
8908    /// and the vector is filled with one constant.
8909    fn rebuild_node_ns(&mut self) {
8910        let total = self.ids.len();
8911        self.ns_names.truncate(1);
8912        self.node_ns.clear();
8913        self.node_ns.resize(total, NS_DEFAULT_IDX);
8914        let has_ns_column = {
8915            let cv = self.props_view();
8916            cv.field_names().iter().any(|f| f == NS_PROP)
8917        };
8918        if !has_ns_column {
8919            return;
8920        }
8921        // Collected first so the props view is released before `ns_index_for`
8922        // takes `&mut self`.
8923        let named: Vec<(u32, String)> = {
8924            let cv = self.props_view();
8925            (0..total as u32)
8926                .filter_map(|id| match cv.get(id, NS_PROP).map(|vr| vr.into_value()) {
8927                    Some(Value::Str(s)) if s != NS_DEFAULT => Some((id, s)),
8928                    _ => None,
8929                })
8930                .collect()
8931        };
8932        for (id, name) in named {
8933            let idx = self.ns_index_for(&name);
8934            self.node_ns[id as usize] = idx;
8935        }
8936    }
8937
8938    /// Every namespace with at least one live node, in name order.
8939    ///
8940    /// `["default"]` on any store that has never named a namespace, including
8941    /// an empty one: a store is always at least its default namespace.
8942    pub fn namespaces(&self) -> Vec<String> {
8943        let mut out: BTreeSet<&str> = BTreeSet::new();
8944        out.insert(NS_DEFAULT);
8945        for (id, &idx) in self.node_ns.iter().enumerate() {
8946            if idx == NS_DEFAULT_IDX || !self.is_live_node(id as u32) {
8947                continue;
8948            }
8949            out.insert(self.ns_name(idx));
8950        }
8951        out.into_iter().map(str::to_string).collect()
8952    }
8953
8954    /// The namespace of `key`, or `None` when the key names no live node.
8955    pub fn namespace_of(&self, key: &str) -> Option<String> {
8956        let id = self.ids.get(key)?;
8957        if !self.is_live_node(id) {
8958            return None;
8959        }
8960        Some(self.ns_name(self.node_ns_idx(id)).to_string())
8961    }
8962
8963    /// Every live node in `namespace`, as a visibility mask.
8964    ///
8965    /// Built off `node_ns` on whichever handle this is, so on a temporal handle
8966    /// it is the namespace's membership at that commit. A name no node uses
8967    /// gives an empty mask — a namespace scope never widens.
8968    pub fn mask_for_namespace(&self, namespace: &str) -> crate::mask::NodeMask {
8969        let Some(idx) = self.ns_index_of(namespace) else {
8970            return crate::mask::NodeMask::from_ids(std::collections::HashSet::new());
8971        };
8972        let visible: std::collections::HashSet<u32> = (0..self.ids.len() as u32)
8973            .filter(|&id| self.node_ns_idx(id) == idx && self.is_live_node(id))
8974            .collect();
8975        crate::mask::NodeMask::from_ids(visible)
8976    }
8977
8978    /// Live-node test used by the namespace accessors: a deleted node keeps its
8979    /// dense id and its `node_ns` slot, and the label sentinel is what marks it
8980    /// gone — the same test `mask_for_role`'s label leg applies implicitly.
8981    fn is_live_node(&self, id: u32) -> bool {
8982        self.labels
8983            .get(id as usize)
8984            .is_some_and(|&sym| sym != u32::MAX)
8985            && self.ids.key_of(id).is_some()
8986    }
8987
8988    /// Per-namespace live node counts for [`Stats`], in name order.
8989    fn namespace_stats(&self) -> Vec<NamespaceStats> {
8990        let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
8991        counts.insert(NS_DEFAULT, 0);
8992        for id in 0..self.ids.len() as u32 {
8993            if !self.is_live_node(id) {
8994                continue;
8995            }
8996            *counts
8997                .entry(self.ns_name(self.node_ns_idx(id)))
8998                .or_insert(0) += 1;
8999        }
9000        counts
9001            .into_iter()
9002            .filter(|&(name, n)| n > 0 || name == NS_DEFAULT)
9003            .map(|(name, nodes_live)| NamespaceStats {
9004                name: name.to_string(),
9005                nodes_live,
9006            })
9007            .collect()
9008    }
9009
9010    /// The namespace a create-class op would put its node in: the `ns` entry of
9011    /// the props it carries, normalised, with absent meaning [`NS_DEFAULT`].
9012    fn created_namespace<'a>(key: &str, props: &'a [(String, Value)]) -> Result<&'a str> {
9013        Ok(namespace_of_value(Self::sole_ns_entry(key, props)?))
9014    }
9015
9016    /// The one `ns` entry in a node's props, or `None` when it carries none.
9017    ///
9018    /// A props list naming `ns` twice is refused. Without that refusal the
9019    /// write path and the authorisation path can read the same list
9020    /// differently — one taking the first entry, the other the last — and
9021    /// `CREATE (n:L {ns: 'mine', ns: 'theirs'})` lands a node in a namespace
9022    /// the role was checked against the other of. One entry is the only shape
9023    /// where "the node's namespace" is a single fact, so it is the only shape
9024    /// accepted, and every reader of it agrees by construction.
9025    fn sole_ns_entry<'a>(key: &str, props: &'a [(String, Value)]) -> Result<Option<&'a Value>> {
9026        let mut found: Option<&'a Value> = None;
9027        for (field, value) in props {
9028            if field != NS_PROP {
9029                continue;
9030            }
9031            if found.is_some() {
9032                return Err(GraphError::RuleInvalid {
9033                    detail: format!(
9034                        "node {key}: {NS_PROP} is given more than once; a node has exactly \
9035                         one namespace"
9036                    ),
9037                });
9038            }
9039            found = Some(value);
9040        }
9041        Ok(found)
9042    }
9043
9044    /// The definition of the role a write authorisation names.
9045    ///
9046    /// `None` when `roles.json` was corrupt at open or the role has since been
9047    /// removed — neither can reach a write, because the authorisation carries a
9048    /// mask `mask_for_role` already resolved for that name.
9049    fn role_def_for(&self, role: &str) -> Option<&RoleDef> {
9050        self.roles.as_ref()?.iter().find(|r| r.name == role)
9051    }
9052
9053    /// Validate the `ns` entry of a node's props and drop an explicit default.
9054    ///
9055    /// Runs on the write path only (see `rewrite_wal_dense`), never on replay:
9056    /// a record that reached the WAL was already accepted here.
9057    fn normalise_insert_ns(
9058        key: &str,
9059        props: Vec<(String, Value)>,
9060    ) -> Result<(Vec<(String, Value)>, String)> {
9061        // One `ns` or none: this is where that is enforced, so every later
9062        // reader of the list — the authorisation gate, the two `apply` arms,
9063        // `node_ns` — is looking at a single entry and cannot disagree about
9064        // which one counts.
9065        Self::sole_ns_entry(key, &props)?;
9066        let mut name = NS_DEFAULT.to_string();
9067        let mut out = Vec::with_capacity(props.len());
9068        for (field, value) in props {
9069            if field != NS_PROP {
9070                out.push((field, value));
9071                continue;
9072            }
9073            let Value::Str(ref s) = value else {
9074                return Err(GraphError::RuleInvalid {
9075                    detail: format!(
9076                        "node {key}: {NS_PROP} must be a string naming a namespace, \
9077                         got {value:?}"
9078                    ),
9079                });
9080            };
9081            if !valid_namespace(s) {
9082                return Err(GraphError::RuleInvalid {
9083                    detail: format!(
9084                        "node {key}: {s:?} is not a valid namespace name — 1 to {NS_MAX_LEN} \
9085                         characters of [A-Za-z0-9_.-]"
9086                    ),
9087                });
9088            }
9089            name = s.clone();
9090            // An explicit default stores nothing, so a single-tenant store
9091            // never grows an `ns` column.
9092            if name != NS_DEFAULT {
9093                out.push((field, value));
9094            }
9095        }
9096        Ok((out, name))
9097    }
9098
9099    // -----------------------------------------------------------------------
9100    // RBAC role resolution
9101    // -----------------------------------------------------------------------
9102
9103    /// Parse `roles.json` bytes from `fs`.
9104    ///
9105    /// Return values:
9106    ///   `Ok(Some(roles))` — file absent (returns `vec![]`) **or** file present
9107    ///                       and valid; in both cases `mask_for_role` uses the
9108    ///                       list normally (an absent file means no roles defined).
9109    ///   `Ok(None)`        — file present but corrupt or unrecognised version
9110    ///                       → poisoned state; `mask_for_role` returns `Err` for
9111    ///                       any role name until the file is fixed and the DB
9112    ///                       re-opened (or `apply_schema` is called to repair it).
9113    ///
9114    /// Note: `None` signals corruption, not absence — the opposite of what an
9115    /// optional "file missing" convention would suggest.  The open path stores
9116    /// this result on `db.roles` directly.
9117    fn load_roles_from_fs(fs: &F) -> Result<Option<Vec<RoleDef>>> {
9118        let bytes = fs.read(FileId::Roles).map_err(GraphError::Io)?;
9119        if bytes.is_empty() {
9120            // Empty bytes means either the file is absent or zero-byte — both
9121            // are treated identically as "no roles defined".  A zero-byte
9122            // roles.json does NOT widen access: an absent file and a zero-byte
9123            // file both resolve to an empty role list (sees nothing by default).
9124            return Ok(Some(vec![]));
9125        }
9126        match serde_json::from_slice::<RolesFile>(&bytes) {
9127            Ok(f) if matches!(f.version, 1..=4) => Ok(Some(f.roles)),
9128            // Corrupt or unrecognised version (>4): poison the roles state.
9129            // Never widen: a version this binary does not know may carry a
9130            // narrowing this binary would not apply.
9131            _ => Ok(None),
9132        }
9133    }
9134
9135    /// Resolve a role to a node-visibility mask against the current graph state.
9136    ///
9137    /// Returns `Err` when:
9138    /// - `roles.json` was present but corrupt at open (poisoned state), or
9139    /// - `role` does not match any defined role name.
9140    ///
9141    /// The mask union is: explicit `keys` (unknown keys silently ignored) plus
9142    /// all live nodes carrying any label in `labels` that also pass the role's
9143    /// [`visible_where`](crate::roles::RoleDef::visible_where) predicate, if it
9144    /// has one.  Label resolution is live — new nodes of an allowed label are
9145    /// visible without re-applying the schema, and a property edited out of the
9146    /// predicate takes its node out of the mask on the next read.  An empty
9147    /// union = empty mask = sees nothing.
9148    ///
9149    /// This is the one resolver every read path calls, live and as-of alike, so
9150    /// the predicate applies everywhere at once.  On an as-of handle the role
9151    /// *definition* is the current one and the graph is the historical one: the
9152    /// predicate is evaluated against the property values at the commit being
9153    /// read.
9154    ///
9155    /// The result is memoised per `(role, commit_seq)`, so a scoped reader
9156    /// between two writes resolves the role once.  See
9157    /// [`RoleMaskCache`](crate::mask::RoleMaskCache) for why that cannot go
9158    /// stale.
9159    pub fn mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
9160        self.role_masks
9161            .get_or_build(role, self.commit_seq, || self.build_mask_for_role(role))
9162            .map(|m| (*m).clone())
9163    }
9164
9165    /// The mask an [`AsOfScope`] names, resolved against this handle.
9166    ///
9167    /// Shared by [`GraphDb::query_at_scoped`] and
9168    /// [`GraphDb::query_at_scoped_in_namespace`] so one scope resolves one way
9169    /// however the namespace leg is added.
9170    fn mask_at_scope(&self, scope: AsOfScope<'_>) -> Result<crate::mask::NodeMask> {
9171        // One resolver answers "what may this role see" — `mask_for_role` — and
9172        // it runs against this handle, so on a temporal one the answer is the
9173        // as-of one.
9174        Ok(match scope {
9175            AsOfScope::Role(role) => self.mask_for_role(role)?,
9176            AsOfScope::Keys(keys) => {
9177                crate::mask::NodeMask::from_keys(self, keys.iter().map(String::as_str))
9178            }
9179            AsOfScope::RoleAndKeys(role, keys) => {
9180                self.mask_for_role(role)?
9181                    .intersect(&crate::mask::NodeMask::from_keys(
9182                        self,
9183                        keys.iter().map(String::as_str),
9184                    ))
9185            }
9186            AsOfScope::Namespace(namespace) => self.mask_for_namespace(namespace),
9187        })
9188    }
9189
9190    /// Resolve `role` against the current graph, ignoring the memo.
9191    fn build_mask_for_role(&self, role: &str) -> Result<crate::mask::NodeMask> {
9192        let roles = self.roles.as_ref().ok_or_else(roles_poisoned)?;
9193        let def = roles
9194            .iter()
9195            .find(|r| r.name == role)
9196            .ok_or_else(|| GraphError::KeyNotFound {
9197                key: format!("role:{role}"),
9198            })?;
9199
9200        let mut visible = std::collections::HashSet::new();
9201
9202        // Key leg: resolve explicit keys to dense ids (unknown keys ignored).
9203        // An administrative grant, never narrowed by the predicate.
9204        for key in &def.keys {
9205            if let Some(id) = self.ids.get(key) {
9206                visible.insert(id);
9207            }
9208        }
9209
9210        // Label leg: live scan — iterate labels vec for matching symbol, and
9211        // when the role carries a predicate, test the property as well.  The
9212        // property comes from the store's own merged view (overlay over the
9213        // mmap'd base), so an as-of handle reads the values of its own commit.
9214        let props = def.visible_where.as_ref().map(|_| self.props_view());
9215        for label_name in &def.labels {
9216            if let Some(sym) = self.syms.get(label_name) {
9217                for (i, &s) in self.labels.iter().enumerate() {
9218                    if s != sym {
9219                        continue;
9220                    }
9221                    let id = i as u32;
9222                    match (&def.visible_where, &props) {
9223                        (Some(pred), Some(view)) => {
9224                            let value = view.get(id, &pred.field).map(|vr| vr.into_value());
9225                            if pred.holds(value.as_ref()) {
9226                                visible.insert(id);
9227                            }
9228                        }
9229                        _ => {
9230                            visible.insert(id);
9231                        }
9232                    }
9233                }
9234            }
9235        }
9236
9237        // Namespace leg: an intersection over the whole union, the key leg
9238        // included. A namespace is a tenancy boundary, so a key naming a node in
9239        // another tenant's namespace is not an administrative grant — and
9240        // `apply_schema` has already refused that role, so this only has to be
9241        // right about the node that moved into existence afterwards.
9242        if def.namespaces.is_some() {
9243            visible.retain(|&id| def.sees_namespace(self.ns_name(self.node_ns_idx(id))));
9244        }
9245
9246        Ok(crate::mask::NodeMask::from_ids(visible))
9247    }
9248
9249    /// Return the current list of role definitions.
9250    ///
9251    /// Returns an empty list when no roles are defined or when `roles.json`
9252    /// was corrupt at open (check [`mask_for_role`](Self::mask_for_role) for
9253    /// the fail-loud error in that case, or call
9254    /// [`roles_checked`](Self::roles_checked), which is this readout with that
9255    /// error in it).
9256    pub fn roles(&self) -> Vec<RoleDef> {
9257        self.roles.as_deref().unwrap_or(&[]).to_vec()
9258    }
9259
9260    /// The role definitions, or the poison error when `roles.json` was corrupt
9261    /// at open.
9262    ///
9263    /// [`roles`](Self::roles) answers `[]` both for a store that defines no
9264    /// roles and for one whose sidecar did not parse, and a caller validating a
9265    /// role name at boot cannot tell those apart. The wrong reading of the pair
9266    /// is the dangerous one: a store with no roles at all is an unrestricted
9267    /// store, so a poisoned file would read as "nothing is restricted here".
9268    ///
9269    /// This is the same answer, for the same cause, that
9270    /// [`mask_for_role`](Self::mask_for_role) gives on the first read.
9271    pub fn roles_checked(&self) -> Result<Vec<RoleDef>> {
9272        match self.roles.as_deref() {
9273            Some(roles) => Ok(roles.to_vec()),
9274            None => Err(roles_poisoned()),
9275        }
9276    }
9277
9278    // ── Role-scoped write authz ───────────────────────────────────────────────
9279
9280    /// Execute `ops` with optional role-scoped write authorization.
9281    ///
9282    /// - `None` → full authority, identical to [`write_batch`](Self::write_batch)
9283    ///   (zero-cost bypass of all authz checks).
9284    /// - `Some(authz)` → the decision table is evaluated per-op BEFORE any WAL
9285    ///   record is built.  A denial returns an error with no WAL frame written
9286    ///   (all-or-nothing at the authz boundary, then at the MutPreview boundary).
9287    ///
9288    /// See the plan's "authz decision table" section for the full semantics.
9289    pub fn write_batch_authz(
9290        &mut self,
9291        authz: Option<&WriteAuthz>,
9292        ops: Vec<BatchOp>,
9293    ) -> Result<(usize, usize)> {
9294        // Thread authz as a direct parameter — never touches pending_write_authz.
9295        self.commit_logged_batch(ops, None, authz.cloned())
9296            .map(inserted_pair)
9297    }
9298
9299    /// Execute a Cypher write statement with role-scoped write authorization.
9300    ///
9301    /// Resolves scope + mask from `self.roles` inside the call (same write-guard
9302    /// lifetime as execution, satisfying §5 lock discipline).  The resolved
9303    /// `WriteAuthz` is stored as `pending_write_authz` for the duration of the
9304    /// call so that all inner `batch.commit()` calls are authz-checked.
9305    ///
9306    /// MERGE is handled specially: the MERGE scope precondition (§3.3) is
9307    /// checked in `exec_merge` BEFORE `has_node` to close the §6.2
9308    /// timing-oracle item (hidden ≡ absent for unscoped roles).
9309    ///
9310    /// Roles with `write: None` (v1 behavior) → `RoleWriteDenied` with
9311    /// "this endpoint is not permitted".
9312    pub fn query_write_authz(
9313        &mut self,
9314        role: &str,
9315        cypher: &str,
9316        params: &BTreeMap<String, Value>,
9317    ) -> Result<ResultSet> {
9318        // Resolve scope (fails fast if role has no write scope).
9319        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9320        let scope =
9321            {
9322                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9323                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9324                })?;
9325                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9326                    GraphError::KeyNotFound {
9327                        key: format!("role:{role}"),
9328                    }
9329                })?;
9330                def.write
9331                    .clone()
9332                    .ok_or_else(|| GraphError::RoleWriteDenied {
9333                        reason: "role-bound token: writes are not permitted".into(),
9334                    })?
9335            };
9336        // Resolve mask inside the call (same guard, §5 coherence).
9337        let mask = self.mask_for_role(role)?;
9338        self.pending_write_authz = Some(WriteAuthz {
9339            role: role.into(),
9340            scope,
9341            mask,
9342        });
9343        // RAII guard: always clears pending_write_authz on scope exit, including
9344        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9345        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9346        impl Drop for ClearPendingAuthzOnDrop {
9347            fn drop(&mut self) {
9348                // SAFETY: pointer into the owning GraphDb; guard is dropped
9349                // within this function's frame before it returns.
9350                unsafe { *self.0 = None };
9351            }
9352        }
9353        // SAFETY: raw pointer into self; guard dropped before this fn returns.
9354        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9355        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9356            detail: format!("lex: {e}"),
9357        })?;
9358        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
9359            detail: format!("parse: {e}"),
9360        })?;
9361        self.exec_write_stmt(stmt, params)
9362    }
9363
9364    /// Execute `ops` with optional role-scoped write authorization, suppressing
9365    /// fsync (for use inside the group-commit drain thread, which performs one
9366    /// group fsync after releasing the write lock).
9367    ///
9368    /// Identical to [`write_batch_authz`] except the fsync policy is temporarily
9369    /// forced to `Relaxed` for the duration of the call, matching the drain-thread
9370    /// contract established by [`commit_batch_nosync`].
9371    pub(crate) fn write_batch_authz_nosync(
9372        &mut self,
9373        authz: Option<&WriteAuthz>,
9374        ops: Vec<BatchOp>,
9375    ) -> Result<(usize, usize)> {
9376        let saved = self.fsync;
9377        struct RestoreFsync(*mut FsyncPolicy, FsyncPolicy);
9378        impl Drop for RestoreFsync {
9379            fn drop(&mut self) {
9380                // SAFETY: pointer into the owning GraphDb; guard is dropped
9381                // within the enclosing function's frame before it returns.
9382                unsafe { *self.0 = self.1 };
9383            }
9384        }
9385        // SAFETY: raw pointer into self; guard dropped before this fn returns.
9386        let _g = RestoreFsync(&mut self.fsync as *mut FsyncPolicy, saved);
9387        self.fsync = FsyncPolicy::Relaxed;
9388        self.commit_logged_batch(ops, None, authz.cloned())
9389            .map(inserted_pair)
9390    }
9391
9392    /// Execute a `/ingest` request with role-scoped write authorization.
9393    ///
9394    /// Resolves the role's `WriteScope` and `NodeMask` inside this call (same
9395    /// write-guard lifetime as the mutation, satisfying §5 lock discipline).
9396    /// Sets `pending_write_authz` for the duration of the call so that the
9397    /// `commit_ingest` → `commit_logged_batch` path picks up the authz context
9398    /// and evaluates the decision table per-op before any WAL write.
9399    ///
9400    /// §7.3: roles with empty `create_labels` will see every `InsertNode` op
9401    /// denied by the decision table with the appropriate §4.3 scope reason;
9402    /// no special HTTP-layer check is needed.
9403    ///
9404    /// Roles with `write: None` return `RoleWriteDenied` with
9405    /// "writes are not permitted" (byte-identical to v1 blanket 403).
9406    pub fn ingest_with_edges_authz(
9407        &mut self,
9408        role: &str,
9409        label: &str,
9410        rows: Vec<std::collections::BTreeMap<String, Value>>,
9411        opts: &crate::ingest::IngestOptions,
9412        edges: &[(String, String, String)],
9413    ) -> Result<crate::ingest::IngestReport> {
9414        // Resolve scope (fails fast if role has no write scope).
9415        // write:None → byte-identical v1 blanket-403 body (plan §v1-sidecar mandate).
9416        let scope =
9417            {
9418                let roles = self.roles.as_deref().ok_or_else(|| GraphError::Corrupt {
9419                    detail: "roles.json was corrupt at open; re-open to restore role access".into(),
9420                })?;
9421                let def = roles.iter().find(|r| r.name == role).ok_or_else(|| {
9422                    GraphError::KeyNotFound {
9423                        key: format!("role:{role}"),
9424                    }
9425                })?;
9426                def.write
9427                    .clone()
9428                    .ok_or_else(|| GraphError::RoleWriteDenied {
9429                        reason: "role-bound token: writes are not permitted".into(),
9430                    })?
9431            };
9432        let mask = self.mask_for_role(role)?;
9433        self.pending_write_authz = Some(WriteAuthz {
9434            role: role.into(),
9435            scope,
9436            mask,
9437        });
9438        // RAII guard: always clears pending_write_authz on scope exit, including
9439        // on panic or early-return, mirroring the RestoreEmitDeltas precedent.
9440        struct ClearPendingAuthzOnDrop(*mut Option<WriteAuthz>);
9441        impl Drop for ClearPendingAuthzOnDrop {
9442            fn drop(&mut self) {
9443                // SAFETY: pointer into the owning GraphDb; guard is dropped
9444                // within this function's frame before it returns.
9445                unsafe { *self.0 = None };
9446            }
9447        }
9448        // SAFETY: raw pointer into self; guard dropped before this fn returns.
9449        let _authz_guard = ClearPendingAuthzOnDrop(&mut self.pending_write_authz as *mut _);
9450        self.ingest_with_edges(label, rows, opts, edges)
9451    }
9452
9453    /// Evaluate the write-authz decision table for one `BatchOp`.
9454    ///
9455    /// Called by `commit_logged_batch` for each op when `pending_write_authz`
9456    /// is `Some`, BEFORE MutPreview.  A denial returns an error immediately;
9457    /// the remaining ops are not evaluated and no WAL frame is written.
9458    ///
9459    /// `batch_created` carries the key→label pairs of nodes that earlier ops in
9460    /// THIS batch will create.  Used by `InsertEdgeUpsert` to count same-batch
9461    /// placeholder nodes as visible (spec: "a placeholder endpoint the SAME
9462    /// batch creates counts as visible if its label passed the create-class gate").
9463    fn check_single_op_authz(
9464        &self,
9465        authz: &WriteAuthz,
9466        op: &BatchOp,
9467        batch_created: &BTreeMap<String, String>,
9468    ) -> Result<()> {
9469        // Helper: 3-way node status under the authz mask.
9470        //
9471        // Batch-created nodes (from earlier InsertNode in THIS batch) are treated
9472        // as Visible with their recorded label — their create gate already passed
9473        // and they are not yet in self.ids (not committed).  This fixes the
9474        // MERGE+ON CREATE SET case where InsertNode + SetProp arrive together:
9475        // the SetProp must not see the node as Absent.
9476        let node_status = |key: &str| -> NodeAuthzStatus {
9477            if let Some(label) = batch_created.get(key) {
9478                return NodeAuthzStatus::Visible(label.clone());
9479            }
9480            match self.ids.get(key) {
9481                None => NodeAuthzStatus::Absent,
9482                Some(id) if !authz.mask.contains_id(id) => NodeAuthzStatus::Hidden,
9483                Some(id) => {
9484                    let label = self
9485                        .labels
9486                        .get(id as usize)
9487                        .and_then(|&sym| {
9488                            if sym == u32::MAX {
9489                                None
9490                            } else {
9491                                self.syms.resolve(sym).map(str::to_string)
9492                            }
9493                        })
9494                        .unwrap_or_default();
9495                    NodeAuthzStatus::Visible(label)
9496                }
9497            }
9498        };
9499
9500        // Helper: is an InsertEdgeUpsert endpoint visible?
9501        // A same-batch placeholder counts as visible if its label passed
9502        // the create-class gate (spec "upsert placeholder-counts-as-visible").
9503        let upsert_ep_visible = |ep_key: &str, placeholder_label: &str| -> bool {
9504            // In store and visible?
9505            if let Some(id) = self.ids.get(ep_key) {
9506                return authz.mask.contains_id(id);
9507            }
9508            // Created by an earlier op in this batch?
9509            if let Some(created_label) = batch_created.get(ep_key) {
9510                return authz.scope.create_labels.contains(created_label);
9511            }
9512            // Will be created by THIS InsertEdgeUpsert: placeholder_label
9513            // must pass the create-class gate.
9514            authz
9515                .scope
9516                .create_labels
9517                .contains(&placeholder_label.to_string())
9518        };
9519
9520        match op {
9521            // RenameNode / CreateRule / DeleteRule: defense-in-depth gate.
9522            // These ops are never routed to role-scoped paths by the HTTP layer,
9523            // but we 403 them here to close any future bypass route.
9524            //
9525            // InsertNodeOnConflict joins them: it is reachable only from the
9526            // embedded Python binding, which has no role token, and `Replace`
9527            // is a create and an update at once. Rather than split the decision
9528            // table for an op no role-scoped path constructs, refuse it — a
9529            // role-scoped caller writes through the ops that are already in the
9530            // table.
9531            BatchOp::RenameNode { .. }
9532            | BatchOp::CreateRule(_)
9533            | BatchOp::DeleteRule { .. }
9534            | BatchOp::InsertNodeOnConflict { .. } => {
9535                return Err(GraphError::RoleWriteDenied {
9536                    reason: "role-bound token: this endpoint is not permitted".into(),
9537                });
9538            }
9539
9540            // ── CREATE-class: InsertNode ─────────────────────────────────────
9541            //
9542            // Decision table row 1 (scope-before-lookup): check label in
9543            // create_labels BEFORE any key lookup.  This is the structural
9544            // closure of the §6.2 timing-oracle item — the denial fires even
9545            // when the store is EMPTY (see test_create_scope_denied_empty_store).
9546            BatchOp::InsertNode { label, key, props } => {
9547                if !authz.scope.create_labels.contains(label) {
9548                    return Err(GraphError::RoleWriteDenied {
9549                        reason: format!(
9550                            "role-bound token: label '{}' not in write scope (create_labels)",
9551                            label
9552                        ),
9553                    });
9554                }
9555                // A role bound to namespaces may only create inside them. The
9556                // never-widen rule is about what a write makes visible to *any*
9557                // party, not only to the writer: a node this role could never
9558                // read back is a write into somebody else's tenancy. Also a
9559                // scope check, so it runs before the key lookup — it discloses
9560                // nothing about the store. Covers Cypher `CREATE` and the node
9561                // `MERGE` creates, both of which arrive as this op.
9562                // Resolved before the role lookup so a props list naming `ns`
9563                // twice is refused for every role, scoped or not: it is the same
9564                // malformed write the seam refuses, and leaving it to the seam
9565                // would mean the gate had already read one of the two.
9566                let target = Self::created_namespace(key, props)?;
9567                if let Some(def) = self.role_def_for(&authz.role) {
9568                    if !def.sees_namespace(target) {
9569                        return Err(GraphError::RoleWriteDenied {
9570                            reason: format!(
9571                                "role-bound token: namespace '{target}' not in the role's \
9572                                 namespaces"
9573                            ),
9574                        });
9575                    }
9576                }
9577                // Row 2/3: key lookup.
9578                match self.ids.get(key.as_str()) {
9579                    Some(id) if authz.mask.contains_id(id) => {
9580                        // Visible: DuplicateKey — let MutPreview handle this.
9581                    }
9582                    Some(_) => {
9583                        // Hidden: indistinguishable from absent to the role.
9584                        return Err(GraphError::RoleWriteDenied {
9585                            reason: "role-bound token: target node not visible".into(),
9586                        });
9587                    }
9588                    None => {
9589                        // Absent: proceed (create).
9590                    }
9591                }
9592            }
9593
9594            // ── UPDATE-class: SetProp, RemoveProp ────────────────────────────
9595            BatchOp::SetProp { key, .. } | BatchOp::RemoveProp { key, .. } => {
9596                if batch_created.contains_key(key.as_str()) {
9597                    // Batch-created node: create gate already passed this batch.
9598                    // Updating it in the same batch is always allowed, regardless
9599                    // of update_labels (ruling §3.5: "writer just created it").
9600                } else {
9601                    let label = match node_status(key) {
9602                        NodeAuthzStatus::Visible(lbl) => lbl,
9603                        _ => {
9604                            return Err(GraphError::RoleWriteDenied {
9605                                reason: "role-bound token: target node not visible".into(),
9606                            });
9607                        }
9608                    };
9609                    if !authz.scope.update_labels.contains(&label) {
9610                        return Err(GraphError::RoleWriteDenied {
9611                            reason: format!(
9612                                "role-bound token: label '{}' not in write scope (update_labels)",
9613                                label
9614                            ),
9615                        });
9616                    }
9617                }
9618            }
9619
9620            // ── DELETE-class: DeleteNode ─────────────────────────────────────
9621            BatchOp::DeleteNode { key } => {
9622                let label = match node_status(key) {
9623                    NodeAuthzStatus::Visible(lbl) => lbl,
9624                    _ => {
9625                        return Err(GraphError::RoleWriteDenied {
9626                            reason: "role-bound token: target node not visible".into(),
9627                        });
9628                    }
9629                };
9630                if !authz.scope.delete_labels.contains(&label) {
9631                    return Err(GraphError::RoleWriteDenied {
9632                        reason: format!(
9633                            "role-bound token: label '{}' not in write scope (delete_labels)",
9634                            label
9635                        ),
9636                    });
9637                }
9638            }
9639
9640            // ── DELETE-class: DeleteEdge ─────────────────────────────────────
9641            //
9642            // Derived-edge rejection runs BEFORE the delete_edge_types scope
9643            // check (spec §3.5: "existing derived-edge rejection precedes
9644            // delete_edge_types check").
9645            BatchOp::DeleteEdge {
9646                edge_type,
9647                src_key,
9648                dst_key,
9649            } => {
9650                // Check provenance ownership BEFORE scope (spec §3.5 ordering).
9651                if let (Some(src_id), Some(dst_id), Some(et_sym)) = (
9652                    self.ids.get(src_key.as_str()),
9653                    self.ids.get(dst_key.as_str()),
9654                    self.syms.get(edge_type.as_str()),
9655                ) {
9656                    if self.engine.is_owned(et_sym, src_id, dst_id) {
9657                        return Err(GraphError::RuleOwned {
9658                            detail: format!(
9659                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9660                                 delete or change the owning rule"
9661                            ),
9662                        });
9663                    }
9664                    // Also check would_derive via MutPreview (empty overlay, pre-batch).
9665                    let preview = MutPreview::new(self);
9666                    if preview.would_derive(edge_type, src_key, dst_key) {
9667                        return Err(GraphError::RuleOwned {
9668                            detail: format!(
9669                                "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
9670                                 delete or change the owning rule, or a live rule would \
9671                                 re-derive it"
9672                            ),
9673                        });
9674                    }
9675                }
9676                // Scope check (AFTER derived-edge check, BEFORE endpoint visibility).
9677                if !authz.scope.delete_edge_types.contains(edge_type) {
9678                    return Err(GraphError::RoleWriteDenied {
9679                        reason: format!(
9680                            "role-bound token: edge type '{}' not in write scope (delete_edge_types)",
9681                            edge_type
9682                        ),
9683                    });
9684                }
9685                // Both endpoints must be visible.
9686                for ep_key in [src_key.as_str(), dst_key.as_str()] {
9687                    match self.ids.get(ep_key) {
9688                        None => {
9689                            return Err(GraphError::RoleWriteDenied {
9690                                reason: "role-bound token: edge endpoint not visible".into(),
9691                            });
9692                        }
9693                        Some(id) if !authz.mask.contains_id(id) => {
9694                            return Err(GraphError::RoleWriteDenied {
9695                                reason: "role-bound token: edge endpoint not visible".into(),
9696                            });
9697                        }
9698                        _ => {}
9699                    }
9700                }
9701            }
9702
9703            // ── EDGE-CREATE: InsertEdge ──────────────────────────────────────
9704            //
9705            // Scope check BEFORE endpoint lookup (preserves timing symmetry).
9706            BatchOp::InsertEdge {
9707                edge_type,
9708                src_key,
9709                dst_key,
9710            } => {
9711                if !authz.scope.create_edge_types.contains(edge_type) {
9712                    return Err(GraphError::RoleWriteDenied {
9713                        reason: format!(
9714                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9715                            edge_type
9716                        ),
9717                    });
9718                }
9719                // Both endpoints must be visible. A node created by an earlier
9720                // InsertNode in the same batch (tracked in batch_created) counts
9721                // as visible if its label passed the create-class gate.
9722                for ep_key in [src_key.as_str(), dst_key.as_str()] {
9723                    if batch_created.contains_key(ep_key) {
9724                        // Created earlier this batch — already scope-checked.
9725                        continue;
9726                    }
9727                    match self.ids.get(ep_key) {
9728                        None => {
9729                            return Err(GraphError::RoleWriteDenied {
9730                                reason: "role-bound token: edge endpoint not visible".into(),
9731                            });
9732                        }
9733                        Some(id) if !authz.mask.contains_id(id) => {
9734                            return Err(GraphError::RoleWriteDenied {
9735                                reason: "role-bound token: edge endpoint not visible".into(),
9736                            });
9737                        }
9738                        _ => {}
9739                    }
9740                }
9741            }
9742
9743            // ── EDGE-CREATE: InsertEdgeUpsert ────────────────────────────────
9744            //
9745            // Scope check first; then endpoint visibility using same-batch
9746            // placeholder awareness (spec: "a placeholder endpoint the SAME
9747            // batch creates counts as visible if its label passed the
9748            // create-class gate").
9749            BatchOp::InsertEdgeUpsert {
9750                edge_type,
9751                src_key,
9752                dst_key,
9753                placeholder_label,
9754            } => {
9755                if !authz.scope.create_edge_types.contains(edge_type) {
9756                    return Err(GraphError::RoleWriteDenied {
9757                        reason: format!(
9758                            "role-bound token: edge type '{}' not in write scope (create_edge_types)",
9759                            edge_type
9760                        ),
9761                    });
9762                }
9763                // Check placeholder label against create_labels (create-class gate).
9764                // This ensures the auto-created endpoints are scope-allowed.
9765                for ep_key in [src_key.as_str(), dst_key.as_str()] {
9766                    if !upsert_ep_visible(ep_key, placeholder_label) {
9767                        return Err(GraphError::RoleWriteDenied {
9768                            reason: "role-bound token: edge endpoint not visible".into(),
9769                        });
9770                    }
9771                }
9772                // A placeholder is created with no props, so it lands in the
9773                // default namespace. A role that cannot read `default` must not
9774                // create one there, for the same reason it may not create a node
9775                // there outright.
9776                //
9777                // The refusal is byte-identical to the hidden-endpoint one above,
9778                // and deliberately so: this arm fires only for an endpoint that
9779                // does **not** exist, and the one above only for an endpoint that
9780                // does. Two different strings would make the pair an existence
9781                // oracle — ask for an upsert and read off whether the key is
9782                // taken. Hidden ≡ absent is the rule everywhere else in this
9783                // table and it holds here too.
9784                if let Some(def) = self.role_def_for(&authz.role) {
9785                    if !def.sees_namespace(NS_DEFAULT) {
9786                        for ep_key in [src_key.as_str(), dst_key.as_str()] {
9787                            if self.ids.get(ep_key).is_none() && !batch_created.contains_key(ep_key)
9788                            {
9789                                return Err(GraphError::RoleWriteDenied {
9790                                    reason: "role-bound token: edge endpoint not visible".into(),
9791                                });
9792                            }
9793                        }
9794                    }
9795                }
9796            }
9797        }
9798        Ok(())
9799    }
9800
9801    /// Write `roles` to `roles.json` atomically and update the in-memory list.
9802    ///
9803    /// Called by `apply_schema` when roles change. Never called on unchanged
9804    /// re-apply — this preserves byte-identical idempotency.
9805    pub(crate) fn commit_roles(&mut self, roles: Vec<RoleDef>) -> Result<()> {
9806        let file = RolesFile::new_versioned(roles.clone());
9807        let bytes = serde_json::to_vec(&file).map_err(|e| GraphError::Corrupt {
9808            detail: format!("roles serialization: {e}"),
9809        })?;
9810        self.fs
9811            .write_atomic(FileId::Roles, &bytes)
9812            .map_err(GraphError::Io)?;
9813        self.roles = Some(roles);
9814        // Rewriting the sidecar is not a commit, so `commit_seq` does not move
9815        // and a memoised mask would still match its version. Install a fresh
9816        // cache instead of clearing the shared one: a reader snapshot frozen
9817        // against the old definitions keeps the old `Arc` to itself and can
9818        // never publish an answer this handle would read back.
9819        self.role_masks = Arc::new(crate::mask::RoleMaskCache::new());
9820        // Refresh the MVCC frozen overlay so that reader() immediately sees the
9821        // updated role definitions without waiting for the next K-commit fold.
9822        self.fold_now();
9823        Ok(())
9824    }
9825
9826    fn view(&self) -> GraphView<'_> {
9827        GraphView {
9828            ids: &self.ids,
9829            syms: &self.syms,
9830            labels: &self.labels,
9831            props: self.props_view(),
9832            topo: self.topo_view(),
9833            edge_props: self.edge_props_view(),
9834            mask: None,
9835            prop_index: Some(&self.prop_index),
9836        }
9837    }
9838
9839    fn view_masked<'a>(&'a self, mask: &'a crate::mask::NodeMask) -> GraphView<'a> {
9840        GraphView {
9841            ids: &self.ids,
9842            syms: &self.syms,
9843            labels: &self.labels,
9844            props: self.props_view(),
9845            topo: self.topo_view(),
9846            edge_props: self.edge_props_view(),
9847            mask: Some(&mask.visible),
9848            prop_index: Some(&self.prop_index),
9849        }
9850    }
9851
9852    /// Execute a read-only Cypher query with a node visibility mask.
9853    ///
9854    /// Only nodes whose key is in `mask` are accessible: label scans, key
9855    /// lookups, and neighbor expansions all respect the mask. Edges where
9856    /// either endpoint is hidden are silently dropped.
9857    ///
9858    /// Returns `Err` with a "masked queries are read-only" message when
9859    /// `cypher` is a write statement (CREATE / MERGE / MATCH…SET / DELETE).
9860    pub fn query_masked(
9861        &self,
9862        cypher: &str,
9863        params: &std::collections::BTreeMap<String, Value>,
9864        mask: &crate::mask::NodeMask,
9865    ) -> Result<ResultSet> {
9866        // Reject write statements up front.
9867        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
9868            detail: format!("lex: {e}"),
9869        })?;
9870        if is_write_tokens(&tokens) {
9871            return Err(GraphError::MaskedReadOnly);
9872        }
9873        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
9874            detail: format!("parse: {e}"),
9875        })?;
9876        // Each UNION part executes against the same masked view, so the mask
9877        // applies uniformly across the chain.
9878        execute_union(&self.view_masked(mask), &union, &Params(params)).map_err(|e| {
9879            GraphError::QueryError {
9880                detail: format!("execute: {e}"),
9881            }
9882        })
9883    }
9884
9885    pub fn node_ref(&self, key: &str) -> Option<NodeRef<'_, F>> {
9886        let id = self.ids.get(key)?;
9887        Some(NodeRef { db: self, id })
9888    }
9889
9890    /// BFS neighborhood expansion restricted to visible nodes in `mask`.
9891    ///
9892    /// Hidden nodes are never used as traversal intermediaries in either
9893    /// [`MaskMode::Omit`] or [`MaskMode::Stub`] — a visible node reachable
9894    /// only through a hidden node will not appear in results.
9895    ///
9896    /// In [`MaskMode::Stub`] mode, hidden nodes that are direct neighbours of
9897    /// a visited visible node are appended to the result as stub rows
9898    /// (`label` column is `null`, same key+depth columns as visible rows).
9899    /// They are NOT added to the BFS frontier.
9900    ///
9901    /// Returns `None` when `key` does not exist (caller should 404).
9902    ///
9903    /// **SECURITY**: role-token callers always pass an Omit-mode mask, so
9904    /// stub rows are never produced on the role path.
9905    pub fn neighborhood_masked(
9906        &self,
9907        key: &str,
9908        depth: u32,
9909        edge_types: Option<&[&str]>,
9910        dir: Dir,
9911        mask: &crate::mask::NodeMask,
9912    ) -> Option<ResultSet> {
9913        let start_id = self.ids.get(key)?;
9914        let view = self.view_masked(mask);
9915        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
9916            names
9917                .iter()
9918                .filter_map(|name| view.syms.get(name))
9919                .collect()
9920        });
9921        let nb = neighborhood(&view, start_id, depth, resolved.as_deref(), dir);
9922        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
9923        // Collect visible BFS results (start_id at depth 0, BFS nodes after).
9924        let mut visited: Vec<(u32, u32)> = Vec::with_capacity(nb.nodes.len() + 1);
9925        visited.push((start_id, 0));
9926        for (nid, d) in &nb.nodes {
9927            let k = view.key_of(*nid);
9928            let label = view
9929                .label_of(*nid)
9930                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
9931            rs.push_row(vec![
9932                Some(Value::Str(k.to_string())),
9933                Some(Value::Str(label.to_string())),
9934                Some(Value::Int(*d as i64)),
9935            ]);
9936            visited.push((*nid, *d));
9937        }
9938        // Stub mode: add hidden direct neighbours of each visited node as stubs.
9939        // Hidden nodes are edge-endpoints only — they are not added to the BFS
9940        // frontier, so the BFS never expands through them.
9941        if mask.mode() == crate::mask::MaskMode::Stub {
9942            let raw_view = self.view();
9943            let mut seen: std::collections::HashSet<u32> =
9944                visited.iter().map(|(id, _)| *id).collect();
9945            for (node_id, node_depth) in &visited {
9946                if *node_depth >= depth {
9947                    continue;
9948                }
9949                for e in expand(&raw_view, *node_id, resolved.as_deref(), dir) {
9950                    let nbr = if e.src == *node_id { e.dst } else { e.src };
9951                    if !mask.contains_id(nbr) && seen.insert(nbr) {
9952                        if let Some(k) = self.ids.key_of(nbr) {
9953                            rs.push_row(vec![
9954                                Some(Value::Str(k.to_string())),
9955                                None,
9956                                Some(Value::Int((*node_depth + 1) as i64)),
9957                            ]);
9958                        }
9959                    }
9960                }
9961            }
9962        }
9963        Some(rs)
9964    }
9965
9966    /// [`neighborhood_masked`](Self::neighborhood_masked) with the **subject
9967    /// check** a scoped caller needs: a start key the mask hides answers exactly
9968    /// as an absent one does.
9969    ///
9970    /// `neighborhood_masked` expands from any existing key, hidden or not,
9971    /// because a full-token caller supplying a client mask already knows which
9972    /// keys exist. A scoped caller does not, so telling it apart a hidden key
9973    /// from an absent one would be an existence oracle.
9974    ///
9975    /// Expansion itself is unchanged: hidden nodes are neither returned nor used
9976    /// as traversal intermediaries, so a visible node reachable only through a
9977    /// hidden one stays out of the result.
9978    ///
9979    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
9980    pub fn neighborhood_scoped(
9981        &self,
9982        key: &str,
9983        depth: u32,
9984        edge_types: Option<&[&str]>,
9985        dir: Dir,
9986        mask: &crate::mask::NodeMask,
9987    ) -> Result<ResultSet> {
9988        if !mask.contains_node(self, key) {
9989            return Err(GraphError::KeyNotFound { key: key.into() });
9990        }
9991        self.neighborhood_masked(key, depth, edge_types, dir, mask)
9992            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })
9993    }
9994
9995    /// Live node's key, label, and columnar props. Unknown or tombstoned → `None`.
9996    pub fn node_info(&self, key: &str) -> Option<NodeInfo> {
9997        let n = self.node_ref(key)?;
9998        Some(NodeInfo {
9999            key: n.key().to_string(),
10000            label: n.label().to_string(),
10001            props: n.props(),
10002        })
10003    }
10004
10005    /// Look up a node with mask awareness.
10006    ///
10007    /// | Key state         | Omit mode       | Stub mode              |
10008    /// |-------------------|-----------------|------------------------|
10009    /// | does not exist    | `None` (→ 404)  | `None` (→ 404)         |
10010    /// | exists, visible   | `Some(Visible)` | `Some(Visible)`        |
10011    /// | exists, hidden    | `None` (→ 404)  | `Some(Restricted)`     |
10012    ///
10013    /// **SECURITY**: only call from client-mask (full-token) paths.
10014    /// Role-token paths must use [`node_info`] after an explicit visibility check.
10015    pub fn node_info_masked(
10016        &self,
10017        key: &str,
10018        mask: &crate::mask::NodeMask,
10019    ) -> Option<MaskedNodeResult> {
10020        let id = self.ids.get(key)?;
10021        if mask.contains_id(id) {
10022            Some(MaskedNodeResult::Visible(self.node_info(key)?))
10023        } else {
10024            match mask.mode() {
10025                crate::mask::MaskMode::Stub => Some(MaskedNodeResult::Restricted),
10026                crate::mask::MaskMode::Omit => None,
10027            }
10028        }
10029    }
10030
10031    /// Get edges for `key` with mask-aware hidden-endpoint handling.
10032    ///
10033    /// - Omit mode: edges to hidden endpoints are excluded (same as role-path filtering).
10034    /// - Stub mode: edges to hidden endpoints are included; `src_restricted`/`dst_restricted`
10035    ///   is `true` for each hidden endpoint.
10036    ///
10037    /// Unknown key → [`GraphError::KeyNotFound`].
10038    ///
10039    /// **SECURITY**: only call from client-mask (full-token) paths.
10040    pub fn node_edges_masked(
10041        &self,
10042        key: &str,
10043        mask: &crate::mask::NodeMask,
10044    ) -> Result<Vec<MaskedEdge>> {
10045        self.ensure_v8_base_sections_loaded();
10046        let id = self
10047            .ids
10048            .get(key)
10049            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10050        let derived: BTreeSet<(u32, u32, u32)> = self
10051            .engine
10052            .provenance_touching(id)
10053            .map(|(_rule, etype, src, dst)| (etype, src, dst))
10054            .collect();
10055        let mut edges = Vec::new();
10056        let tv = self.topo_view();
10057        for etype in tv.etypes() {
10058            // etype comes from the archived CSR (access_unchecked, no eager CRC).
10059            // A bit-flip in the large TOPOLOGY section can produce an etype id
10060            // that is not in the interner.  Return Corrupt rather than panic.
10061            let edge_type = self
10062                .syms
10063                .resolve(etype)
10064                .ok_or_else(|| GraphError::Corrupt {
10065                    detail: format!("v8: topology etype {etype} not in interner"),
10066                })?
10067                .to_string();
10068            for dir in [Direction::Out, Direction::In] {
10069                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
10070                    let nbr_restricted = !mask.contains_id(nbr);
10071                    if nbr_restricted && mask.mode() == crate::mask::MaskMode::Omit {
10072                        continue;
10073                    }
10074                    let nbr_key = self
10075                        .ids
10076                        .key_of(nbr)
10077                        .ok_or_else(|| GraphError::Corrupt {
10078                            detail: format!("topology id {nbr} has no key"),
10079                        })?
10080                        .to_string();
10081                    let (src_id, dst_id, src_key, dst_key, src_restricted, dst_restricted) =
10082                        match dir {
10083                            Direction::Out => {
10084                                (id, nbr, key.to_string(), nbr_key, false, nbr_restricted)
10085                            }
10086                            Direction::In => {
10087                                (nbr, id, nbr_key, key.to_string(), nbr_restricted, false)
10088                            }
10089                        };
10090                    edges.push(MaskedEdge {
10091                        edge_type: edge_type.clone(),
10092                        src_key,
10093                        src_restricted,
10094                        dst_key,
10095                        dst_restricted,
10096                        derived: derived.contains(&(etype, src_id, dst_id)),
10097                    });
10098                }
10099            }
10100        }
10101        edges.sort_by(|a, b| {
10102            a.edge_type
10103                .cmp(&b.edge_type)
10104                .then(a.src_key.cmp(&b.src_key))
10105                .then(a.dst_key.cmp(&b.dst_key))
10106        });
10107        edges.dedup_by(|a, b| {
10108            a.edge_type == b.edge_type && a.src_key == b.src_key && a.dst_key == b.dst_key
10109        });
10110        Ok(edges)
10111    }
10112
10113    /// [`node_edges_masked`](Self::node_edges_masked) with the **subject check**
10114    /// a scoped caller needs, and a plain [`EdgeInfo`] list.
10115    ///
10116    /// `node_edges_masked` raises [`GraphError::KeyNotFound`] only when `key` is
10117    /// unknown; a key that exists but is hidden still yields its (filtered) edge
10118    /// list, which is correct for a full-token client mask and an existence
10119    /// oracle for a scoped one. Here a hidden subject answers exactly as an
10120    /// absent one does.
10121    ///
10122    /// Every edge naming a hidden endpoint is dropped, whatever the mask's
10123    /// [`MaskMode`](crate::mask::MaskMode): a scoped caller never sees a
10124    /// restricted stub, so there is nothing for it to render.
10125    ///
10126    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
10127    pub fn node_edges_scoped(
10128        &self,
10129        key: &str,
10130        mask: &crate::mask::NodeMask,
10131    ) -> Result<Vec<EdgeInfo>> {
10132        if !mask.contains_node(self, key) {
10133            return Err(GraphError::KeyNotFound { key: key.into() });
10134        }
10135        Ok(self
10136            .node_edges_masked(key, mask)?
10137            .into_iter()
10138            .filter(|e| !e.src_restricted && !e.dst_restricted)
10139            .map(|e| EdgeInfo {
10140                edge_type: e.edge_type,
10141                src_key: e.src_key,
10142                dst_key: e.dst_key,
10143                derived: e.derived,
10144            })
10145            .collect())
10146    }
10147
10148    /// Every directed edge incident on `key`, both directions, every etype.
10149    ///
10150    /// Walk is `topology.etypes()` × `{Out, In}` × `neighbors()`. `derived` is
10151    /// membership in [`RuleEngine::provenance_touching`] (O(degree) via the
10152    /// Plan-8 `by_node` index). Sorted by `(edge_type, src_key, dst_key)`.
10153    /// Unknown key → [`GraphError::KeyNotFound`].
10154    pub fn node_edges(&self, key: &str) -> Result<Vec<EdgeInfo>> {
10155        self.ensure_v8_base_sections_loaded();
10156        let id = self
10157            .ids
10158            .get(key)
10159            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
10160        let derived: BTreeSet<(u32, u32, u32)> = self
10161            .engine
10162            .provenance_touching(id)
10163            .map(|(_rule, etype, src, dst)| (etype, src, dst))
10164            .collect();
10165        let mut edges = Vec::new();
10166        let tv = self.topo_view();
10167        for etype in tv.etypes() {
10168            // Same guard as node_edges_masked: etype from unchecked-CRC CSR.
10169            let edge_type = self
10170                .syms
10171                .resolve(etype)
10172                .ok_or_else(|| GraphError::Corrupt {
10173                    detail: format!("v8: topology etype {etype} not in interner"),
10174                })?
10175                .to_string();
10176            for dir in [Direction::Out, Direction::In] {
10177                for &nbr in tv.neighbors(etype, dir, id).as_ref() {
10178                    let (src, dst, src_key, dst_key) = match dir {
10179                        Direction::Out => (
10180                            id,
10181                            nbr,
10182                            key.to_string(),
10183                            self.ids
10184                                .key_of(nbr)
10185                                .ok_or_else(|| GraphError::Corrupt {
10186                                    detail: format!("topology id {nbr} has no key"),
10187                                })?
10188                                .to_string(),
10189                        ),
10190                        Direction::In => (
10191                            nbr,
10192                            id,
10193                            self.ids
10194                                .key_of(nbr)
10195                                .ok_or_else(|| GraphError::Corrupt {
10196                                    detail: format!("topology id {nbr} has no key"),
10197                                })?
10198                                .to_string(),
10199                            key.to_string(),
10200                        ),
10201                    };
10202                    edges.push(EdgeInfo {
10203                        edge_type: edge_type.clone(),
10204                        src_key,
10205                        dst_key,
10206                        derived: derived.contains(&(etype, src, dst)),
10207                    });
10208                }
10209            }
10210        }
10211        edges.sort_by(|a, b| {
10212            a.edge_type
10213                .cmp(&b.edge_type)
10214                .then(a.src_key.cmp(&b.src_key))
10215                .then(a.dst_key.cmp(&b.dst_key))
10216        });
10217        // Self-loops appear in both Out and In; sort makes the pair adjacent
10218        // (sort key matches PartialEq for this case) so one pass drops the dup.
10219        edges.dedup();
10220        Ok(edges)
10221    }
10222
10223    // ── Backup ────────────────────────────────────────────────────────────────
10224
10225    /// Copy this store to `dest` as a consistent, verified snapshot.
10226    ///
10227    /// Copies every durable file in the database directory — `snapshot.bin`,
10228    /// `wal.bin`, all `wal.<N>.archive` files, `wal.floor`, `wal.genesis`, and
10229    /// `roles.json` — into a freshly created `dest` directory using OS-level
10230    /// `copy` calls (no large in-process buffers).
10231    ///
10232    /// # Consistency guarantee
10233    ///
10234    /// The guarantee is **process-local**: the caller holds `&self`, which
10235    /// prevents any concurrent writer in the **same process** from modifying
10236    /// the files during the copy.  Running `mushroomdb backup` against a
10237    /// directory that is **concurrently being written by another process** (e.g.
10238    /// `mushroomdb serve`) is **unsafe** — the copy can be torn.  The post-copy
10239    /// `verified: true` result reduces but does not eliminate the risk of a
10240    /// silent corrupt backup (CRC catches many bit-flips; it cannot catch a
10241    /// consistent mid-write snapshot).
10242    ///
10243    /// **The safe path for a live-served store is `POST /backup` on the HTTP
10244    /// server.** That handler acquires the read lock on the shared database
10245    /// before calling this method, which is the correct cross-process
10246    /// synchronisation point because the server is the single process writing
10247    /// the files.
10248    ///
10249    /// After copying, opens the destination read-only and runs the CRC section
10250    /// verifier (`verify_snapshot`) to confirm byte-for-byte integrity.
10251    /// `BackupReport::verified` reflects whether both checks passed.
10252    ///
10253    /// Returns `Err` when `self` is not backed by a `RealFs` (e.g. `SimFs`).
10254    pub fn backup_to(&self, dest: &std::path::Path) -> Result<BackupReport> {
10255        // Derive source directory from snapshot_path (RealFs only).
10256        let src_dir = match self.fs.snapshot_path() {
10257            Some(p) => p.parent().map(|d| d.to_path_buf()).ok_or_else(|| {
10258                GraphError::Io(std::io::Error::other("snapshot has no parent dir"))
10259            })?,
10260            None => {
10261                return Err(GraphError::Io(std::io::Error::other(
10262                    "backup_to requires a real filesystem (RealFs)",
10263                )))
10264            }
10265        };
10266
10267        std::fs::create_dir_all(dest)?;
10268
10269        let mut files: Vec<String> = Vec::new();
10270        let mut bytes: u64 = 0;
10271
10272        // Helper: copy src_dir/name → dest/name if the file exists.
10273        let mut try_copy = |name: &str| -> std::io::Result<()> {
10274            let src_path = src_dir.join(name);
10275            if src_path.exists() {
10276                let n = std::fs::copy(&src_path, dest.join(name))?;
10277                bytes += n;
10278                files.push(name.to_string());
10279            }
10280            Ok(())
10281        };
10282
10283        try_copy("snapshot.bin")?;
10284        try_copy("snapshot.bin.bak")?;
10285        try_copy("wal.bin")?;
10286        try_copy("wal.floor")?;
10287        try_copy("wal.genesis")?;
10288        try_copy("roles.json")?;
10289        // The map from commit to wall-clock time. Without it the copy cannot
10290        // resolve a date to a commit, and answers `NoRecordedTime` for history
10291        // it holds in full (ledger row 86).
10292        try_copy("commit_times.bin")?;
10293        // Empty unless the store's last WAL-replacing snapshot was
10294        // interrupted. Then, without it, the copy is that snapshot beside the
10295        // WAL it was made from, with nothing to say so.
10296        try_copy("snapshot.intent")?;
10297
10298        // Copy WAL archives.
10299        let archives = self.fs.list_archives()?;
10300        for n in &archives {
10301            let name = format!("wal.{n}.archive");
10302            let n_bytes = std::fs::copy(src_dir.join(&name), dest.join(&name))?;
10303            bytes += n_bytes;
10304            files.push(name);
10305        }
10306
10307        files.sort();
10308
10309        // Post-copy verification: open dest and run CRC checks.
10310        let snap_in_dest = dest.join("snapshot.bin").exists();
10311        let crc_ok = if snap_in_dest {
10312            crate::verify_snapshot(dest)
10313                .map(|results| results.iter().all(|(_, _, _, r)| r.is_ok()))
10314                .unwrap_or(false)
10315        } else {
10316            true // WAL-only store: nothing to CRC-check in snapshot
10317        };
10318        let opens_ok = GraphDb::<core_storage::fs::RealFs>::open(dest).is_ok();
10319        let verified = crc_ok && opens_ok;
10320
10321        Ok(BackupReport {
10322            files,
10323            bytes,
10324            verified,
10325        })
10326    }
10327
10328    // ── Export helpers ────────────────────────────────────────────────────────
10329
10330    /// All live nodes, sorted by key (deterministic).
10331    ///
10332    /// Reads base + WAL overlay. Tombstoned nodes are excluded.
10333    pub fn all_nodes_for_export(&self) -> Vec<NodeInfo> {
10334        self.ensure_v8_base_sections_loaded();
10335        let pv = self.props_view();
10336        let mut nodes = Vec::new();
10337        for id in 0..self.ids.len() as u32 {
10338            let Some(key) = self.ids.key_of(id) else {
10339                continue;
10340            };
10341            let Some(&sym) = self.labels.get(id as usize) else {
10342                continue;
10343            };
10344            if sym == u32::MAX {
10345                continue; // tombstoned
10346            }
10347            let Some(label) = self.syms.resolve(sym) else {
10348                continue;
10349            };
10350            let mut props = BTreeMap::new();
10351            for field in pv.field_names() {
10352                if let Some(vr) = pv.get(id, &field) {
10353                    props.insert(field, vr.into_value());
10354                }
10355            }
10356            nodes.push(NodeInfo {
10357                key: key.to_string(),
10358                label: label.to_string(),
10359                props,
10360            });
10361        }
10362        nodes.sort_by(|a, b| a.key.cmp(&b.key));
10363        nodes
10364    }
10365
10366    /// All directed edges, sorted by `(edge_type, src, dst)`. Each edge appears once.
10367    ///
10368    /// Derived edges carry `derived: true` and the creating rule's name in `rule`.
10369    /// Manual edges carry `derived: false` and `rule: None`.
10370    /// `weight` is the creating rule's `weight_prop` value read off the edge
10371    /// (numeric only), mirroring the convention used by [`GraphDb::explain`]
10372    /// and [`GraphDb::weighted_edges`]. Deterministic across runs on the same
10373    /// store state.
10374    pub fn all_edges_for_export(&self) -> Vec<ExportEdge> {
10375        self.ensure_v8_base_sections_loaded();
10376
10377        // Build (etype_sym, src_id, dst_id) → rule_name for O(1) derivation lookup.
10378        let mut prov: HashMap<(u32, u32, u32), String> = HashMap::new();
10379        for (rule_name, triples) in self.engine.provenance() {
10380            for &(etype, src, dst) in triples {
10381                prov.insert((etype, src, dst), rule_name.clone());
10382            }
10383        }
10384
10385        // rule_name → weight_prop, for O(1) lookup per derived edge.
10386        let weight_props: HashMap<&str, Option<&str>> = self
10387            .engine
10388            .rules()
10389            .map(|r| (r.name.as_str(), r.weight_prop.as_deref()))
10390            .collect();
10391
10392        let tv = self.topo_view();
10393        let ep = self.edge_props_view();
10394        let mut edges = Vec::new();
10395
10396        for id in 0..self.ids.len() as u32 {
10397            let Some(key) = self.ids.key_of(id) else {
10398                continue;
10399            };
10400            let Some(&lsym) = self.labels.get(id as usize) else {
10401                continue;
10402            };
10403            if lsym == u32::MAX {
10404                continue; // tombstoned
10405            }
10406
10407            for etype_sym in tv.etypes() {
10408                // etype from archived CSR (access_unchecked, no eager CRC).
10409                // Skip edges whose etype is not in the interner; this can only
10410                // occur with a corrupt large TOPOLOGY section (bit-flip on an
10411                // etype field in the archived data).  The function returns Vec,
10412                // not Result, so we continue rather than propagate.
10413                let Some(edge_type) = self.syms.resolve(etype_sym) else {
10414                    continue;
10415                };
10416                let edge_type = edge_type.to_string();
10417                for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10418                    let Some(dst_key) = self.ids.key_of(nbr) else {
10419                        continue; // skip corrupt entries
10420                    };
10421                    let prov_key = (etype_sym, id, nbr);
10422                    let rule = prov.get(&prov_key).cloned();
10423                    let derived = rule.is_some();
10424                    let weight = rule
10425                        .as_deref()
10426                        .and_then(|rn| weight_props.get(rn).copied().flatten())
10427                        .and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10428                            Some(Value::Float(f)) => Some(f),
10429                            Some(Value::Int(i)) => Some(i as f64),
10430                            _ => None,
10431                        });
10432                    edges.push(ExportEdge {
10433                        edge_type: edge_type.clone(),
10434                        src: key.to_string(),
10435                        dst: dst_key.to_string(),
10436                        derived,
10437                        rule,
10438                        weight,
10439                    });
10440                }
10441            }
10442        }
10443
10444        edges.sort_by(|a, b| {
10445            a.edge_type
10446                .cmp(&b.edge_type)
10447                .then(a.src.cmp(&b.src))
10448                .then(a.dst.cmp(&b.dst))
10449        });
10450        edges
10451    }
10452
10453    /// What each edge type *is*, without building one record per edge.
10454    ///
10455    /// [`all_edges_for_export`](Self::all_edges_for_export) answers the same
10456    /// question by materialising every edge — three `String`s apiece, a
10457    /// provenance `HashMap` over every derived edge, and a final sort. That is
10458    /// the right shape for an export, and the wrong one for a summary: on a
10459    /// store with 1.3 M derived edges it allocates hundreds of megabytes to
10460    /// produce nine lines. This walks the topology instead, summing neighbour
10461    /// slice lengths and collecting *label symbols* rather than label strings,
10462    /// so the per-edge cost is an integer add and a set insert on a set with
10463    /// as many members as the store has labels.
10464    ///
10465    /// The rule names come off the rule *definitions*, which each declare the
10466    /// `edge_type` they derive, so naming them costs one pass over the rules
10467    /// rather than one provenance lookup per edge. That is also why `rules`
10468    /// is a list: two rules may derive the same type — the association store
10469    /// derives `INDUSTRY_ALIGNMENT` from both a talent→company and a
10470    /// talent→job rule — and naming only one of them would be a half-truth.
10471    /// A type with no rules is one written by hand.
10472    ///
10473    /// `sample` is the first edge of the type in the store's own id order,
10474    /// which is insertion order: deterministic for a given store, and not the
10475    /// same as key order, which cannot be had without resolving a key per
10476    /// edge. Sorted by `edge_type`.
10477    pub fn edge_type_census(&self) -> Vec<EdgeTypeCensus> {
10478        self.ensure_v8_base_sections_loaded();
10479
10480        let mut rules_by_type: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
10481        for r in self.engine.rules() {
10482            rules_by_type
10483                .entry(r.edge_type.as_str())
10484                .or_default()
10485                .insert(r.name.as_str());
10486        }
10487
10488        let tv = self.topo_view();
10489        let node_count = self.ids.len() as u32;
10490        let mut out = Vec::new();
10491        for etype_sym in tv.etypes() {
10492            // An etype the interner cannot resolve means a corrupt TOPOLOGY
10493            // section; skip it rather than name it, as `all_edges_for_export`
10494            // does for the same reason.
10495            let Some(edge_type) = self.syms.resolve(etype_sym) else {
10496                continue;
10497            };
10498            let mut edges: u64 = 0;
10499            let mut src_syms: BTreeSet<u32> = BTreeSet::new();
10500            let mut dst_syms: BTreeSet<u32> = BTreeSet::new();
10501            let mut sample: Option<(u32, u32)> = None;
10502            for id in 0..node_count {
10503                let Some(&lsym) = self.labels.get(id as usize) else {
10504                    continue;
10505                };
10506                if lsym == u32::MAX {
10507                    continue; // tombstoned
10508                }
10509                let nbrs = tv.neighbors(etype_sym, Direction::Out, id);
10510                let nbrs = nbrs.as_ref();
10511                if nbrs.is_empty() {
10512                    continue;
10513                }
10514                edges += nbrs.len() as u64;
10515                src_syms.insert(lsym);
10516                for &nbr in nbrs {
10517                    if let Some(&dsym) = self.labels.get(nbr as usize) {
10518                        if dsym != u32::MAX {
10519                            dst_syms.insert(dsym);
10520                        }
10521                    }
10522                }
10523                if sample.is_none() {
10524                    sample = Some((id, nbrs[0]));
10525                }
10526            }
10527            let resolve = |syms: &BTreeSet<u32>| -> Vec<String> {
10528                syms.iter()
10529                    .filter_map(|&s| self.syms.resolve(s))
10530                    .map(ToString::to_string)
10531                    .collect()
10532            };
10533            out.push(EdgeTypeCensus {
10534                edge_type: edge_type.to_string(),
10535                edges,
10536                src_labels: resolve(&src_syms),
10537                dst_labels: resolve(&dst_syms),
10538                rules: rules_by_type
10539                    .get(edge_type)
10540                    .map(|rs| rs.iter().map(ToString::to_string).collect())
10541                    .unwrap_or_default(),
10542                sample: sample.and_then(|(s, d)| {
10543                    Some((
10544                        self.ids.key_of(s)?.to_string(),
10545                        self.ids.key_of(d)?.to_string(),
10546                    ))
10547                }),
10548            });
10549        }
10550        out.sort_by(|a, b| a.edge_type.cmp(&b.edge_type));
10551        out
10552    }
10553
10554    /// All directed edges of `edge_type`, with the raw value of `weight_prop`
10555    /// on each edge when given.
10556    ///
10557    /// `weight` is `Some(f)` only when `weight_prop` is set and the edge
10558    /// carries that property with a numeric (`Int`/`Float`) value; otherwise
10559    /// `None` — callers that want a default weight (e.g. `1.0` for missing
10560    /// props) apply it themselves, matching the convention used internally
10561    /// by [`GraphDb::pagerank`], [`GraphDb::connected_components`],
10562    /// [`GraphDb::degree_centrality`], and [`GraphDb::communities`].
10563    ///
10564    /// Sorted by `(src, dst)` for determinism. Reads the unified topology
10565    /// (manual + rule-derived edges).  An unknown `edge_type` returns an
10566    /// empty vec.
10567    pub fn weighted_edges(
10568        &self,
10569        edge_type: &str,
10570        weight_prop: Option<&str>,
10571    ) -> Vec<(String, String, Option<f64>)> {
10572        let Some(etype_sym) = self.syms.get(edge_type) else {
10573            return Vec::new();
10574        };
10575        let tv = self.topo_view();
10576        let ep = self.edge_props_view();
10577        let mut out = Vec::new();
10578        for id in 0..self.ids.len() as u32 {
10579            let Some(key) = self.ids.key_of(id) else {
10580                continue;
10581            };
10582            let Some(&sym) = self.labels.get(id as usize) else {
10583                continue;
10584            };
10585            if sym == u32::MAX {
10586                continue; // tombstoned
10587            }
10588            for &nbr in tv.neighbors(etype_sym, Direction::Out, id).as_ref() {
10589                let Some(dst_key) = self.ids.key_of(nbr) else {
10590                    continue;
10591                };
10592                let weight = weight_prop.and_then(|prop| match ep.get(etype_sym, id, nbr, prop) {
10593                    Some(Value::Float(f)) => Some(f),
10594                    Some(Value::Int(i)) => Some(i as f64),
10595                    _ => None,
10596                });
10597                out.push((key.to_string(), dst_key.to_string(), weight));
10598            }
10599        }
10600        out.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
10601        out
10602    }
10603
10604    pub fn nodes_with_label(&self, label: &str) -> Vec<NodeRef<'_, F>> {
10605        self.view()
10606            .nodes_with_label(label)
10607            .into_iter()
10608            .map(|id| NodeRef { db: self, id })
10609            .collect()
10610    }
10611
10612    /// Whether any of the first `limit` live nodes under `label`, in id
10613    /// order, carries `field`.
10614    ///
10615    /// Bounded for a caller that asks on every request: one props view, at
10616    /// most `limit` property reads, no value cloned and no `NodeRef` built,
10617    /// and it stops at the first carrier. The label column itself has no
10618    /// per-label index, so on a label with fewer than `limit` nodes the walk
10619    /// of that column runs to its end — integer compares, no property read.
10620    pub(crate) fn first_nodes_carry_prop(&self, label: &str, field: &str, limit: usize) -> bool {
10621        let Some(sym) = self.syms.get(label) else {
10622            return false;
10623        };
10624        let props = self.props_view();
10625        self.labels
10626            .iter()
10627            .enumerate()
10628            .filter(|&(_, &s)| s == sym)
10629            .take(limit)
10630            .any(|(id, _)| props.get(id as u32, field).is_some())
10631    }
10632
10633    pub fn find_nodes(&self, label: &str, filter: &Filter) -> Vec<NodeRef<'_, F>> {
10634        let view = self.view();
10635        view.nodes_with_label(label)
10636            .into_iter()
10637            .filter(|&id| {
10638                eval_filter(filter, &|field| {
10639                    view.prop(id, field).map(|vr| vr.into_value())
10640                })
10641            })
10642            .map(|id| NodeRef { db: self, id })
10643            .collect()
10644    }
10645
10646    /// Returns `true` if any approximate (HNSW) VectorSimilar rule covers
10647    /// `field`.  Use as a capability probe: when `true`, `find_similar_vector`
10648    /// with `label = None` will use the native ANN path rather than the O(n)
10649    /// brute-force scan.
10650    pub fn has_vector_rule(&self, field: &str) -> bool {
10651        self.engine.hnsw_has_rule(field)
10652    }
10653
10654    /// How many HNSW graphs this handle has built from scratch since it was
10655    /// opened (one per side of an approximate rule).
10656    ///
10657    /// An open that restored every graph from the snapshot reports `0`.
10658    /// Exposed for tests that assert the open path reuses the persisted index
10659    /// rather than rebuilding it; not part of the stable surface.
10660    #[doc(hidden)]
10661    pub fn hnsw_build_count(&self) -> u64 {
10662        self.engine.hnsw_build_count()
10663    }
10664
10665    /// Whether the rule engine has built its candidate indexes on this handle.
10666    ///
10667    /// A store opened from a snapshot defers that full node scan until the
10668    /// first write or the first read that needs it. Exposed for tests that
10669    /// assert an open did not pay for it; not part of the stable surface.
10670    #[doc(hidden)]
10671    pub fn rule_indexes_populated(&self) -> bool {
10672        self.engine.indexes_populated()
10673    }
10674
10675    /// How many rules this handle still holds a lazily-decoded HNSW graph for.
10676    ///
10677    /// Zero before the first ANN query on a clean open, and again once the
10678    /// live indexes own the graphs. See [`core_rules::RuleEngine::lazy_hnsw_len`].
10679    /// Exposed for tests that assert the lazy copies are released; not part of
10680    /// the stable surface.
10681    #[doc(hidden)]
10682    pub fn lazy_hnsw_len(&self) -> usize {
10683        self.engine.lazy_hnsw_len()
10684    }
10685
10686    /// Find nodes whose `field` vector is most similar to `q` (cosine
10687    /// similarity), returning up to `k` results with similarity ≥ `min`,
10688    /// sorted descending.
10689    ///
10690    /// When `label` is `None` the search spans all labels (via
10691    /// `hnsw_search_any_dst` or a full brute-force scan); when `label` is
10692    /// `Some(lbl)` it restricts to nodes with that label.
10693    ///
10694    /// Uses the HNSW index when one is available (fast path); otherwise falls
10695    /// back to an O(n) brute-force scan.
10696    ///
10697    /// **The index supplies candidates, never scores.** Its own distances are
10698    /// `f32` (accurate to ~1e-6, so an exact duplicate scores 0.9999999), so
10699    /// every candidate is re-scored from the `f64` property vectors by
10700    /// [`exact_vector_similarity`] before `min`, the ordering and the reported
10701    /// score are decided. `k + VECTOR_RESCORE_MARGIN` candidates are fetched so
10702    /// the re-ordering cannot drop a true top-`k` member; see that constant for
10703    /// the rule. The score a caller receives is therefore the same number the
10704    /// brute-force path would have produced, to `f64` precision, and `min = 1.0`
10705    /// finds an exact duplicate.
10706    pub fn find_similar_vector(
10707        &self,
10708        field: &str,
10709        label: Option<&str>,
10710        q: &[f64],
10711        k: usize,
10712        min: f64,
10713    ) -> Vec<(String, f64)> {
10714        self.find_similar_vector_filtered(field, label, q, k, min, None, None, false)
10715            .expect("find_similar_vector_filtered is infallible without where_")
10716    }
10717
10718    /// Like [`find_similar_vector`] but restricts results to nodes visible in
10719    /// `mask`. Hidden nodes never appear in results; the mask is applied
10720    /// **before** k-truncation so a caller still receives up to `k` visible
10721    /// hits.
10722    ///
10723    /// # HNSW path (widening beam)
10724    ///
10725    /// When an HNSW index covers the request, the beam starts at an over-fetch
10726    /// of `k × n / |visible|` (plus the rescore margin) when the mask's
10727    /// selectivity is known from the index length, otherwise at `k` plus that
10728    /// margin. If fewer than `k` visible candidates remain after the mask and
10729    /// `min` filter, the beam doubles — the same ×2 loop exact `VectorSimilar`
10730    /// rules use, capped at `ef_max()` (`EF_MAX` = 4,096). Reaching the cap,
10731    /// or a beam that comes back short of its own width, falls through to the
10732    /// exhaustive masked scan rather than returning a short result.
10733    ///
10734    /// Every surviving candidate is re-scored from the `f64` property vectors,
10735    /// exactly as [`find_similar_vector`] does and for the same reason.
10736    ///
10737    /// # Brute-force path
10738    ///
10739    /// When no HNSW index covers the request, or the beam cannot admit `k`
10740    /// hits, the function builds a masked [`GraphView`] so that `nodes_all` /
10741    /// `nodes_with_label` return only visible nodes, guaranteeing exact `k`
10742    /// results (or all visible nodes if fewer than `k` exist).
10743    pub fn find_similar_vector_masked(
10744        &self,
10745        field: &str,
10746        label: Option<&str>,
10747        q: &[f64],
10748        k: usize,
10749        min: f64,
10750        mask: &crate::mask::NodeMask,
10751    ) -> Vec<(String, f64)> {
10752        self.find_similar_vector_filtered(field, label, q, k, min, Some(mask), None, false)
10753            .expect("find_similar_vector_filtered is infallible without where_")
10754    }
10755
10756    /// Exact or ANN kNN with optional key-list `mask` and property `where_`.
10757    ///
10758    /// `where_` present and failing [`PropPredicate::validate_named`] `"where"`
10759    /// → `QueryError`. `exact=true` or `where_=Some` skip HNSW and GEMM-brute
10760    /// the candidate set (`label ∩ mask ∩ holds(where)`). `mask` alone still
10761    /// uses HNSW when an index covers the field.
10762    #[allow(clippy::too_many_arguments)]
10763    pub fn find_similar_vector_filtered(
10764        &self,
10765        field: &str,
10766        label: Option<&str>,
10767        q: &[f64],
10768        k: usize,
10769        min: f64,
10770        mask: Option<&crate::mask::NodeMask>,
10771        where_: Option<&PropPredicate>,
10772        exact: bool,
10773    ) -> Result<Vec<(String, f64)>> {
10774        self.find_similar_vector_as(
10775            field,
10776            label,
10777            q,
10778            k,
10779            min,
10780            mask,
10781            where_,
10782            exact,
10783            ExactnessCaller::Vector,
10784        )
10785    }
10786
10787    /// [`find_similar_vector_filtered`](Self::find_similar_vector_filtered)
10788    /// with the caller shape named, so the exactness warning can advise the
10789    /// signature that actually reached it. Everything else is identical.
10790    #[allow(clippy::too_many_arguments)]
10791    fn find_similar_vector_as(
10792        &self,
10793        field: &str,
10794        label: Option<&str>,
10795        q: &[f64],
10796        k: usize,
10797        min: f64,
10798        mask: Option<&crate::mask::NodeMask>,
10799        where_: Option<&PropPredicate>,
10800        exact: bool,
10801        caller: ExactnessCaller,
10802    ) -> Result<Vec<(String, f64)>> {
10803        if let Some(pred) = where_ {
10804            pred.validate_named("where")
10805                .map_err(|detail| GraphError::QueryError { detail })?;
10806        }
10807
10808        // Ensure any HNSW blobs retained from the snapshot are deserialized
10809        // before the first ANN query on a clean-open (no-WAL) path.  The
10810        // section read has to come first: on a clean open nothing else has
10811        // called it, so without it `retained_hnsw_blobs` is empty,
10812        // `ensure_hnsw_loaded` caches an empty map in its `OnceLock`, and every
10813        // approximate query on the handle runs brute force — correct results,
10814        // silently off the index.  Both calls are idempotent and cheap once hot.
10815        self.ensure_v8_base_sections_loaded();
10816        self.engine.ensure_hnsw_loaded();
10817        let norm: f64 = q.iter().map(|x| x * x).sum::<f64>().sqrt();
10818        if norm == 0.0 {
10819            return Ok(vec![]);
10820        }
10821        if let Some(m) = mask {
10822            if k == 0 || m.is_empty() {
10823                return Ok(vec![]);
10824            }
10825        }
10826        let q_unit: Vec<f64> = q.iter().map(|x| x / norm).collect();
10827
10828        // `where` implies exact: a predicate must not ride a silent ANN.
10829        let skip_hnsw = exact || where_.is_some();
10830        if !skip_hnsw {
10831            if let Some(mask) = mask {
10832                if let Some(out) =
10833                    self.find_similar_hnsw_masked(field, label, &q_unit, k, min, mask, caller)
10834                {
10835                    return Ok(out);
10836                }
10837            } else if let Some(out) = self.find_similar_hnsw(field, label, &q_unit, k, min) {
10838                return Ok(out);
10839            }
10840        }
10841
10842        let view = match mask {
10843            Some(m) => self.view_masked(m),
10844            None => self.view(),
10845        };
10846        let candidate_ids = Self::vector_candidates(&view, label, where_);
10847        Ok(self.brute_vector_hits(&view, candidate_ids, field, &q_unit, k, min))
10848    }
10849
10850    /// Unmasked HNSW path. `None` when no populated index covers the request.
10851    fn find_similar_hnsw(
10852        &self,
10853        field: &str,
10854        label: Option<&str>,
10855        q_unit: &[f64],
10856        k: usize,
10857        min: f64,
10858    ) -> Option<Vec<(String, f64)>> {
10859        // Try HNSW fast path.
10860        // `None` label searches across all VectorSimilar rules covering `field`
10861        // (merging their results); `Some(lbl)` restricts to rules whose
10862        // dst_label matches.  Returns `None` when no populated HNSW index
10863        // covers the request — the O(n) brute-force fallback handles that case.
10864        let over_k = k.saturating_add(VECTOR_RESCORE_MARGIN);
10865        let hits = match label {
10866            Some(lbl) => self.engine.hnsw_search_dst(field, lbl, q_unit, over_k)?,
10867            None => self.engine.hnsw_search_any_dst(field, q_unit, over_k)?,
10868        };
10869        // Candidates only: the index's `f32` similarity is discarded and
10870        // each hit is re-scored against the `f64` vectors.
10871        let view = self.view();
10872        let mut out: Vec<(String, f64)> = hits
10873            .into_iter()
10874            .filter_map(|(id, _)| {
10875                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
10876                if sim < min {
10877                    return None;
10878                }
10879                Some((self.ids.key_of(id)?.to_string(), sim))
10880            })
10881            .collect();
10882        out.sort_by(|a, b| {
10883            b.1.partial_cmp(&a.1)
10884                .unwrap_or(std::cmp::Ordering::Equal)
10885                .then_with(|| a.0.cmp(&b.0))
10886        });
10887        out.truncate(k);
10888        Some(out)
10889    }
10890
10891    /// Masked HNSW widening beam. `None` when no index covers the request or
10892    /// the beam cannot admit `k` visible hits (caller falls through to brute).
10893    #[allow(clippy::too_many_arguments)]
10894    fn find_similar_hnsw_masked(
10895        &self,
10896        field: &str,
10897        label: Option<&str>,
10898        q_unit: &[f64],
10899        k: usize,
10900        min: f64,
10901        mask: &crate::mask::NodeMask,
10902        caller: ExactnessCaller,
10903    ) -> Option<Vec<(String, f64)>> {
10904        let index_len = match label {
10905            Some(lbl) => self.engine.hnsw_dst_len(field, lbl, q_unit.len()),
10906            None => self.engine.hnsw_any_dst_len(field, q_unit.len()),
10907        };
10908        let n = index_len?;
10909        // The `?` above is the coverage test: past it, an index exists and this
10910        // masked, non-exact call is about to ride it.
10911        self.note_ambiguous_exactness(field, label, caller);
10912        // Same ceiling the exact-rule widening loop in `hnsw_candidates`
10913        // consults — including the `with_ef_max` test hook.
10914        let cap = ef_max();
10915        let visible = mask.len();
10916        let mut ef = k.saturating_add(VECTOR_RESCORE_MARGIN);
10917        if visible > 0 && n > 0 {
10918            let over = k
10919                .saturating_mul(n)
10920                .div_ceil(visible)
10921                .saturating_add(VECTOR_RESCORE_MARGIN);
10922            ef = ef.max(over);
10923        }
10924        loop {
10925            let hits = match label {
10926                Some(lbl) => self
10927                    .engine
10928                    .hnsw_search_dst_with_ef(field, lbl, q_unit, ef, ef),
10929                None => self
10930                    .engine
10931                    .hnsw_search_any_dst_with_ef(field, q_unit, ef, ef),
10932            };
10933            let hits = hits?;
10934            let full = hits.len() == ef;
10935            let mut out = self.score_masked_hnsw_hits(&hits, field, q_unit, min, mask);
10936            if out.len() >= k {
10937                out.truncate(k);
10938                return Some(out);
10939            }
10940            // Short of its width (frontier exhausted) or at the ceiling:
10941            // a wider beam reaches nothing new, so the scan answers.
10942            if !full || ef >= cap {
10943                return None;
10944            }
10945            ef = ef.saturating_mul(2);
10946        }
10947    }
10948
10949    /// Say once, per `(field, label)` index and caller shape, that a masked
10950    /// search is answering approximately.
10951    ///
10952    /// A mask narrows *which nodes may be returned*. It does not choose a
10953    /// kernel — `exact=true` and a `where=` predicate do, and nothing else
10954    /// does. A caller who needed exact answers, passed `mask=` alone, and read
10955    /// the mask as a promise of exhaustiveness gets a correct-looking
10956    /// approximate answer and no signal at all; that is a silent wrong answer,
10957    /// and it has cost an integration team real time.
10958    ///
10959    /// The fix is a question, not a behaviour change. Making a mask imply
10960    /// `exact` would turn every existing masked caller's ANN into an O(n) GEMM
10961    /// without asking them, which is a worse trade than the ambiguity.
10962    ///
10963    /// Printed once per index for the reason the dimension-mismatch skip in
10964    /// `core_rules::hnsw` is: a line on every call is a line callers learn to
10965    /// scroll past.
10966    ///
10967    /// `caller` decides the advice. The same leg is reached from two signatures
10968    /// and only one of them has an `exact` argument to pass; see
10969    /// [`ExactnessCaller`].
10970    fn note_ambiguous_exactness(&self, field: &str, label: Option<&str>, caller: ExactnessCaller) {
10971        let entry = (field.to_string(), label.unwrap_or("").to_string(), caller);
10972        let first = match self.warned_ambiguous_exactness.lock() {
10973            Ok(mut seen) => seen.insert(entry),
10974            Err(poisoned) => poisoned.into_inner().insert(entry),
10975        };
10976        if !first {
10977            return;
10978        }
10979        AMBIGUOUS_EXACTNESS_WARNS.with(|c| c.set(c.get().saturating_add(1)));
10980        let which = match label {
10981            Some(lbl) => format!(" (label `{lbl}`)"),
10982            None => String::new(),
10983        };
10984        let subject = caller.subject();
10985        let advice = caller.advice();
10986        let line = format!(
10987            "mushroomdb: {subject} on field `{field}`{which} is answering \
10988             approximately. A mask narrows which nodes may be returned; it does not \
10989             change which kernel runs, and an index covers this field. For an exact \
10990             answer over the same visible candidate set, {advice} Further masked \
10991             searches of this shape on this index are silent."
10992        );
10993        AMBIGUOUS_EXACTNESS_LAST.with(|c| *c.borrow_mut() = Some(line.clone()));
10994        eprintln!("{line}");
10995    }
10996
10997    /// `label ∩ mask ∩ holds(where)`. Index fast path when `label` is `Some`
10998    /// and `(label, where.field)` is enabled; otherwise scan with `visible()`.
10999    fn vector_candidates(
11000        view: &GraphView<'_>,
11001        label: Option<&str>,
11002        where_: Option<&PropPredicate>,
11003    ) -> Vec<u32> {
11004        if let (Some(lbl), Some(pred)) = (label, where_) {
11005            let indexed = view
11006                .prop_index
11007                .is_some_and(|idx| idx.is_enabled(lbl, &pred.field));
11008            if indexed {
11009                match (&pred.eq, &pred.in_) {
11010                    (Some(eq), None) => {
11011                        if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, eq) {
11012                            return ids;
11013                        }
11014                    }
11015                    (None, Some(allowed)) => {
11016                        let mut seen = HashSet::new();
11017                        let mut out = Vec::new();
11018                        for v in allowed {
11019                            if let Some(ids) = view.nodes_with_prop(lbl, &pred.field, v) {
11020                                for id in ids {
11021                                    if seen.insert(id) {
11022                                        out.push(id);
11023                                    }
11024                                }
11025                            }
11026                        }
11027                        return out;
11028                    }
11029                    _ => {}
11030                }
11031            }
11032        }
11033
11034        let mut ids: Vec<u32> = match label {
11035            Some(lbl) => view
11036                .nodes_with_label(lbl)
11037                .into_iter()
11038                .filter(|&id| view.visible(id))
11039                .collect(),
11040            None => view.nodes_all(),
11041        };
11042        if let Some(pred) = where_ {
11043            ids.retain(|&id| match view.prop(id, &pred.field) {
11044                None => pred.holds(None),
11045                Some(vr) => pred.holds(Some(vr.as_value())),
11046            });
11047        }
11048        ids
11049    }
11050
11051    /// Exact brute kNN: pack candidates at `q_unit`'s dim, GEMV, keep
11052    /// `score >= min`, sort `(sim desc, key asc)`, truncate to `k`.
11053    fn brute_vector_hits(
11054        &self,
11055        view: &GraphView<'_>,
11056        candidate_ids: impl IntoIterator<Item = u32>,
11057        field: &str,
11058        q_unit: &[f64],
11059        k: usize,
11060        min: f64,
11061    ) -> Vec<(String, f64)> {
11062        let rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = candidate_ids
11063            .into_iter()
11064            .filter_map(|id| crate::exact_knn::vector_f64(view, id, field).map(|v| (id, v)))
11065            .collect();
11066        let packed =
11067            crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), q_unit.len());
11068        let scores = crate::exact_knn::gemv(&packed, q_unit);
11069        let mut scored: Vec<(String, f64)> = packed
11070            .ids
11071            .iter()
11072            .zip(scores.iter())
11073            .filter_map(|(&id, &sim)| {
11074                if sim < min {
11075                    return None;
11076                }
11077                let key = self.ids.key_of(id)?.to_string();
11078                Some((key, sim))
11079            })
11080            .collect();
11081        scored.sort_by(|a, b| {
11082            b.1.partial_cmp(&a.1)
11083                .unwrap_or(std::cmp::Ordering::Equal)
11084                .then_with(|| a.0.cmp(&b.0))
11085        });
11086        scored.truncate(k);
11087        scored
11088    }
11089
11090    /// Exact cosine top-k for each key in `keys`, scored only against `keys`.
11091    ///
11092    /// `min` is cosine similarity in [-1, 1], inclusive (`score >= min`), the
11093    /// same unit and inequality as `find_similar_vector`. Self-matches are
11094    /// excluded. Unknown keys, keys with no `field`, zero-norm or wrong-dim
11095    /// embeddings are omitted as both query and candidate. Duplicate keys are
11096    /// collapsed, first-seen order. Empty `keys` → empty `Ok(vec![])`. Never
11097    /// uses HNSW. `n > PAIRWISE_MAX_N` → `QueryError`.
11098    #[allow(clippy::type_complexity)]
11099    pub fn pairwise_similar(
11100        &self,
11101        keys: &[&str],
11102        field: &str,
11103        k: usize,
11104        min: f64,
11105    ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
11106        let mut seen = HashSet::new();
11107        let mut unique_ids = Vec::new();
11108        for key in keys {
11109            let Some(id) = self.ids.get(key) else {
11110                continue;
11111            };
11112            if seen.insert(id) {
11113                unique_ids.push(id);
11114            }
11115        }
11116        let max_n = crate::exact_knn::pairwise_max_n();
11117        if unique_ids.len() > max_n {
11118            return Err(GraphError::QueryError {
11119                detail: format!(
11120                    "pairwise_similar: n={} exceeds PAIRWISE_MAX_N ({max_n})",
11121                    unique_ids.len()
11122                ),
11123            });
11124        }
11125        if unique_ids.is_empty() {
11126            return Ok(Vec::new());
11127        }
11128
11129        let view = self.view();
11130        let mut rows: Vec<(u32, std::borrow::Cow<'_, [f64]>)> = Vec::new();
11131        let mut counts: HashMap<usize, usize> = HashMap::new();
11132        for id in unique_ids {
11133            let Some(v) = crate::exact_knn::vector_f64(&view, id, field) else {
11134                continue;
11135            };
11136            let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
11137            if norm == 0.0 {
11138                continue;
11139            }
11140            *counts.entry(v.len()).or_default() += 1;
11141            rows.push((id, v));
11142        }
11143        if rows.is_empty() {
11144            return Ok(Vec::new());
11145        }
11146        let dim = counts
11147            .into_iter()
11148            .max_by_key(|&(d, c)| (c, d))
11149            .map(|(d, _)| d)
11150            .expect("rows non-empty");
11151        let packed = crate::exact_knn::pack(rows.iter().map(|(id, v)| (*id, v.as_ref())), dim);
11152        let n = packed.ids.len();
11153        if n == 0 {
11154            return Ok(Vec::new());
11155        }
11156        let src_keys: Vec<String> = packed
11157            .ids
11158            .iter()
11159            .map(|&id| self.ids.key_of(id).unwrap_or("").to_string())
11160            .collect();
11161
11162        let mut out = Vec::with_capacity(n);
11163        if n <= crate::exact_knn::pairwise_gram_max() {
11164            let sims = crate::exact_knn::gram(&packed);
11165            for i in 0..n {
11166                out.push(Self::topk_from_row(
11167                    &src_keys,
11168                    i,
11169                    &sims[i * n..(i + 1) * n],
11170                    k,
11171                    min,
11172                ));
11173            }
11174        } else {
11175            for i in 0..n {
11176                let row = &packed.data[i * packed.dim..(i + 1) * packed.dim];
11177                let scores = crate::exact_knn::gemv(&packed, row);
11178                out.push(Self::topk_from_row(&src_keys, i, &scores, k, min));
11179            }
11180        }
11181        Ok(out)
11182    }
11183
11184    /// [`pairwise_similar`](Self::pairwise_similar) over the keys the mask
11185    /// admits — intersected **before** the matmul, never filtered after it.
11186    ///
11187    /// A hidden vector packed into the Gram is a row every visible key is
11188    /// scored against. It can take a visible neighbour's place in the top-`k`,
11189    /// and because the packed dimension is a majority vote over the candidate
11190    /// rows it can decide whether a visible pair is scored at all. Dropping
11191    /// hidden names from the finished answer leaves both effects standing, so
11192    /// the intersection happens first and the answer is byte-for-byte the one
11193    /// `pairwise_similar` gives for the visible keys alone.
11194    ///
11195    /// The caps therefore measure the **post-filter** count: a key set over
11196    /// [`PAIRWISE_MAX_N`](crate::PAIRWISE_MAX_N) unscoped can come under it
11197    /// scoped and succeed, because the work the cap refuses is work this call
11198    /// no longer does. A filtered count still over the cap is still refused.
11199    #[allow(clippy::type_complexity)]
11200    pub fn pairwise_similar_scoped(
11201        &self,
11202        keys: &[&str],
11203        field: &str,
11204        k: usize,
11205        min: f64,
11206        mask: &crate::mask::NodeMask,
11207    ) -> Result<Vec<(String, Vec<(String, f64)>)>> {
11208        let visible: Vec<&str> = keys
11209            .iter()
11210            .copied()
11211            .filter(|key| mask.contains_node(self, key))
11212            .collect();
11213        self.pairwise_similar(&visible, field, k, min)
11214    }
11215
11216    /// Neighbours of packed row `i`: drop self, keep `score >= min`, sort
11217    /// `(sim desc, key asc)`, truncate to `k`. Packed srcs with no survivors
11218    /// still appear as `(src, [])`.
11219    fn topk_from_row(
11220        src_keys: &[String],
11221        i: usize,
11222        scores: &[f64],
11223        k: usize,
11224        min: f64,
11225    ) -> (String, Vec<(String, f64)>) {
11226        let mut neigh: Vec<(String, f64)> = scores
11227            .iter()
11228            .enumerate()
11229            .filter_map(|(j, &sim)| {
11230                if i == j || sim < min {
11231                    return None;
11232                }
11233                Some((src_keys[j].clone(), sim))
11234            })
11235            .collect();
11236        neigh.sort_by(|a, b| {
11237            b.1.partial_cmp(&a.1)
11238                .unwrap_or(std::cmp::Ordering::Equal)
11239                .then_with(|| a.0.cmp(&b.0))
11240        });
11241        neigh.truncate(k);
11242        (src_keys[i].clone(), neigh)
11243    }
11244
11245    /// Re-score HNSW candidates from the `f64` vectors, drop hidden / below-`min`
11246    /// hits, order by score then key. The index's own `f32` similarity is discarded.
11247    fn score_masked_hnsw_hits(
11248        &self,
11249        hits: &[(u32, f64)],
11250        field: &str,
11251        q_unit: &[f64],
11252        min: f64,
11253        mask: &crate::mask::NodeMask,
11254    ) -> Vec<(String, f64)> {
11255        let view = self.view_masked(mask);
11256        let mut out: Vec<(String, f64)> = hits
11257            .iter()
11258            .copied()
11259            .filter(|&(id, _)| mask.contains_id(id))
11260            .filter_map(|(id, _)| {
11261                let sim = exact_vector_similarity(&view, id, field, q_unit)?;
11262                if sim < min {
11263                    return None;
11264                }
11265                Some((self.ids.key_of(id)?.to_string(), sim))
11266            })
11267            .collect();
11268        out.sort_by(|a, b| {
11269            b.1.partial_cmp(&a.1)
11270                .unwrap_or(std::cmp::Ordering::Equal)
11271                .then_with(|| a.0.cmp(&b.0))
11272        });
11273        out
11274    }
11275
11276    /// Read a single property from an edge.
11277    ///
11278    /// Returns `None` when the edge does not exist, the field is absent, or any
11279    /// of the string keys cannot be resolved to interned ids.  Only edge props
11280    /// written by rules (weight fields) are accessible without a `set_edge_prop`
11281    /// binding; topology-only edges (no props set) return `None` for every field.
11282    pub fn get_edge_prop(
11283        &self,
11284        edge_type: &str,
11285        src_key: &str,
11286        dst_key: &str,
11287        field: &str,
11288    ) -> Option<Value> {
11289        let etype = self.syms.get(edge_type)?;
11290        let src = self.ids.get(src_key)?;
11291        let dst = self.ids.get(dst_key)?;
11292        self.edge_props_view().get(etype, src, dst, field)
11293    }
11294
11295    /// Lex → parse → plan → execute `cypher` over a read-only view.
11296    /// Every pipeline `Err(String)` becomes `GraphError::QueryError` with a
11297    /// stage prefix (`lex:` / `parse:` / `plan:` / `execute:`).
11298    pub fn query(&self, cypher: &str, params: &BTreeMap<String, Value>) -> Result<ResultSet> {
11299        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
11300            detail: format!("lex: {e}"),
11301        })?;
11302        let union = parse_read(&tokens).map_err(|e| GraphError::QueryError {
11303            detail: format!("parse: {e}"),
11304        })?;
11305        let t0 = std::time::Instant::now();
11306        let result = execute_union(&self.view(), &union, &Params(params)).map_err(|e| {
11307            GraphError::QueryError {
11308                detail: format!("execute: {e}"),
11309            }
11310        });
11311        let elapsed_ms = t0.elapsed().as_millis() as u64;
11312        let threshold = self.slow_query_threshold_ms;
11313        if threshold > 0 && elapsed_ms >= threshold {
11314            eprintln!("[mushroomdb] slow query ({elapsed_ms}ms): {cypher}");
11315            let entry = SlowQueryEntry {
11316                ms: elapsed_ms,
11317                query: cypher.to_string(),
11318                at_commit: self.commit_seq,
11319            };
11320            if let Ok(mut log) = self.slow_queries.lock() {
11321                if log.entries.len() == SLOW_QUERY_RING_CAP {
11322                    log.entries.pop_front();
11323                }
11324                log.entries.push_back(entry);
11325                log.total += 1;
11326            }
11327        }
11328        result
11329    }
11330
11331    /// Convenience entry-point that accepts a slice of `(name, value)` pairs
11332    /// instead of a pre-built `BTreeMap`.  Equivalent to building the map and
11333    /// calling [`GraphDb::query`].
11334    pub fn query_with_params(&self, cypher: &str, params: &[(&str, Value)]) -> Result<ResultSet> {
11335        let map: BTreeMap<String, Value> = params
11336            .iter()
11337            .map(|(k, v)| (k.to_string(), v.clone()))
11338            .collect();
11339        self.query(cypher, &map)
11340    }
11341
11342    /// Execute a Cypher write statement (CREATE / MATCH…SET / MATCH…DELETE / MERGE).
11343    ///
11344    /// All mutations flow through the same `insert_node` / `set_prop` /
11345    /// `delete_edge` / `insert_edge` path as the Rust API so the rule engine
11346    /// fires and the WAL captures everything with one fsync per statement.
11347    ///
11348    /// Returns a one-row [`ResultSet`] with columns `created`, `properties_set`,
11349    /// and `deleted` matching the write-result contract.
11350    ///
11351    /// **Mutation routing**: mutations are collected into a single
11352    /// [`BatchBuilder`] and committed atomically (one WAL `Batch` frame, one
11353    /// fsync). The MATCH phase for SET/DELETE uses a read-only `execute` call
11354    /// over `self.view()` — the borrow is dropped before the batch is opened.
11355    ///
11356    /// **Limitations (v1)**:
11357    /// - SET RHS must be a literal, `$param`, or arithmetic; bare property copy → named error.
11358    /// - `DETACH DELETE n` → calls `delete_node` for each matched node (removes all edges).
11359    /// - Bare `DELETE n` → error if n has any incident edges; succeeds for isolated nodes.
11360    /// - MERGE supports `ON CREATE SET` / `ON MATCH SET` in the same write batch.
11361    /// - Deleting a derived edge → named error "cannot delete derived edge".
11362    pub fn query_write(
11363        &mut self,
11364        cypher: &str,
11365        params: &BTreeMap<String, Value>,
11366    ) -> Result<ResultSet> {
11367        let tokens = lex(cypher).map_err(|e| GraphError::QueryError {
11368            detail: format!("lex: {e}"),
11369        })?;
11370        let stmt = parse_write(&tokens).map_err(|e| GraphError::QueryError {
11371            detail: format!("parse: {e}"),
11372        })?;
11373        self.exec_write_stmt(stmt, params)
11374    }
11375
11376    fn exec_write_stmt(
11377        &mut self,
11378        stmt: WriteStatement,
11379        params: &BTreeMap<String, Value>,
11380    ) -> Result<ResultSet> {
11381        match stmt {
11382            WriteStatement::Create(s) => self.exec_create(s, params),
11383            WriteStatement::MatchSet(s) => self.exec_match_set(s, params),
11384            WriteStatement::MatchDelete(s) => self.exec_match_delete(s, params),
11385            WriteStatement::MatchDeleteNode(s) => self.exec_match_delete_node(s, params),
11386            WriteStatement::Merge(s) => self.exec_merge(s, params),
11387        }
11388    }
11389
11390    fn exec_create(
11391        &mut self,
11392        stmt: core_query::cypher::CreateStmt,
11393        params: &BTreeMap<String, Value>,
11394    ) -> Result<ResultSet> {
11395        // Extract the node key from props: require a string-valued `id` field.
11396        let mut var_to_key: BTreeMap<String, String> = BTreeMap::new();
11397        for node in &stmt.nodes {
11398            let var = node.var.as_deref().unwrap_or("_cn0");
11399            let key = node
11400                .props
11401                .iter()
11402                .find(|(f, _)| f == "id")
11403                .and_then(|(_, v)| {
11404                    if let Value::Str(s) = v {
11405                        Some(s.clone())
11406                    } else {
11407                        None
11408                    }
11409                })
11410                .ok_or_else(|| GraphError::QueryError {
11411                    detail: format!(
11412                        "CREATE node ({}:{}) requires a string 'id' property",
11413                        var, node.label
11414                    ),
11415                })?;
11416            var_to_key.insert(var.to_string(), key);
11417        }
11418
11419        let mut batch = self.batch();
11420        let mut created: usize = 0;
11421        for node in &stmt.nodes {
11422            let var = node.var.as_deref().unwrap_or("_cn0");
11423            let key = &var_to_key[var];
11424            batch.insert_node(&node.label, key, node.props.clone());
11425            created += 1;
11426        }
11427        for edge in &stmt.edges {
11428            let src_key = var_to_key
11429                .get(&edge.src_var)
11430                .ok_or_else(|| GraphError::QueryError {
11431                    detail: format!("CREATE edge src variable '{}' is not bound", edge.src_var),
11432                })?;
11433            let dst_key = var_to_key
11434                .get(&edge.dst_var)
11435                .ok_or_else(|| GraphError::QueryError {
11436                    detail: format!("CREATE edge dst variable '{}' is not bound", edge.dst_var),
11437                })?;
11438            batch.insert_edge(&edge.etype, src_key, dst_key);
11439        }
11440        batch.commit()?;
11441
11442        // Optional RETURN clause: project created bindings as a read result.
11443        if let Some(returns) = stmt.returns {
11444            // Each created node is looked up by its key via a separate MATCH pattern.
11445            // Multiple single-node patterns cross-join to produce 1 output row with
11446            // all variables bound (each pattern returns exactly 1 row).
11447            let patterns: Vec<Pattern> = stmt
11448                .nodes
11449                .iter()
11450                .map(|node| {
11451                    let var = node.var.as_deref().unwrap_or("_cn0");
11452                    let key = var_to_key[var].clone();
11453                    Pattern {
11454                        start: NodePat {
11455                            var: Some(var.to_string()),
11456                            label: Some(node.label.clone()),
11457                            props: vec![("id".to_string(), Operand::Lit(Value::Str(key)))],
11458                        },
11459                        chain: vec![],
11460                        shortest: false,
11461                    }
11462                })
11463                .collect();
11464            let q = Query {
11465                matches: patterns,
11466                optional_clauses: vec![],
11467                where_expr: None,
11468                unwinds: vec![],
11469                post_unwind_where: None,
11470                stages: vec![],
11471                returns,
11472                distinct: false,
11473                order_by: vec![],
11474                skip: None,
11475                limit: None,
11476            };
11477            let ops = plan(&q).map_err(|e| GraphError::QueryError {
11478                detail: format!("plan: {e}"),
11479            })?;
11480            return execute(&self.view(), &ops, &Params(params)).map_err(|e| {
11481                GraphError::QueryError {
11482                    detail: format!("execute: {e}"),
11483                }
11484            });
11485        }
11486
11487        let mut rs = write_result_set();
11488        rs.push_row(vec![
11489            Some(Value::Int(created as i64)),
11490            Some(Value::Int(0)),
11491            Some(Value::Int(0)),
11492        ]);
11493        Ok(rs)
11494    }
11495
11496    fn exec_match_set(
11497        &mut self,
11498        stmt: core_query::cypher::MatchSetStmt,
11499        params: &BTreeMap<String, Value>,
11500    ) -> Result<ResultSet> {
11501        let project_returns = stmt.returns.clone();
11502        // Collect unique node vars targeted by SET clauses, plus RETURN bindings
11503        // so the post-write projection can look them up by key.
11504        let mut set_vars: Vec<String> = Vec::new();
11505        for s in &stmt.sets {
11506            if !set_vars.contains(&s.var) {
11507                set_vars.push(s.var.clone());
11508            }
11509        }
11510        let rel_vars = pattern_rel_vars(&stmt.matches);
11511        // `count` is the engine's, on an edge: it is the insert-count §5.13
11512        // maintains, and a `SET` that overwrote it would make the number mean
11513        // whatever the last writer said rather than how many times the pair was
11514        // inserted. Refused by name here, before the match runs, so the caller
11515        // is told what is actually wrong instead of meeting the executor's
11516        // generic "did not resolve to a node key" — and so the answer does not
11517        // depend on whether the pattern happened to match a row. The same name
11518        // on a *node* is an ordinary property and is untouched.
11519        for s in &stmt.sets {
11520            if s.field == EDGE_COUNT_PROP && rel_vars.iter().any(|r| r == &s.var) {
11521                return Err(GraphError::QueryError {
11522                    detail: format!(
11523                        "cannot SET {}.{EDGE_COUNT_PROP}: `{EDGE_COUNT_PROP}` is a reserved edge \
11524                         property holding the pair's insert count",
11525                        s.var
11526                    ),
11527                });
11528            }
11529        }
11530        let mut lookup_vars = set_vars.clone();
11531        for v in pattern_node_vars(&stmt.matches) {
11532            add_var(&mut lookup_vars, &v);
11533        }
11534        if let Some(ref returns) = project_returns {
11535            for v in ret_node_vars(returns) {
11536                if !rel_vars.iter().any(|r| r == &v) {
11537                    add_var(&mut lookup_vars, &v);
11538                }
11539            }
11540        }
11541
11542        // Synthesize a read query: MATCH … WHERE … RETURN <lookup_vars>, <set_values…>
11543        // SET values are projected as ScalarExpr items so that arithmetic expressions
11544        // (e.g. `SET n.score = n.score * 1.5`) are evaluated in the matched-row context.
11545        let mut set_returns: Vec<RetItem> = lookup_vars
11546            .iter()
11547            .map(|v| RetItem {
11548                value: RetVal::Var(v.clone()),
11549                alias: None,
11550            })
11551            .collect();
11552        // One computed column per SET clause; alias is `__sv_<i>`.
11553        let set_val_cols: Vec<String> = stmt
11554            .sets
11555            .iter()
11556            .enumerate()
11557            .map(|(i, _)| format!("__sv_{i}"))
11558            .collect();
11559        for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11560            set_returns.push(RetItem {
11561                value: RetVal::ScalarExpr(sc.value.clone()),
11562                alias: Some(col.clone()),
11563            });
11564        }
11565        // Capture relationship types while r is bound; SET does not change them.
11566        for r in &rel_vars {
11567            set_returns.push(RetItem {
11568                value: RetVal::FuncCall {
11569                    name: "type".into(),
11570                    args: vec![Operand::Var(r.clone())],
11571                },
11572                alias: Some(rel_type_alias(r)),
11573            });
11574        }
11575
11576        let read_q = Query {
11577            matches: stmt.matches.clone(),
11578            optional_clauses: vec![],
11579            where_expr: stmt.where_expr.clone(),
11580            unwinds: vec![],
11581            post_unwind_where: None,
11582            stages: vec![],
11583            returns: set_returns,
11584            distinct: false,
11585            order_by: vec![],
11586            skip: None,
11587            limit: None,
11588        };
11589        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11590            detail: format!("plan: {e}"),
11591        })?;
11592        // MATCH phase is read-only; borrow ends before batch opens.
11593        //
11594        // When a role-scoped write is in flight, run the MATCH read through
11595        // view_masked so hidden nodes are invisible → hidden ≡ absent ≡
11596        // zero-rows (no SetProp ops generated, no existence-oracle 403).
11597        // Full-authority writes (pending_write_authz=None) keep view().
11598        let match_rs = {
11599            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11600            if let Some(ref mask) = mask_opt {
11601                execute(&self.view_masked(mask), &ops, &Params(params))
11602            } else {
11603                execute(&self.view(), &ops, &Params(params))
11604            }
11605        }
11606        .map_err(|e| GraphError::QueryError {
11607            detail: format!("execute: {e}"),
11608        })?;
11609
11610        // Collect (key, field, value) for each matched row × each SET clause.
11611        let mut set_ops: Vec<(String, String, Value)> = Vec::new();
11612        for row_i in 0..match_rs.len() {
11613            for (sc, col) in stmt.sets.iter().zip(&set_val_cols) {
11614                let key = match match_rs.get(row_i, &sc.var) {
11615                    Some(Value::Str(k)) => k.clone(),
11616                    _ => {
11617                        return Err(GraphError::QueryError {
11618                            detail: format!(
11619                                "SET variable '{}' did not resolve to a node key",
11620                                sc.var
11621                            ),
11622                        })
11623                    }
11624                };
11625                // The SET value was already evaluated by the executor.
11626                let value = match match_rs.get(row_i, col) {
11627                    Some(v) => v.clone(),
11628                    None => {
11629                        return Err(GraphError::QueryError {
11630                            detail: format!(
11631                                "SET value for {}.{} evaluated to null",
11632                                sc.var, sc.field
11633                            ),
11634                        })
11635                    }
11636                };
11637                set_ops.push((key, sc.field.clone(), value));
11638            }
11639        }
11640
11641        // Apply as one atomic batch.
11642        let props_set = set_ops.len();
11643        let mut batch = self.batch();
11644        for (key, field, value) in set_ops {
11645            batch.set_prop(&key, &field, value);
11646        }
11647        batch.commit()?;
11648
11649        if let Some(returns) = project_returns {
11650            return project_set_return_rows(self, &rel_vars, &match_rs, &returns, params);
11651        }
11652
11653        let mut rs = write_result_set();
11654        rs.push_row(vec![
11655            Some(Value::Int(0)),
11656            Some(Value::Int(props_set as i64)),
11657            Some(Value::Int(0)),
11658        ]);
11659        Ok(rs)
11660    }
11661
11662    fn exec_match_delete(
11663        &mut self,
11664        stmt: core_query::cypher::MatchDeleteStmt,
11665        params: &BTreeMap<String, Value>,
11666    ) -> Result<ResultSet> {
11667        // Collect unique node vars needed to identify edge endpoints.
11668        let mut node_vars: Vec<String> = Vec::new();
11669        for ed in &stmt.deletes {
11670            if !node_vars.contains(&ed.src_var) {
11671                node_vars.push(ed.src_var.clone());
11672            }
11673            if !node_vars.contains(&ed.dst_var) {
11674                node_vars.push(ed.dst_var.clone());
11675            }
11676        }
11677
11678        // Synthesize read query.
11679        let returns: Vec<RetItem> = node_vars
11680            .iter()
11681            .map(|v| RetItem {
11682                value: RetVal::Var(v.clone()),
11683                alias: None,
11684            })
11685            .collect();
11686        let read_q = Query {
11687            matches: stmt.matches,
11688            optional_clauses: vec![],
11689            where_expr: stmt.where_expr,
11690            unwinds: vec![],
11691            post_unwind_where: None,
11692            stages: vec![],
11693            returns,
11694            distinct: false,
11695            order_by: vec![],
11696            skip: None,
11697            limit: None,
11698        };
11699        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11700            detail: format!("plan: {e}"),
11701        })?;
11702        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11703        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11704        let match_rs = {
11705            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11706            if let Some(ref mask) = mask_opt {
11707                execute(&self.view_masked(mask), &ops, &Params(params))
11708            } else {
11709                execute(&self.view(), &ops, &Params(params))
11710            }
11711        }
11712        .map_err(|e| GraphError::QueryError {
11713            detail: format!("execute: {e}"),
11714        })?;
11715
11716        // Collect (etype, src_key, dst_key) for each row × each delete target.
11717        let mut del_ops: Vec<(String, String, String)> = Vec::new();
11718        for row_i in 0..match_rs.len() {
11719            for ed in &stmt.deletes {
11720                let src_key = match match_rs.get(row_i, &ed.src_var) {
11721                    Some(Value::Str(k)) => k.clone(),
11722                    _ => {
11723                        return Err(GraphError::QueryError {
11724                            detail: format!(
11725                                "DELETE src variable '{}' did not resolve to a node key",
11726                                ed.src_var
11727                            ),
11728                        })
11729                    }
11730                };
11731                let dst_key = match match_rs.get(row_i, &ed.dst_var) {
11732                    Some(Value::Str(k)) => k.clone(),
11733                    _ => {
11734                        return Err(GraphError::QueryError {
11735                            detail: format!(
11736                                "DELETE dst variable '{}' did not resolve to a node key",
11737                                ed.dst_var
11738                            ),
11739                        })
11740                    }
11741                };
11742                del_ops.push((ed.etype.clone(), src_key, dst_key));
11743            }
11744        }
11745
11746        // Apply as one atomic batch.
11747        let deleted = del_ops.len();
11748        let mut batch = self.batch();
11749        for (etype, src_key, dst_key) in del_ops {
11750            batch.delete_edge(&etype, &src_key, &dst_key);
11751        }
11752        batch.commit().map_err(|e| match e {
11753            GraphError::RuleOwned { .. } => GraphError::QueryError {
11754                detail: "cannot delete derived edge; retract via the rule or change the property"
11755                    .to_string(),
11756            },
11757            other => other,
11758        })?;
11759
11760        let mut rs = write_result_set();
11761        rs.push_row(vec![
11762            Some(Value::Int(0)),
11763            Some(Value::Int(0)),
11764            Some(Value::Int(deleted as i64)),
11765        ]);
11766        Ok(rs)
11767    }
11768
11769    /// Execute `MATCH … [DETACH] DELETE <node_var> [, …]`.
11770    ///
11771    /// Collects the matching node keys via an ephemeral read query, then calls
11772    /// `delete_node` on each one.  When `stmt.detach` is `false` (bare DELETE)
11773    /// the executor first checks that the node has no incident edges; if any
11774    /// remain it returns a named error matching openCypher semantics.
11775    fn exec_match_delete_node(
11776        &mut self,
11777        stmt: MatchDeleteNodeStmt,
11778        params: &BTreeMap<String, Value>,
11779    ) -> Result<ResultSet> {
11780        // Build a read query returning only the node keys we need.
11781        let returns: Vec<RetItem> = stmt
11782            .node_vars
11783            .iter()
11784            .map(|v| RetItem {
11785                value: RetVal::Var(v.clone()),
11786                alias: None,
11787            })
11788            .collect();
11789        let read_q = Query {
11790            matches: stmt.matches,
11791            optional_clauses: vec![],
11792            where_expr: stmt.where_expr,
11793            unwinds: vec![],
11794            post_unwind_where: None,
11795            stages: vec![],
11796            returns,
11797            distinct: false,
11798            order_by: vec![],
11799            skip: None,
11800            limit: None,
11801        };
11802        let ops = plan(&read_q).map_err(|e| GraphError::QueryError {
11803            detail: format!("plan: {e}"),
11804        })?;
11805        // Role-scoped writes: mask the MATCH read phase so hidden nodes are
11806        // invisible → hidden ≡ absent ≡ zero-rows (spec §3.1, hidden ≡ absent).
11807        let match_rs = {
11808            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
11809            if let Some(ref mask) = mask_opt {
11810                execute(&self.view_masked(mask), &ops, &Params(params))
11811            } else {
11812                execute(&self.view(), &ops, &Params(params))
11813            }
11814        }
11815        .map_err(|e| GraphError::QueryError {
11816            detail: format!("execute: {e}"),
11817        })?;
11818
11819        // Collect unique node keys to delete (deduplicate across rows × vars).
11820        let mut keys: Vec<String> = Vec::new();
11821        for row_i in 0..match_rs.len() {
11822            for var in &stmt.node_vars {
11823                if let Some(Value::Str(k)) = match_rs.get(row_i, var) {
11824                    if !keys.contains(k) {
11825                        keys.push(k.clone());
11826                    }
11827                }
11828            }
11829        }
11830
11831        if !stmt.detach {
11832            // openCypher bare DELETE: error if any matched node has incident edges.
11833            for key in &keys {
11834                if let Some(id) = self.ids.get(key) {
11835                    let tv = self.topo_view();
11836                    let has_edges = tv.etypes().any(|et| {
11837                        !tv.neighbors(et, Direction::Out, id).is_empty()
11838                            || !tv.neighbors(et, Direction::In, id).is_empty()
11839                    });
11840                    if has_edges {
11841                        return Err(GraphError::QueryError {
11842                            detail: format!(
11843                                "Cannot delete node `{key}` because it still has incident edges. \
11844                                 Use DETACH DELETE to remove the node and all its edges."
11845                            ),
11846                        });
11847                    }
11848                }
11849            }
11850        }
11851
11852        let mut nodes_deleted = 0i64;
11853        let mut edges_deleted = 0i64;
11854        for key in keys {
11855            match self.delete_node(&key) {
11856                Ok(report) => {
11857                    nodes_deleted += 1;
11858                    edges_deleted += (report.manual_edges + report.derived_edges) as i64;
11859                }
11860                Err(GraphError::KeyNotFound { .. }) => {
11861                    // Node may have been deleted by an earlier iteration (e.g., via
11862                    // multiple MATCH rows for the same node).  Safe to skip.
11863                }
11864                Err(e) => return Err(e),
11865            }
11866        }
11867
11868        let mut rs = write_result_set();
11869        rs.push_row(vec![
11870            Some(Value::Int(0)),
11871            Some(Value::Int(0)),
11872            Some(Value::Int(nodes_deleted + edges_deleted)),
11873        ]);
11874        Ok(rs)
11875    }
11876
11877    /// Props the MERGE create arm inserts: the identifying key, plus `ns` when
11878    /// the pattern named one, or the executing role's sole namespace when it
11879    /// did not. A role bound to two or more namespaces cannot choose, and is
11880    /// refused with [`MERGE_CREATE_NEEDS_ONE_NAMESPACE`]. The authorizer still
11881    /// refuses a named `ns` the role cannot write.
11882    fn merge_create_props(
11883        &self,
11884        key_field: &str,
11885        key_value: &Value,
11886        named_ns: Option<&Value>,
11887    ) -> Result<Vec<(String, Value)>> {
11888        let mut props = vec![(key_field.to_string(), key_value.clone())];
11889        if let Some(ns) = named_ns {
11890            props.push((NS_PROP.to_string(), ns.clone()));
11891            return Ok(props);
11892        }
11893        if let Some(ns) = self.merge_create_stamp_ns()? {
11894            props.push((NS_PROP.to_string(), Value::Str(ns)));
11895        }
11896        Ok(props)
11897    }
11898
11899    /// The namespace a role-scoped MERGE create stamps when the pattern does
11900    /// not name `ns`. `None` = unscoped / full authority, so the node lands in
11901    /// `default`.
11902    fn merge_create_stamp_ns(&self) -> Result<Option<String>> {
11903        let Some(authz) = self.pending_write_authz.as_ref() else {
11904            return Ok(None);
11905        };
11906        let Some(def) = self.role_def_for(&authz.role) else {
11907            return Ok(None);
11908        };
11909        match def.namespaces.as_deref() {
11910            Some([only]) => Ok(Some(only.clone())),
11911            Some(_) => Err(GraphError::RoleWriteDenied {
11912                reason: MERGE_CREATE_NEEDS_ONE_NAMESPACE.to_string(),
11913            }),
11914            None => Ok(None),
11915        }
11916    }
11917
11918    fn exec_merge(
11919        &mut self,
11920        stmt: core_query::cypher::MergeStmt,
11921        params: &BTreeMap<String, Value>,
11922    ) -> Result<ResultSet> {
11923        // MERGE: check if a node with the given key already exists.
11924        let key = match &stmt.key_value {
11925            Value::Str(s) => s.clone(),
11926            _ => {
11927                return Err(GraphError::QueryError {
11928                    detail: format!(
11929                        "MERGE key value must be a string (got {:?})",
11930                        stmt.key_value
11931                    ),
11932                })
11933            }
11934        };
11935
11936        if let Some(var) = stmt.var.as_deref() {
11937            for sc in stmt.on_create.iter().chain(&stmt.on_match) {
11938                if sc.var != var {
11939                    return Err(GraphError::QueryError {
11940                        detail: format!(
11941                            "SET variable '{}' does not match MERGE variable '{var}'",
11942                            sc.var
11943                        ),
11944                    });
11945                }
11946            }
11947        }
11948
11949        // ── MERGE authz pre-check (when role-scoped) ─────────────────────────
11950        //
11951        // MERGE scope precondition: check create OR update scope for the
11952        // declared label BEFORE calling `has_node` (timing-oracle closure,
11953        // spec §6.2 "MERGE visibility oracle" item: hidden ≡ absent for
11954        // unscoped roles — the scope denial fires without touching the key store).
11955        //
11956        // Clone to avoid holding a borrow on `self.pending_write_authz` while
11957        // also calling `self.ids.get(key)`.
11958        let merge_existed: bool = if let Some(authz) = self.pending_write_authz.clone() {
11959            let has_create = authz.scope.create_labels.contains(&stmt.label);
11960            let has_update = authz.scope.update_labels.contains(&stmt.label);
11961            if !has_create && !has_update {
11962                // Scope-before-lookup: 403 without has_node call (timing oracle
11963                // closure — see test_merge_unscoped_no_key_lookup).
11964                return Err(GraphError::RoleWriteDenied {
11965                    reason: format!(
11966                        "role-bound token: label '{}' not in write scope (create_labels)",
11967                        stmt.label
11968                    ),
11969                });
11970            }
11971            // Key lookup under mask.
11972            match self.ids.get(key.as_str()) {
11973                Some(id) if authz.mask.contains_id(id) => {
11974                    // Visible: must have update scope to proceed to match arm.
11975                    if !has_update {
11976                        return Err(GraphError::RoleWriteDenied {
11977                            reason: format!(
11978                                "role-bound token: label '{}' not in write scope (update_labels)",
11979                                stmt.label
11980                            ),
11981                        });
11982                    }
11983                    true // existed = true → match arm
11984                }
11985                Some(_) => {
11986                    // Hidden: same error as absent to the role (spec §3.1/§3.3).
11987                    return Err(GraphError::RoleWriteDenied {
11988                        reason: "role-bound token: target node not visible".into(),
11989                    });
11990                }
11991                None => {
11992                    // Absent: must have create scope to proceed to the create arm.
11993                    //
11994                    // Update-only roles (create_labels empty, update_labels set):
11995                    // return the SAME "not visible" error as the hidden-key branch
11996                    // so hidden ≡ absent — no distinguishing oracle (spec §6.1
11997                    // "confirm existence of hidden nodes: No").
11998                    //
11999                    // Create-scoped roles (has_create=true): absent → create arm
12000                    // as before.  The accepted structural key-existence disclosure
12001                    // (§THREAT-MODEL) applies only when the role holds create scope.
12002                    if !has_create {
12003                        return Err(GraphError::RoleWriteDenied {
12004                            reason: "role-bound token: target node not visible".into(),
12005                        });
12006                    }
12007                    false // existed = false → create arm
12008                }
12009            }
12010        } else {
12011            // Full authority: use the existing non-masked has_node check.
12012            self.has_node(&key)
12013        };
12014
12015        let existed = merge_existed;
12016        let create_props = if existed {
12017            None
12018        } else {
12019            Some(self.merge_create_props(&stmt.key_field, &stmt.key_value, stmt.ns.as_ref())?)
12020        };
12021        let mut created = 0i64;
12022        if create_props.is_some() || !stmt.on_match.is_empty() {
12023            let mut batch = self.batch();
12024            if let Some(props) = create_props {
12025                batch.insert_node(&stmt.label, &key, props);
12026                for sc in &stmt.on_create {
12027                    let value = resolve_merge_set_value(&sc.value, params)?;
12028                    batch.set_prop(&key, &sc.field, value);
12029                }
12030                created = 1;
12031            } else {
12032                for sc in &stmt.on_match {
12033                    let value = resolve_merge_set_value(&sc.value, params)?;
12034                    batch.set_prop(&key, &sc.field, value);
12035                }
12036            }
12037            batch.commit()?;
12038        }
12039
12040        // Refresh the role mask so the just-created node is visible to this
12041        // statement's RETURN (read-after-write). Safe: create_labels ⊆ read labels
12042        // (apply_schema subset rule), so the new node's label is already in the
12043        // role's read scope — this never widens beyond the role's declared labels.
12044        if !existed {
12045            if let Some(role) = self.pending_write_authz.as_ref().map(|a| a.role.clone()) {
12046                let new_mask = self.mask_for_role(&role)?;
12047                if let Some(a) = self.pending_write_authz.as_mut() {
12048                    a.mask = new_mask;
12049                }
12050            }
12051        }
12052
12053        // Optional RETURN clause: project the node (created or matched) as a read result.
12054        if let Some(returns) = stmt.returns {
12055            let var = stmt.var.as_deref().unwrap_or("_mn0");
12056            let q = Query {
12057                matches: vec![Pattern {
12058                    start: NodePat {
12059                        var: Some(var.to_string()),
12060                        label: Some(stmt.label.clone()),
12061                        props: vec![("id".to_string(), Operand::Lit(stmt.key_value.clone()))],
12062                    },
12063                    chain: vec![],
12064                    shortest: false,
12065                }],
12066                optional_clauses: vec![],
12067                where_expr: None,
12068                unwinds: vec![],
12069                post_unwind_where: None,
12070                stages: vec![],
12071                returns,
12072                distinct: false,
12073                order_by: vec![],
12074                skip: None,
12075                limit: None,
12076            };
12077            let ops = plan(&q).map_err(|e| GraphError::QueryError {
12078                detail: format!("plan: {e}"),
12079            })?;
12080            // Use view_masked when a role-scoped write is in flight so the
12081            // post-merge projection is consistent with the masked read phase.
12082            let mask_opt = self.pending_write_authz.as_ref().map(|a| a.mask.clone());
12083            return (if let Some(ref mask) = mask_opt {
12084                execute(&self.view_masked(mask), &ops, &Params(params))
12085            } else {
12086                execute(&self.view(), &ops, &Params(params))
12087            })
12088            .map_err(|e| GraphError::QueryError {
12089                detail: format!("execute: {e}"),
12090            });
12091        }
12092
12093        let mut rs = write_result_set();
12094        rs.push_row(vec![
12095            Some(Value::Int(created)),
12096            Some(Value::Int(0)),
12097            Some(Value::Int(0)),
12098        ]);
12099        Ok(rs)
12100    }
12101
12102    /// Return all rule-owned edges between `key_a` and `key_b` (either direction),
12103    /// annotated with rule name, edge type, direction, and weight.
12104    /// Results are sorted by (rule, edge_type).
12105    /// Returns `Err(KeyNotFound)` if either key is unknown.
12106    pub fn explain(&self, key_a: &str, key_b: &str) -> Result<Vec<Explanation>> {
12107        self.ensure_v8_base_sections_loaded();
12108        let id_a = self
12109            .ids
12110            .get(key_a)
12111            .ok_or_else(|| GraphError::KeyNotFound { key: key_a.into() })?;
12112        let id_b = self
12113            .ids
12114            .get(key_b)
12115            .ok_or_else(|| GraphError::KeyNotFound { key: key_b.into() })?;
12116
12117        let mut results = Vec::new();
12118
12119        // Walk the smaller incident set so explain is O(min(deg(a), deg(b)))
12120        // rather than O(total provenance).
12121        let scan = if self.engine.provenance_touching_len(id_a)
12122            <= self.engine.provenance_touching_len(id_b)
12123        {
12124            id_a
12125        } else {
12126            id_b
12127        };
12128        for (rule_name, etype, src, dst) in self.engine.provenance_touching(scan) {
12129            if !((src == id_a && dst == id_b) || (src == id_b && dst == id_a)) {
12130                continue;
12131            }
12132            let Some(rule_def) = self.engine.rules().find(|r| r.name == rule_name) else {
12133                continue;
12134            };
12135            let edge_type = match self.syms.resolve(etype) {
12136                Some(s) => s.to_string(),
12137                None => continue,
12138            };
12139            // Provenance (src, dst) ids come from the archived PROVENANCE section
12140            // (large, no eager CRC).  A corrupt section can produce ids that are
12141            // out of range; return Corrupt rather than panic.
12142            let src_key = self
12143                .ids
12144                .key_of(src)
12145                .ok_or_else(|| GraphError::Corrupt {
12146                    detail: format!("v8: provenance src id {src} not in id table"),
12147                })?
12148                .to_string();
12149            let dst_key = self
12150                .ids
12151                .key_of(dst)
12152                .ok_or_else(|| GraphError::Corrupt {
12153                    detail: format!("v8: provenance dst id {dst} not in id table"),
12154                })?
12155                .to_string();
12156            let stored = rule_def.weight_prop.as_deref().and_then(|prop| {
12157                self.edge_props_view()
12158                    .get(etype, src, dst, prop)
12159                    .and_then(|v| {
12160                        if let Value::Float(f) = v {
12161                            Some(f)
12162                        } else {
12163                            None
12164                        }
12165                    })
12166            });
12167            // Rules that store no weight (KeyMatch/FieldEqual defaults, auto-FK)
12168            // still have a score: recompute it from the predicate so explain
12169            // never reports "no score" for an edge the engine scored.  Via-hop
12170            // rules score over their via set, not over (src, dst), so leave
12171            // those None rather than report a number the rule did not produce.
12172            let weight = stored.or_else(|| {
12173                if rule_def.via_edge.is_some() {
12174                    return None;
12175                }
12176                let props_view = build_props_view(&self.props, &self.base);
12177                let src_get = |field: &str| props_view.get(src, field).map(|vr| vr.into_value());
12178                let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
12179                let src_view = NodeView {
12180                    key: &src_key,
12181                    props: &src_get,
12182                };
12183                let dst_view = NodeView {
12184                    key: &dst_key,
12185                    props: &dst_get,
12186                };
12187                evaluate(&rule_def.predicate, &src_view, &dst_view)
12188            });
12189            results.push(Explanation {
12190                rule: rule_name.to_string(),
12191                edge_type,
12192                src_key,
12193                dst_key,
12194                weight,
12195                predicate: PredicateSummary {
12196                    approximate: rule_def.approximate,
12197                    ..PredicateSummary::from(&rule_def.predicate)
12198                },
12199                via_edge: rule_def.via_edge.clone(),
12200            });
12201        }
12202
12203        results.sort_by(|a, b| a.rule.cmp(&b.rule).then(a.edge_type.cmp(&b.edge_type)));
12204        Ok(results)
12205    }
12206
12207    /// [`explain`](Self::explain) with the scoped read contract (§5.3): both
12208    /// endpoints are subject-checked, and any explanation whose evidence runs
12209    /// through a hidden node is **dropped entirely, not redacted**.
12210    ///
12211    /// A plain two-node rule's evidence is the pair itself, so once both
12212    /// subjects are visible there is nothing left to hide. A **via-hop** rule is
12213    /// different: it fires `src → dst` because some node carrying `via_label`
12214    /// sits between them, and [`Explanation`] carries the hop's edge *type*
12215    /// (`via_edge`) and never the hop's key. There is no field to blank, so a
12216    /// redacted explanation would still say "these two are linked through
12217    /// something you cannot see" — which discloses that the something exists.
12218    /// The explanation is therefore kept only when at least one **visible** via
12219    /// node satisfies the rule on its own.
12220    ///
12221    /// The weight is the **visible corpus's** number, not the store's: a via-hop
12222    /// rule stores the max over every via it hopped through, so the stored value
12223    /// can be a score only a hidden via produced. It is recomputed over the
12224    /// visible vias alone.
12225    ///
12226    /// Hidden or unknown `key_a` or `key_b` → [`GraphError::KeyNotFound`].
12227    pub fn explain_scoped(
12228        &self,
12229        key_a: &str,
12230        key_b: &str,
12231        mask: &crate::mask::NodeMask,
12232    ) -> Result<Vec<Explanation>> {
12233        for key in [key_a, key_b] {
12234            if !mask.contains_node(self, key) {
12235                return Err(GraphError::KeyNotFound { key: key.into() });
12236            }
12237        }
12238        Ok(self
12239            .explain(key_a, key_b)?
12240            .into_iter()
12241            .filter_map(|e| self.scoped_explanation(e, mask))
12242            .collect())
12243    }
12244
12245    /// `e` as a caller limited to `mask` may have it, or `None` when it must be
12246    /// dropped entirely.
12247    ///
12248    /// Every non-via-hop explanation passes through untouched: its only nodes
12249    /// are the two subjects, which [`explain_scoped`](Self::explain_scoped) has
12250    /// already checked, and its weight is scored over that pair alone.
12251    ///
12252    /// A via-hop explanation is kept only when some via node the caller may see
12253    /// satisfies the rule on its own — and then its weight is recomputed as the
12254    /// max over exactly those vias. The engine writes the max over **all** of
12255    /// them (`core-rules::engine`, `best = prev.max(score)`), so passing the
12256    /// stored number through would let a hidden node set a figure the caller
12257    /// reads: the same disclosure dropping the explanation exists to prevent.
12258    ///
12259    /// A rule that stores no weight still reports none. The recomputed score is
12260    /// a sanitised version of a number `explain` already returned, never a new
12261    /// one — a scoped read must not say more than the unscoped read it narrows.
12262    fn scoped_explanation(
12263        &self,
12264        e: Explanation,
12265        mask: &crate::mask::NodeMask,
12266    ) -> Option<Explanation> {
12267        let Some(via_edge) = e.via_edge.clone() else {
12268            return Some(e);
12269        };
12270        let Some(rule_def) = self.engine.rules().find(|r| r.name == e.rule) else {
12271            // The rule is gone but its provenance is not; nothing can vouch for
12272            // the hop, so nothing is shown.
12273            return None;
12274        };
12275        let Some(via_label) = rule_def.via_label.as_deref() else {
12276            return Some(e);
12277        };
12278        let (Some(src), Some(dst)) = (self.ids.get(&e.src_key), self.ids.get(&e.dst_key)) else {
12279            return None;
12280        };
12281        let (Some(via_etype), Some(via_sym)) = (self.syms.get(&via_edge), self.syms.get(via_label))
12282        else {
12283            return None;
12284        };
12285        let via_dir = rule_def.via_dir.unwrap_or(Direction::Out);
12286        let props_view = build_props_view(&self.props, &self.base);
12287        let dst_get = |field: &str| props_view.get(dst, field).map(|vr| vr.into_value());
12288        let dst_view = NodeView {
12289            key: &e.dst_key,
12290            props: &dst_get,
12291        };
12292        // The rule's own namespace test, the one the engine applies to each via
12293        // candidate (`core-rules::engine::rule_sees_node`). Without it a
12294        // visible, out-of-namespace via — right label, satisfying predicate —
12295        // vouches for a hop the engine never made, and an explanation whose real
12296        // evidence is a hidden in-namespace node is kept.
12297        let rule_sees = |id: u32| match rule_def.namespace.as_deref() {
12298            None => true,
12299            Some(ns) => {
12300                let value = props_view.get(id, NS_PROP).map(|vr| vr.into_value());
12301                namespace_of_value(value.as_ref()) == ns
12302            }
12303        };
12304        let best = self
12305            .topo_view()
12306            .neighbors(via_etype, via_dir, src)
12307            .iter()
12308            .copied()
12309            .filter_map(|via| {
12310                if !mask.contains_id(via) {
12311                    return None;
12312                }
12313                if self.labels.get(via as usize).copied() != Some(via_sym) {
12314                    return None;
12315                }
12316                if !rule_sees(via) {
12317                    return None;
12318                }
12319                let via_key = self.ids.key_of(via)?;
12320                let via_get = |field: &str| props_view.get(via, field).map(|vr| vr.into_value());
12321                let via_view = NodeView {
12322                    key: via_key,
12323                    props: &via_get,
12324                };
12325                evaluate(&rule_def.predicate, &via_view, &dst_view)
12326            })
12327            .fold(None::<f64>, |best, score| {
12328                Some(match best {
12329                    None => score,
12330                    Some(prev) => prev.max(score),
12331                })
12332            })?;
12333        let weight = e.weight.map(|_| best);
12334        Some(Explanation { weight, ..e })
12335    }
12336
12337    pub fn neighbors(&self, key: &str, edge_type: &str, dir: Direction) -> Result<Vec<String>> {
12338        let id = self
12339            .ids
12340            .get(key)
12341            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12342        let Some(sym) = self.syms.get(edge_type) else {
12343            return Ok(Vec::new());
12344        };
12345        self.topo_view()
12346            .neighbors(sym, dir, id)
12347            .iter()
12348            .map(|&n| {
12349                self.ids
12350                    .key_of(n)
12351                    .map(|k| k.to_string())
12352                    .ok_or_else(|| GraphError::Corrupt {
12353                        detail: format!("topology id {n} has no key"),
12354                    })
12355            })
12356            .collect::<Result<Vec<_>>>()
12357    }
12358
12359    /// Unique directed degree of `key`. Unknown key → [`GraphError::KeyNotFound`].
12360    /// Unknown `edge_type` → 0. [`crate::algo::AlgoDir::Both`] is out + in (sum).
12361    pub fn degree(
12362        &self,
12363        key: &str,
12364        edge_type: Option<&str>,
12365        direction: crate::algo::AlgoDir,
12366    ) -> Result<u64> {
12367        let id = self
12368            .ids
12369            .get(key)
12370            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12371        let topo = self.topo_view();
12372        Ok(Self::unique_directed_degree(
12373            &topo, &self.syms, id, edge_type, direction,
12374        ))
12375    }
12376
12377    /// [`degree`](Self::degree) summing each pair's **insert count** instead of
12378    /// counting each pair once (§5.13).
12379    ///
12380    /// The unique degree asks how many neighbours there are; this asks how many
12381    /// times they were inserted. A pair with no recorded count contributes 1,
12382    /// so on a store that never called
12383    /// [`enable_multiplicity`](Self::enable_multiplicity) this returns exactly
12384    /// what [`degree`](Self::degree) returns rather than erroring — the
12385    /// distinction is a readout preference, not a demand the store cannot meet.
12386    ///
12387    /// `AlgoDir::Both` still sums out + in, so a pair visible on both sides
12388    /// still contributes twice: multiplicity changes what a pair is worth, never
12389    /// how a direction is counted.
12390    pub fn degree_multiplicity(
12391        &self,
12392        key: &str,
12393        edge_type: Option<&str>,
12394        direction: crate::algo::AlgoDir,
12395    ) -> Result<u64> {
12396        let id = self
12397            .ids
12398            .get(key)
12399            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12400        Ok(Self::multiplicity_directed_degree(
12401            &self.topo_view(),
12402            &self.edge_props_view(),
12403            &self.syms,
12404            id,
12405            edge_type,
12406            direction,
12407            None,
12408        ))
12409    }
12410
12411    /// [`degree_multiplicity`](Self::degree_multiplicity) under a scope.
12412    ///
12413    /// The sum covers **visible pairs only**. A hidden neighbour's inserts stay
12414    /// out of it for the reason
12415    /// [`degree_scoped`](Self::degree_scoped) documents, and more sharply: an
12416    /// unscoped multiplicity count discloses not only that a hidden neighbour
12417    /// exists but how often it was written.
12418    ///
12419    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`].
12420    pub fn degree_scoped_multiplicity(
12421        &self,
12422        key: &str,
12423        edge_type: Option<&str>,
12424        direction: crate::algo::AlgoDir,
12425        mask: &crate::mask::NodeMask,
12426    ) -> Result<u64> {
12427        let id = self
12428            .ids
12429            .get(key)
12430            .filter(|&id| mask.contains_id(id))
12431            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12432        Ok(Self::multiplicity_directed_degree(
12433            &self.topo_view(),
12434            &self.edge_props_view(),
12435            &self.syms,
12436            id,
12437            edge_type,
12438            direction,
12439            Some(mask),
12440        ))
12441    }
12442
12443    /// [`degree`](Self::degree) counting **only neighbours the mask admits**.
12444    ///
12445    /// The filter is a correctness requirement, not an optimisation: an
12446    /// unfiltered count discloses the existence of a hidden neighbour to a
12447    /// caller who cannot see it, which is the same leak
12448    /// [`node_edges_scoped`](Self::node_edges_scoped) exists to prevent —
12449    /// reached by arithmetic instead of by name.
12450    ///
12451    /// Hidden or unknown `key` → [`GraphError::KeyNotFound`]. Unknown
12452    /// `edge_type` is still 0, as it is unscoped.
12453    pub fn degree_scoped(
12454        &self,
12455        key: &str,
12456        edge_type: Option<&str>,
12457        direction: crate::algo::AlgoDir,
12458        mask: &crate::mask::NodeMask,
12459    ) -> Result<u64> {
12460        let id = self
12461            .ids
12462            .get(key)
12463            .filter(|&id| mask.contains_id(id))
12464            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
12465        let topo = self.topo_view();
12466        Ok(Self::visible_directed_degree(
12467            &topo, &self.syms, id, edge_type, direction, mask,
12468        ))
12469    }
12470
12471    /// Unique directed degree for a subset or a label scan.
12472    ///
12473    /// Unknown keys in `keys` are omitted (mask-like). `keys = Some(&[])` →
12474    /// empty `Ok(vec![])`. `limit` is applied after sorting degree desc, key
12475    /// asc, and only when `Some`. Invalid `where_` → `QueryError`.
12476    #[allow(clippy::too_many_arguments)]
12477    pub fn degrees(
12478        &self,
12479        keys: Option<&[String]>,
12480        label: Option<&str>,
12481        where_: Option<&PropPredicate>,
12482        edge_type: Option<&str>,
12483        direction: crate::algo::AlgoDir,
12484        limit: Option<usize>,
12485    ) -> Result<Vec<(String, u64)>> {
12486        self.degrees_inner(
12487            keys, label, where_, edge_type, direction, limit, None, false,
12488        )
12489    }
12490
12491    /// [`degrees`](Self::degrees) reporting each row's **insert-count** sum
12492    /// instead of its unique neighbour count, as
12493    /// [`degree_multiplicity`](Self::degree_multiplicity) does for one key.
12494    ///
12495    /// The sort is still degree descending, key ascending — over the counts this
12496    /// reading produces — and `limit` still applies after it.
12497    #[allow(clippy::too_many_arguments)]
12498    pub fn degrees_multiplicity(
12499        &self,
12500        keys: Option<&[String]>,
12501        label: Option<&str>,
12502        where_: Option<&PropPredicate>,
12503        edge_type: Option<&str>,
12504        direction: crate::algo::AlgoDir,
12505        limit: Option<usize>,
12506    ) -> Result<Vec<(String, u64)>> {
12507        self.degrees_inner(keys, label, where_, edge_type, direction, limit, None, true)
12508    }
12509
12510    /// [`degrees_scoped`](Self::degrees_scoped) reporting insert counts.
12511    ///
12512    /// Both filters apply: a hidden key stays out of the result, and every
12513    /// row's sum covers its **visible** pairs only.
12514    #[allow(clippy::too_many_arguments)]
12515    pub fn degrees_scoped_multiplicity(
12516        &self,
12517        keys: Option<&[String]>,
12518        label: Option<&str>,
12519        where_: Option<&PropPredicate>,
12520        edge_type: Option<&str>,
12521        direction: crate::algo::AlgoDir,
12522        limit: Option<usize>,
12523        mask: &crate::mask::NodeMask,
12524    ) -> Result<Vec<(String, u64)>> {
12525        self.degrees_inner(
12526            keys,
12527            label,
12528            where_,
12529            edge_type,
12530            direction,
12531            limit,
12532            Some(mask),
12533            true,
12534        )
12535    }
12536
12537    /// [`degrees`](Self::degrees) with the scope applied on both sides: a hidden
12538    /// key is omitted from the input — whether it arrived in `keys` or came out
12539    /// of the `label`/`where_` scan — and every row's count is the count of its
12540    /// **visible** neighbours, for the reason
12541    /// [`degree_scoped`](Self::degree_scoped) documents.
12542    ///
12543    /// Unlike `degree_scoped`, a hidden key here is not
12544    /// [`GraphError::KeyNotFound`]: `degrees` already drops unknown keys
12545    /// silently, so hidden and absent stay one answer by staying out of the
12546    /// result. `limit` still applies after the sort, and so counts visible rows.
12547    #[allow(clippy::too_many_arguments)]
12548    pub fn degrees_scoped(
12549        &self,
12550        keys: Option<&[String]>,
12551        label: Option<&str>,
12552        where_: Option<&PropPredicate>,
12553        edge_type: Option<&str>,
12554        direction: crate::algo::AlgoDir,
12555        limit: Option<usize>,
12556        mask: &crate::mask::NodeMask,
12557    ) -> Result<Vec<(String, u64)>> {
12558        self.degrees_inner(
12559            keys,
12560            label,
12561            where_,
12562            edge_type,
12563            direction,
12564            limit,
12565            Some(mask),
12566            false,
12567        )
12568    }
12569
12570    /// The body shared by [`degrees`](Self::degrees) and
12571    /// [`degrees_scoped`](Self::degrees_scoped). `mask = None` is the unscoped
12572    /// contract unchanged.
12573    #[allow(clippy::too_many_arguments)]
12574    fn degrees_inner(
12575        &self,
12576        keys: Option<&[String]>,
12577        label: Option<&str>,
12578        where_: Option<&PropPredicate>,
12579        edge_type: Option<&str>,
12580        direction: crate::algo::AlgoDir,
12581        limit: Option<usize>,
12582        mask: Option<&crate::mask::NodeMask>,
12583        multiplicity: bool,
12584    ) -> Result<Vec<(String, u64)>> {
12585        if let Some(pred) = where_ {
12586            pred.validate_named("where")
12587                .map_err(|detail| GraphError::QueryError { detail })?;
12588        }
12589        if matches!(keys, Some(ks) if ks.is_empty()) {
12590            return Ok(Vec::new());
12591        }
12592        let view = self.view();
12593        let ids: Vec<u32> = match keys {
12594            Some(ks) => {
12595                let mut seen = HashSet::new();
12596                let mut out = Vec::new();
12597                for k in ks {
12598                    let Some(id) = view.ids.get(k) else {
12599                        continue;
12600                    };
12601                    if !seen.insert(id) {
12602                        continue;
12603                    }
12604                    if let Some(pred) = where_ {
12605                        let holds = match view.prop(id, &pred.field) {
12606                            None => pred.holds(None),
12607                            Some(vr) => pred.holds(Some(vr.as_value())),
12608                        };
12609                        if !holds {
12610                            continue;
12611                        }
12612                    }
12613                    out.push(id);
12614                }
12615                out
12616            }
12617            None => Self::vector_candidates(&view, label, where_),
12618        };
12619        let mut out: Vec<(String, u64)> = ids
12620            .into_iter()
12621            // A hidden candidate leaves as quietly as an unknown key does.
12622            .filter(|&id| mask.is_none_or(|m| m.contains_id(id)))
12623            .filter_map(|id| {
12624                let key = self.ids.key_of(id)?.to_string();
12625                let deg = match (multiplicity, mask) {
12626                    // The same `view` the unique arms read, so the per-row
12627                    // rebuild F9 measured is gone and all three arms agree on
12628                    // the state they are reading.
12629                    (true, m) => Self::multiplicity_directed_degree(
12630                        &view.topo,
12631                        &view.edge_props,
12632                        view.syms,
12633                        id,
12634                        edge_type,
12635                        direction,
12636                        m,
12637                    ),
12638                    (false, Some(m)) => Self::visible_directed_degree(
12639                        &view.topo, view.syms, id, edge_type, direction, m,
12640                    ),
12641                    (false, None) => Self::unique_directed_degree(
12642                        &view.topo, view.syms, id, edge_type, direction,
12643                    ),
12644                };
12645                Some((key, deg))
12646            })
12647            .collect();
12648        out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
12649        if let Some(lim) = limit {
12650            out.truncate(lim);
12651        }
12652        Ok(out)
12653    }
12654
12655    /// Unique neighbour count for `id` across `edge_type` (or all types) and
12656    /// `direction`. Unknown `edge_type` → 0. `Both` sums out + in.
12657    fn unique_directed_degree(
12658        topo: &TopologyView<'_>,
12659        syms: &Interner,
12660        id: u32,
12661        edge_type: Option<&str>,
12662        direction: crate::algo::AlgoDir,
12663    ) -> u64 {
12664        let dirs: &[Direction] = match direction {
12665            crate::algo::AlgoDir::Out => &[Direction::Out],
12666            crate::algo::AlgoDir::In => &[Direction::In],
12667            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12668        };
12669        match edge_type {
12670            Some(name) => {
12671                let Some(et) = syms.get(name) else {
12672                    return 0;
12673                };
12674                dirs.iter().map(|&d| topo.degree(et, d, id) as u64).sum()
12675            }
12676            None => topo
12677                .etypes()
12678                .map(|et| {
12679                    dirs.iter()
12680                        .map(|&d| topo.degree(et, d, id) as u64)
12681                        .sum::<u64>()
12682                })
12683                .sum(),
12684        }
12685    }
12686
12687    /// [`unique_directed_degree`](Self::unique_directed_degree) counting only
12688    /// neighbours `mask` admits.
12689    ///
12690    /// Same shape, one substitution: `topo.degree` is a length, so it cannot be
12691    /// filtered; the neighbour list it measures can. `Both` still sums out + in,
12692    /// so a node visible on both sides still counts twice — the filter changes
12693    /// which neighbours are counted, never how a degree is defined.
12694    fn visible_directed_degree(
12695        topo: &TopologyView<'_>,
12696        syms: &Interner,
12697        id: u32,
12698        edge_type: Option<&str>,
12699        direction: crate::algo::AlgoDir,
12700        mask: &crate::mask::NodeMask,
12701    ) -> u64 {
12702        let dirs: &[Direction] = match direction {
12703            crate::algo::AlgoDir::Out => &[Direction::Out],
12704            crate::algo::AlgoDir::In => &[Direction::In],
12705            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12706        };
12707        let visible = |et: u32| -> u64 {
12708            dirs.iter()
12709                .map(|&d| {
12710                    topo.neighbors(et, d, id)
12711                        .iter()
12712                        .filter(|&&n| mask.contains_id(n))
12713                        .count() as u64
12714                })
12715                .sum()
12716        };
12717        match edge_type {
12718            Some(name) => syms.get(name).map_or(0, visible),
12719            None => topo.etypes().map(visible).sum(),
12720        }
12721    }
12722
12723    /// Sum of the insert counts of `id`'s pairs (§5.13), over `edge_type` (or
12724    /// all types) and `direction`, restricted to what `mask` admits when one is
12725    /// given.
12726    ///
12727    /// The same neighbour lists the unique reading measures, with each entry
12728    /// worth its pair's count rather than worth 1 — so the filter decides which
12729    /// pairs are in the sum and the count decides what each contributes. A
12730    /// direction decides which way round the pair is addressed: an `In`
12731    /// neighbour `n` of `id` is the pair `(et, n, id)`.
12732    ///
12733    /// Takes its views as parameters, exactly as the unique helpers do, because
12734    /// it is called once per row from a label scan. `edge_props_view()` reaches
12735    /// into the mmap'd base's rkyv section on every call, so building the two
12736    /// views inside made an N-row `degrees(multiplicity=True)` do N section
12737    /// accesses where the unique reading does one: worth 2.57 ms of 16.68 ms
12738    /// over 20 000 rows, about 0.13 us per row (defect #29,
12739    /// `tests/f9_bench.rs`). Most of that call's cost is the per-neighbour
12740    /// count lookup and is inherent, so this is a hoist, not a rescue.
12741    ///
12742    /// The views are exactly `self.view()`'s own `topo` and `edge_props`, so a
12743    /// caller that already has a view passes its halves and reads the same
12744    /// state it reads everything else from.
12745    fn multiplicity_directed_degree(
12746        topo: &TopologyView<'_>,
12747        edge_props: &EdgePropsView<'_>,
12748        syms: &Interner,
12749        id: u32,
12750        edge_type: Option<&str>,
12751        direction: crate::algo::AlgoDir,
12752        mask: Option<&crate::mask::NodeMask>,
12753    ) -> u64 {
12754        let dirs: &[Direction] = match direction {
12755            crate::algo::AlgoDir::Out => &[Direction::Out],
12756            crate::algo::AlgoDir::In => &[Direction::In],
12757            crate::algo::AlgoDir::Both => &[Direction::Out, Direction::In],
12758        };
12759        let count_of = |et: u32, src: u32, dst: u32| -> u64 {
12760            match edge_props.get(et, src, dst, EDGE_COUNT_PROP) {
12761                Some(Value::Int(n)) if n > 0 => n as u64,
12762                _ => 1,
12763            }
12764        };
12765        let per_etype = |et: u32| -> u64 {
12766            dirs.iter()
12767                .map(|&d| {
12768                    topo.neighbors(et, d, id)
12769                        .iter()
12770                        .filter(|&&n| mask.is_none_or(|m| m.contains_id(n)))
12771                        .map(|&n| match d {
12772                            Direction::Out => count_of(et, id, n),
12773                            Direction::In => count_of(et, n, id),
12774                        })
12775                        .sum::<u64>()
12776                })
12777                .sum()
12778        };
12779        match edge_type {
12780            Some(name) => syms.get(name).map_or(0, per_etype),
12781            None => topo.etypes().map(per_etype).sum(),
12782        }
12783    }
12784
12785    /// Return the last-change commit sequence for `key`, or `None` if the node
12786    /// does not exist or has never been mutated since the last V5-V7 snapshot
12787    /// (horizon-bounded for legacy stores).
12788    ///
12789    /// The returned sequence is a monotonically increasing counter that starts
12790    /// at 1 for the first commit after `open` and increments with every
12791    /// successful write.  WAL replay at open also assigns sequences (1..N for N
12792    /// replayed frames), so sequences are consistent across snapshot+WAL cycles.
12793    ///
12794    /// For V5-V7 stores opened without a V8 snapshot, nodes that were present
12795    /// in the snapshot but not touched by any WAL frame will return `None`
12796    /// (horizon-bounded: CAS against such nodes is only safe after the first
12797    /// V8 snapshot or after the node is next mutated).
12798    pub fn last_changed(&self, key: &str) -> Option<u64> {
12799        let id = self.ids.get(key)?;
12800        self.last_change.get(&id).copied()
12801    }
12802
12803    /// Which loaded store this handle is.
12804    ///
12805    /// Paired with [`commit_seq`](GraphDb::commit_seq) it identifies a graph
12806    /// state outright, which `commit_seq` alone does not: two stores of the same
12807    /// age share a sequence, and a reload can return to one. Memos of dense
12808    /// node ids that the handle does not own are stamped with both; see
12809    /// [`StoreStamp`](crate::mask::StoreStamp).
12810    pub(crate) fn store_id(&self) -> crate::mask::StoreId {
12811        self.store_id
12812    }
12813
12814    /// The current commit sequence (number of successful commits since open,
12815    /// including WAL replay frames).  Useful for recording a baseline before
12816    /// a read-modify-write cycle.
12817    pub fn commit_seq(&self) -> u64 {
12818        self.commit_seq
12819    }
12820
12821    /// Check that all `preconds` are satisfied against the current db state.
12822    /// Returns `Err(GraphError::CasConflict)` on the first failing precondition.
12823    pub(crate) fn check_preconditions(&self, preconds: &[Precondition]) -> Result<()> {
12824        for precond in preconds {
12825            match precond {
12826                Precondition::NodeUnchangedSince { key, expected } => {
12827                    // Missing entry means the node predates the WAL window or
12828                    // does not exist; treat as 0 (before any commit).
12829                    let actual = self.last_changed(key).unwrap_or_default();
12830                    if actual != *expected {
12831                        return Err(GraphError::CasConflict {
12832                            key: key.clone(),
12833                            expected: *expected,
12834                            actual,
12835                        });
12836                    }
12837                }
12838                Precondition::NodeAbsent { key } => {
12839                    // Node must not exist (not live).
12840                    if self.ids.get(key).is_some() {
12841                        let actual = self.last_changed(key).unwrap_or(0);
12842                        return Err(GraphError::CasConflict {
12843                            key: key.clone(),
12844                            expected: u64::MAX,
12845                            actual,
12846                        });
12847                    }
12848                }
12849            }
12850        }
12851        Ok(())
12852    }
12853
12854    /// Apply a batch of mutations with compare-and-set preconditions.
12855    ///
12856    /// All preconditions are checked atomically before any operation is applied.
12857    /// If any precondition fails, the entire batch is rejected with
12858    /// [`GraphError::CasConflict`] and no WAL frame is written.
12859    ///
12860    /// # Returns
12861    /// `(nodes_inserted, edges_inserted)` on success, same as [`write_batch`].
12862    ///
12863    /// # Errors
12864    /// - [`GraphError::CasConflict`] if any precondition is not satisfied.
12865    /// - Any error that [`write_batch`] would return for the ops themselves.
12866    pub fn write_batch_cas(
12867        &mut self,
12868        preconds: Vec<Precondition>,
12869        ops: Vec<BatchOp>,
12870    ) -> Result<(usize, usize)> {
12871        self.check_preconditions(&preconds)?;
12872        self.commit_logged_batch(ops, None, None).map(inserted_pair)
12873    }
12874
12875    /// Update the per-node last-change map for a WAL record at commit `seq`.
12876    ///
12877    /// Called after a successful apply to record which nodes were touched.
12878    /// For replay, called with the WAL-frame's replayed seq.
12879    ///
12880    /// Touch definition (see [`Precondition`] doc):
12881    /// - InsertNode / InsertNodeId / SetProp / SetPropId / RemoveProp → the node.
12882    /// - InsertEdge / InsertEdgeId / DeleteEdge → both src and dst.
12883    /// - DeleteNode → node tombstoned; last_changed() returns None so no update needed.
12884    /// - DerivedEdge markers, Intern, rule/view records → no-ops.
12885    /// - Batch → recurse into inner records.
12886    fn update_last_change_from_rec(&mut self, rec: &WalRecord, seq: u64) {
12887        match rec {
12888            WalRecord::InsertNode { key, .. }
12889            | WalRecord::SetProp { key, .. }
12890            | WalRecord::RemoveProp { key, .. } => {
12891                if let Some(id) = self.ids.get(key) {
12892                    self.last_change.insert(id, seq);
12893                }
12894            }
12895            WalRecord::InsertNodeId { key, .. } => {
12896                if let Some(id) = self.ids.get(key) {
12897                    self.last_change.insert(id, seq);
12898                }
12899            }
12900            WalRecord::SetPropId { id, .. } => {
12901                self.last_change.insert(*id, seq);
12902            }
12903            WalRecord::InsertEdge {
12904                src_key, dst_key, ..
12905            }
12906            | WalRecord::DeleteEdge {
12907                src_key, dst_key, ..
12908            } => {
12909                if let Some(src_id) = self.ids.get(src_key) {
12910                    self.last_change.insert(src_id, seq);
12911                }
12912                if let Some(dst_id) = self.ids.get(dst_key) {
12913                    self.last_change.insert(dst_id, seq);
12914                }
12915            }
12916            WalRecord::InsertEdgeId { src, dst, .. } => {
12917                self.last_change.insert(*src, seq);
12918                self.last_change.insert(*dst, seq);
12919            }
12920            // A count record touches the pair, so it touches both endpoints —
12921            // the same reading `InsertEdgeId` gets, because a duplicate insert
12922            // that raises the count *is* a mutation of that pair. The opt-in
12923            // declaration touches nothing.
12924            WalRecord::SetEdgeCount { src, dst, .. } if !rec.is_multiplicity_decl() => {
12925                self.last_change.insert(*src, seq);
12926                self.last_change.insert(*dst, seq);
12927            }
12928            WalRecord::SetEdgeCount { .. } => {}
12929            // DeleteNode: node is tombstoned; last_changed(key) returns None for
12930            // deleted keys (ids.get() returns None post-tombstone), so no update needed.
12931            // History markers: state no-ops; the underlying mutation already
12932            // touched the relevant nodes' last_change entries.
12933            WalRecord::DeleteNode { .. }
12934            | WalRecord::DerivedEdgeAdded { .. }
12935            | WalRecord::DerivedEdgeRetracted { .. }
12936            | WalRecord::Intern { .. }
12937            | WalRecord::CreateRule { .. }
12938            | WalRecord::DeleteRule { .. }
12939            | WalRecord::RebuildRule { .. }
12940            | WalRecord::CreateView { .. }
12941            | WalRecord::DeleteView { .. }
12942            | WalRecord::EnableFulltext { .. }
12943            | WalRecord::DisableFulltext { .. }
12944            | WalRecord::EnableIndex { .. }
12945            | WalRecord::DisableIndex { .. } => {}
12946            // RenameNode: node id is stable; update last_change via the new key.
12947            // Called after apply(), so ids already reflects new_key.
12948            WalRecord::RenameNode { new_key, .. } => {
12949                if let Some(id) = self.ids.get(new_key) {
12950                    self.last_change.insert(id, seq);
12951                }
12952            }
12953            WalRecord::Batch(inner) => {
12954                for inner_rec in inner {
12955                    self.update_last_change_from_rec(inner_rec, seq);
12956                }
12957            }
12958        }
12959    }
12960
12961    pub fn node_count(&self) -> usize {
12962        self.ids.len()
12963    }
12964
12965    /// Configure archive retention: keep the `N` newest WAL archives at each
12966    /// [`snapshot_with`] call when `archive_wal: true`.
12967    ///
12968    /// `Some(N)` where N > 0 → prune oldest archives keeping the newest N.
12969    /// `Some(0)` or `None` → unlimited (no pruning).
12970    ///
12971    /// Pruning only ever happens inside [`snapshot_with`]; this method only
12972    /// stores the policy.  Archives below the retention limit are deleted
12973    /// oldest-first.  The horizon floor is updated so that
12974    /// [`was_linked`] / history APIs return `CommitOutOfRange` for commits
12975    /// in pruned archives rather than silently returning wrong data.
12976    pub fn set_wal_archive_retention(&mut self, keep: Option<u32>) {
12977        self.wal_archive_retention = keep;
12978    }
12979
12980    /// Delete any WAL archives that are fully below the current horizon floor.
12981    ///
12982    /// Orphaned archives arise when the floor is written first during retention
12983    /// pruning and then a crash interrupts the archive-delete sequence.  The
12984    /// opening cleanup ensures no subsequent read path sees stale data.
12985    ///
12986    /// Under the monotonic naming scheme, the archive name N equals the
12987    /// cumulative end-frame index of the archive in global commit space (i.e.
12988    /// the archive covers global frames `[prev_n, N)`).  An archive is
12989    /// fully orphaned when `N <= wal_horizon_floor`: all of its frames fall
12990    /// below the floor and have already been counted in it.
12991    fn cleanup_orphaned_archives(&mut self) -> Result<()> {
12992        if self.wal_horizon_floor == 0 {
12993            // Floor at 0 means no pruning has ever occurred; nothing to clean.
12994            return Ok(());
12995        }
12996        let archive_ns = self.fs.list_archives()?;
12997        for n in archive_ns {
12998            if n <= self.wal_horizon_floor {
12999                // Archive N ends at global frame N; all its frames are below
13000                // the floor (floor already accounts for them) → orphaned.
13001                self.fs.delete_archive(n).map_err(GraphError::Io)?;
13002            } else {
13003                // Archives are sorted ascending; first one above floor stops scan.
13004                break;
13005            }
13006        }
13007        Ok(())
13008    }
13009
13010    /// Collect all WAL frames from surviving archives (oldest-first) then the
13011    /// live WAL into one flat list, and return the total along with the number
13012    /// of archive frames at the front of the list.
13013    ///
13014    /// Commit indices into the returned list are LOCAL (0 = first frame of
13015    /// oldest surviving archive).  To obtain the GLOBAL index add
13016    /// `self.wal_horizon_floor`.
13017    /// How many frames the surviving archives hold, without materialising them.
13018    ///
13019    /// The same count `all_frames` puts at the front of its list. Used to seed
13020    /// [`wal_frames_written`](GraphDb::wal_frames_written) at open without
13021    /// decoding the live WAL a second time; free on a store with no archives,
13022    /// which is most of them.
13023    fn archive_frame_count(&self) -> Result<u64> {
13024        let mut n = 0u64;
13025        for a in self.fs.list_archives()? {
13026            let bytes = self.fs.read_archive(a)?;
13027            let (frames, _) = decode_all(&bytes);
13028            n += frames.len() as u64;
13029        }
13030        Ok(n)
13031    }
13032
13033    fn all_frames(&self) -> Result<(Vec<WalRecord>, u64)> {
13034        let archive_ns = self.fs.list_archives()?;
13035        let mut all: Vec<WalRecord> = Vec::new();
13036        for n in archive_ns {
13037            let bytes = self.fs.read_archive(n)?;
13038            let (frames, _) = decode_all(&bytes);
13039            all.extend(frames);
13040        }
13041        let archive_count = all.len() as u64;
13042        let live_bytes = self.fs.read(FileId::Wal)?;
13043        let (live_frames, _) = decode_all(&live_bytes);
13044        all.extend(live_frames);
13045        Ok((all, archive_count))
13046    }
13047
13048    /// Return the total number of committed WAL frames visible in the current
13049    /// horizon window, including frames in surviving WAL archives.
13050    ///
13051    /// This is the exclusive upper bound for valid `at_commit` indices in
13052    /// `was_linked`. Valid indices are `wal_horizon_floor()..wal_total_commits()`.
13053    ///
13054    /// Returns the horizon floor when all surviving history is empty.
13055    pub fn wal_total_commits(&self) -> Result<u64> {
13056        // The floor that belongs with these frames: both are read from disk
13057        // here. A handle that has not refreshed since another process moved
13058        // the floor would otherwise number new frames from its old one (row 81).
13059        let floor = self.fs.read_horizon_floor()?;
13060        let (frames, _) = self.all_frames()?;
13061        Ok(floor + frames.len() as u64)
13062    }
13063
13064    /// The global frame index of the first commit reachable through surviving
13065    /// archives (0 when no archives have been pruned).
13066    pub fn wal_horizon_floor(&self) -> u64 {
13067        self.wal_horizon_floor
13068    }
13069
13070    /// Return the per-node change history for `key` by scanning the on-disk WAL.
13071    ///
13072    /// ## Horizon
13073    ///
13074    /// History reaches back only to the last WAL-truncating snapshot, exactly like `open_at`.
13075    /// Snapshots written with `keep_wal: true` preserve deeper history. This is the honest,
13076    /// zero-cost contract; a durable history log is out of scope.
13077    ///
13078    /// ## Derived edges
13079    ///
13080    /// Rule-created (derived) edges are **not** in the WAL and therefore do not appear in
13081    /// history. Only edges written directly by the application are recorded.
13082    ///
13083    /// ## Deleted nodes
13084    ///
13085    /// For nodes that have been deleted, dense-id records (SetPropId, InsertEdgeId) that
13086    /// predate the deletion may not resolve (the id is tombstoned in the live map). The
13087    /// string-keyed `DeleteNode` record still matches and produces a `NodeDeleted` entry.
13088    /// Prop/edge history of a deleted node may therefore be partially unresolvable.
13089    ///
13090    /// ## Dense-id edge entries and tombstoned partners
13091    ///
13092    /// Edge entries from dense-id WAL records (`InsertEdgeId`) are omitted when the partner
13093    /// endpoint's dense id is tombstoned. As a result, a live node's history can contain an
13094    /// `EdgeRemoved` (string-keyed, always resolves) without a corresponding `EdgeAdded`.
13095    /// Build commit-bounded alias intervals for `queried_key`.
13096    ///
13097    /// Returns a list of `(key, valid_from_inclusive, valid_until_exclusive)` tuples.
13098    /// A record written under `key` at commit `c` matches the queried identity iff
13099    /// `c >= valid_from && (valid_until.is_none() || c < valid_until)`.
13100    ///
13101    /// Each alias entry carries both a lower and an upper bound so that key-reuse
13102    /// after a rename is handled correctly: if "a" is renamed to "b" at commit 5,
13103    /// then a NEW node is created as "a" at commit 7 and renamed to "c" at commit 10,
13104    /// querying "c" must NOT surface identity-1's events (commits 0–4 under "a");
13105    /// only identity-2's events (commits 7–9 under "a") are in scope.
13106    ///
13107    /// Only **forward aliasing**: querying the *new* key surfaces events written
13108    /// under the *old* key.  The reverse direction is not supported.
13109    fn build_key_alias_intervals(
13110        &self,
13111        frames: &[core_storage::wal::WalRecord],
13112        queried_key: &str,
13113        floor: u64,
13114    ) -> Vec<(String, u64, Option<u64>)> {
13115        self.key_alias_intervals(frames, queried_key, floor, false)
13116    }
13117
13118    /// [`Self::build_key_alias_intervals`], and with `current_holder_only`
13119    /// the queried key itself is bounded too: it counts from the commit the
13120    /// node that has it now acquired it. An earlier node that held the same
13121    /// key and was deleted is then no part of the answer — neither its edges
13122    /// nor its deletion.
13123    fn key_alias_intervals(
13124        &self,
13125        frames: &[core_storage::wal::WalRecord],
13126        queried_key: &str,
13127        floor: u64,
13128        current_holder_only: bool,
13129    ) -> Vec<(String, u64, Option<u64>)> {
13130        use core_storage::wal::WalRecord;
13131
13132        // Pre-pass: build reverse_rename and key_starts maps.
13133        // Every rename into a key, in commit order. A key can be renamed into
13134        // more than once — `a → b`, `b → a`, `a → b` — and which rename led
13135        // to the occupancy being followed depends on when.
13136        let mut reverse_rename: HashMap<String, Vec<(String, u64)>> = HashMap::new();
13137        let mut key_starts: HashMap<String, Vec<u64>> = HashMap::new();
13138
13139        for (local_i, frame) in frames.iter().enumerate() {
13140            let commit = floor + local_i as u64;
13141            let records: &[WalRecord] = match frame {
13142                WalRecord::Batch(inner) => inner.as_slice(),
13143                single => std::slice::from_ref(single),
13144            };
13145            for rec in records {
13146                match rec {
13147                    WalRecord::InsertNode { key, .. } | WalRecord::InsertNodeId { key, .. } => {
13148                        key_starts.entry(key.clone()).or_default().push(commit);
13149                    }
13150                    WalRecord::RenameNode { old_key, new_key } => {
13151                        // new_key came into existence at this commit.
13152                        key_starts.entry(new_key.clone()).or_default().push(commit);
13153                        // Record the reverse rename: new_key was introduced by renaming old_key.
13154                        reverse_rename
13155                            .entry(new_key.clone())
13156                            .or_default()
13157                            .push((old_key.clone(), commit));
13158                    }
13159                    _ => {}
13160                }
13161            }
13162        }
13163
13164        // Build alias intervals by following the reverse rename chain.
13165        let mut result: Vec<(String, u64, Option<u64>)> = Vec::new();
13166        let mut current_key = queried_key.to_string();
13167        let mut current_valid_until: Option<u64> = None;
13168
13169        loop {
13170            // valid_from: the most recent commit where current_key was assigned to this
13171            // identity.  For aliases (valid_until = Some(vu)), find the last start event
13172            // for the key strictly before vu — this is where the alias's occupancy by
13173            // this identity began, correctly excluding prior identities that reused the key.
13174            let valid_from = if let Some(vu) = current_valid_until {
13175                key_starts
13176                    .get(&current_key)
13177                    .and_then(|starts| starts.iter().rev().find(|&&s| s < vu).copied())
13178                    .unwrap_or(floor)
13179            } else if current_holder_only {
13180                key_starts
13181                    .get(&current_key)
13182                    .and_then(|starts| starts.last().copied())
13183                    .unwrap_or(floor)
13184            } else {
13185                // Queried key — no upper bound; may have been introduced at any commit.
13186                floor
13187            };
13188
13189            result.push((current_key.clone(), valid_from, current_valid_until));
13190
13191            // The rename that led here: the latest into this key, and for an
13192            // alias the latest before the alias stopped being this node's.
13193            // Each step goes strictly back in time, so the walk ends — taking
13194            // the last rename whatever its commit sent `a → b → a` round for
13195            // ever.
13196            let led_here = reverse_rename
13197                .get(&current_key)
13198                .and_then(|renames| {
13199                    renames
13200                        .iter()
13201                        .rev()
13202                        .find(|(_, at)| current_valid_until.is_none_or(|until| *at < until))
13203                })
13204                // And only if that rename is how this occupancy of the key
13205                // began. A key renamed into, deleted and inserted fresh
13206                // starts a new node at the insert; the older rename led to
13207                // the node that was deleted.
13208                .filter(|(_, at)| {
13209                    (current_valid_until.is_none() && !current_holder_only) || *at == valid_from
13210                });
13211            match led_here {
13212                Some((old_key, rename_commit)) => {
13213                    current_valid_until = Some(*rename_commit);
13214                    current_key = old_key.clone();
13215                }
13216                None => break,
13217            }
13218        }
13219
13220        result
13221    }
13222
13223    /// Returns true if `record_key` matches any alias interval that covers `commit`.
13224    fn aliases_match(
13225        intervals: &[(String, u64, Option<u64>)],
13226        record_key: &str,
13227        commit: u64,
13228    ) -> bool {
13229        intervals
13230            .iter()
13231            .any(|(k, vf, vu)| k == record_key && commit >= *vf && vu.is_none_or(|u| commit < u))
13232    }
13233
13234    /// Return the change history of node `key` by scanning the on-disk WAL.
13235    ///
13236    /// ## Horizon
13237    ///
13238    /// History reaches back only as far as the retained WAL. The returned
13239    /// [`HistoryResult`](crate::history::HistoryResult) carries `total_commits`
13240    /// (the exclusive upper bound for valid commit indices) and `horizon` (the
13241    /// oldest commit still reachable). When `horizon > 0`, older events were
13242    /// pruned and are not in `items`.
13243    pub fn node_history(
13244        &self,
13245        key: &str,
13246    ) -> Result<crate::history::HistoryResult<crate::history::HistoryEntry>> {
13247        use crate::history::{HistoryChange, HistoryEntry, HistoryResult};
13248        use core_storage::wal::WalRecord;
13249
13250        // The floor that belongs with these frames: both are read from disk
13251        // here. A handle that has not refreshed since another process moved
13252        // the floor would otherwise number new frames from its old one (row 81).
13253        let floor = self.fs.read_horizon_floor()?;
13254        let (frames, _) = self.all_frames()?;
13255        let total_commits = floor + frames.len() as u64;
13256
13257        // Resolve commit-bounded alias intervals for `key` (handles renames in the WAL).
13258        let alias_intervals = self.build_key_alias_intervals(&frames, key, floor);
13259
13260        let mut out: Vec<HistoryEntry> = Vec::new();
13261
13262        for (local_i, frame) in frames.iter().enumerate() {
13263            let commit = floor + local_i as u64;
13264            // Collect the inner records to process — Batch is one commit, single records are one commit.
13265            let records: &[WalRecord] = match frame {
13266                WalRecord::Batch(inner) => inner.as_slice(),
13267                single => std::slice::from_ref(single),
13268            };
13269
13270            for rec in records {
13271                let change = match rec {
13272                    WalRecord::InsertNode { label, key: k, .. }
13273                        if Self::aliases_match(&alias_intervals, k, commit) =>
13274                    {
13275                        Some(HistoryChange::NodeInserted {
13276                            label: label.clone(),
13277                        })
13278                    }
13279                    WalRecord::InsertNodeId { label, key: k, .. }
13280                        if Self::aliases_match(&alias_intervals, k, commit) =>
13281                    {
13282                        let label_str = match self.syms.resolve(*label) {
13283                            Some(s) => s.to_string(),
13284                            None => continue,
13285                        };
13286                        Some(HistoryChange::NodeInserted { label: label_str })
13287                    }
13288                    WalRecord::SetProp {
13289                        key: k,
13290                        field,
13291                        value,
13292                    } if Self::aliases_match(&alias_intervals, k, commit) => {
13293                        Some(HistoryChange::PropSet {
13294                            field: field.clone(),
13295                            value: value.clone(),
13296                        })
13297                    }
13298                    WalRecord::SetPropId { id, field, value } => {
13299                        // Use key_of_historical (not key_of) so a node's prop_set
13300                        // events remain visible after the node is later deleted:
13301                        // key_of returns None for a tombstoned id, which would
13302                        // silently drop every PropSet between insert and delete.
13303                        // Mirrors the InsertEdgeId arm below and edge_history's
13304                        // own id-keyed arms.
13305                        match self.ids.key_of_historical(*id) {
13306                            // key_of_historical returns the last-known (possibly
13307                            // post-rename, possibly post-delete) key; compare to queried key.
13308                            Some(resolved) if resolved == key => {
13309                                let field_str = match self.syms.resolve(*field) {
13310                                    Some(s) => s.to_string(),
13311                                    None => continue,
13312                                };
13313                                Some(HistoryChange::PropSet {
13314                                    field: field_str,
13315                                    value: value.clone(),
13316                                })
13317                            }
13318                            _ => None,
13319                        }
13320                    }
13321                    WalRecord::RemoveProp { key: k, field }
13322                        if Self::aliases_match(&alias_intervals, k, commit) =>
13323                    {
13324                        Some(HistoryChange::PropRemoved {
13325                            field: field.clone(),
13326                        })
13327                    }
13328                    WalRecord::InsertEdge {
13329                        edge_type,
13330                        src_key,
13331                        dst_key,
13332                    } => {
13333                        if Self::aliases_match(&alias_intervals, src_key, commit) {
13334                            Some(HistoryChange::EdgeAdded {
13335                                edge_type: edge_type.clone(),
13336                                other: dst_key.clone(),
13337                                outgoing: true,
13338                            })
13339                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13340                            Some(HistoryChange::EdgeAdded {
13341                                edge_type: edge_type.clone(),
13342                                other: src_key.clone(),
13343                                outgoing: false,
13344                            })
13345                        } else {
13346                            None
13347                        }
13348                    }
13349                    WalRecord::InsertEdgeId { etype, src, dst } => {
13350                        let etype_str = match self.syms.resolve(*etype) {
13351                            Some(s) => s.to_string(),
13352                            None => continue,
13353                        };
13354                        // key_of_historical (not key_of): an edge added before
13355                        // either endpoint was later deleted must still resolve —
13356                        // see the SetPropId arm above and edge_history's
13357                        // InsertEdgeId arm, which use the same lookup for the
13358                        // same reason.
13359                        let src_key = self.ids.key_of_historical(*src);
13360                        let dst_key = self.ids.key_of_historical(*dst);
13361                        if src_key == Some(key) {
13362                            let other = match dst_key {
13363                                Some(s) => s.to_string(),
13364                                None => continue,
13365                            };
13366                            Some(HistoryChange::EdgeAdded {
13367                                edge_type: etype_str,
13368                                other,
13369                                outgoing: true,
13370                            })
13371                        } else if dst_key == Some(key) {
13372                            let other = match src_key {
13373                                Some(s) => s.to_string(),
13374                                None => continue,
13375                            };
13376                            Some(HistoryChange::EdgeAdded {
13377                                edge_type: etype_str,
13378                                other,
13379                                outgoing: false,
13380                            })
13381                        } else {
13382                            None
13383                        }
13384                    }
13385                    WalRecord::DeleteEdge {
13386                        edge_type,
13387                        src_key,
13388                        dst_key,
13389                    } => {
13390                        if Self::aliases_match(&alias_intervals, src_key, commit) {
13391                            Some(HistoryChange::EdgeRemoved {
13392                                edge_type: edge_type.clone(),
13393                                other: dst_key.clone(),
13394                                outgoing: true,
13395                            })
13396                        } else if Self::aliases_match(&alias_intervals, dst_key, commit) {
13397                            Some(HistoryChange::EdgeRemoved {
13398                                edge_type: edge_type.clone(),
13399                                other: src_key.clone(),
13400                                outgoing: false,
13401                            })
13402                        } else {
13403                            None
13404                        }
13405                    }
13406                    WalRecord::DeleteNode { key: k }
13407                        if Self::aliases_match(&alias_intervals, k, commit) =>
13408                    {
13409                        Some(HistoryChange::NodeDeleted)
13410                    }
13411                    // Skip: rule/view/fulltext/intern metadata; Batch wrapper handled above.
13412                    _ => None,
13413                };
13414
13415                if let Some(change) = change {
13416                    out.push(HistoryEntry { commit, change });
13417                }
13418            }
13419        }
13420
13421        Ok(HistoryResult {
13422            items: out,
13423            total_commits,
13424            horizon: floor,
13425        })
13426    }
13427
13428    /// Return the per-edge change history between nodes `a` and `b` by scanning
13429    /// the on-disk WAL.
13430    ///
13431    /// ## Horizon
13432    ///
13433    /// History reaches back only to the last WAL-truncating snapshot, exactly
13434    /// like `node_history` and `open_at`. The returned [`HistoryResult`] carries
13435    /// `total_commits` (= number of WAL frames), which is the exclusive upper
13436    /// bound for valid commit indices.
13437    ///
13438    /// ## Derived edges
13439    ///
13440    /// Rule-derived edges appear via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13441    /// WAL markers written by `log_then_apply_with` after each rule-firing
13442    /// mutation. The `rule` field of those events carries the rule name.
13443    ///
13444    /// ## DeleteNode
13445    ///
13446    /// When a node is deleted, its manual incident edges are swept inline without
13447    /// individual `DeleteEdge` WAL records. `edge_history` detects `DeleteNode`
13448    /// events for either endpoint and synthesises `Retracted(rule:None)` events
13449    /// for each manual edge that was active at that point. Derived edges active at
13450    /// the time of deletion are handled by the `DerivedEdgeRetracted` marker that
13451    /// the engine appends immediately after the `DeleteNode` record; those events
13452    /// carry correct rule attribution and are emitted by the marker arm, not the
13453    /// synthetic sweep.
13454    ///
13455    /// ## Masks
13456    ///
13457    /// Like `node_history`, this method has no mask parameter and returns WAL
13458    /// history regardless of any role mask. For masked history semantics, apply
13459    /// the mask at the caller level.
13460    pub fn edge_history(
13461        &self,
13462        a: &str,
13463        b: &str,
13464    ) -> Result<crate::history::HistoryResult<crate::history::EdgeHistoryEvent>> {
13465        use crate::history::{EdgeEvent, EdgeHistoryEvent, HistoryResult};
13466        use core_storage::wal::WalRecord;
13467
13468        // The floor that belongs with these frames: both are read from disk
13469        // here. A handle that has not refreshed since another process moved
13470        // the floor would otherwise number new frames from its old one (row 81).
13471        let floor = self.fs.read_horizon_floor()?;
13472        let (frames, _) = self.all_frames()?;
13473        let total_commits = floor + frames.len() as u64;
13474
13475        // Resolve all historical names for a and b (handles RenameNode in the WAL).
13476        // Intervals are commit-bounded so recycled keys don't contaminate histories.
13477        let alias_a = self.build_key_alias_intervals(&frames, a, floor);
13478        let alias_b = self.build_key_alias_intervals(&frames, b, floor);
13479
13480        // Active edges between a and b tracked as (edge_type, src_key, dst_key, is_derived).
13481        // The is_derived flag is used by the DeleteNode sweep: manual edges are
13482        // swept with a synthetic Retracted(rule:None); derived edges are skipped
13483        // because the engine writes a DerivedEdgeRetracted marker immediately after
13484        // the DeleteNode record, which carries the correct rule attribution.
13485        let mut active: Vec<(String, String, String, bool)> = Vec::new();
13486        let mut out: Vec<EdgeHistoryEvent> = Vec::new();
13487
13488        for (local_i, frame) in frames.iter().enumerate() {
13489            let commit = floor + local_i as u64;
13490            let records: &[WalRecord] = match frame {
13491                WalRecord::Batch(inner) => inner.as_slice(),
13492                single => std::slice::from_ref(single),
13493            };
13494
13495            for rec in records {
13496                match rec {
13497                    WalRecord::InsertEdge {
13498                        edge_type,
13499                        src_key,
13500                        dst_key,
13501                    } => {
13502                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13503                            && Self::aliases_match(&alias_b, dst_key, commit);
13504                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13505                            && Self::aliases_match(&alias_a, dst_key, commit);
13506                        if is_ab || is_ba {
13507                            active.push((
13508                                edge_type.clone(),
13509                                src_key.clone(),
13510                                dst_key.clone(),
13511                                false,
13512                            ));
13513                            out.push(EdgeHistoryEvent {
13514                                edge_type: edge_type.clone(),
13515                                commit,
13516                                event: EdgeEvent::Added,
13517                                rule: None,
13518                            });
13519                        }
13520                    }
13521                    WalRecord::InsertEdgeId { etype, src, dst } => {
13522                        let etype_str = match self.syms.resolve(*etype) {
13523                            Some(s) => s.to_string(),
13524                            None => continue,
13525                        };
13526                        // Use key_of_historical so tombstoned nodes (deleted
13527                        // later in the WAL) still resolve during the scan.
13528                        let src_key = self.ids.key_of_historical(*src);
13529                        let dst_key = self.ids.key_of_historical(*dst);
13530                        let is_ab = src_key == Some(a) && dst_key == Some(b);
13531                        let is_ba = src_key == Some(b) && dst_key == Some(a);
13532                        if is_ab || is_ba {
13533                            let src_str = src_key.unwrap().to_string();
13534                            let dst_str = dst_key.unwrap().to_string();
13535                            active.push((etype_str.clone(), src_str, dst_str, false));
13536                            out.push(EdgeHistoryEvent {
13537                                edge_type: etype_str,
13538                                commit,
13539                                event: EdgeEvent::Added,
13540                                rule: None,
13541                            });
13542                        }
13543                    }
13544                    WalRecord::DeleteEdge {
13545                        edge_type,
13546                        src_key,
13547                        dst_key,
13548                    } => {
13549                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13550                            && Self::aliases_match(&alias_b, dst_key, commit);
13551                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13552                            && Self::aliases_match(&alias_a, dst_key, commit);
13553                        if is_ab || is_ba {
13554                            // Remove the first matching active entry (flag ignored).
13555                            if let Some(pos) = active.iter().position(|(et, s, d, _)| {
13556                                et == edge_type && s == src_key && d == dst_key
13557                            }) {
13558                                active.remove(pos);
13559                            }
13560                            out.push(EdgeHistoryEvent {
13561                                edge_type: edge_type.clone(),
13562                                commit,
13563                                event: EdgeEvent::Retracted,
13564                                rule: None,
13565                            });
13566                        }
13567                    }
13568                    WalRecord::DeleteNode { key: k }
13569                        if Self::aliases_match(&alias_a, k, commit)
13570                            || Self::aliases_match(&alias_b, k, commit) =>
13571                    {
13572                        // Sweep: implicitly retract only MANUAL active edges.
13573                        // Derived active edges are skipped here because the rule
13574                        // engine appends a DerivedEdgeRetracted marker immediately
13575                        // after this DeleteNode record; that marker produces the
13576                        // single correctly-attributed Retracted event.  Derived
13577                        // entries are dropped from `active` (the marker arm's
13578                        // idempotent retain finds nothing to remove).
13579                        for (et, _, _, is_derived) in active.drain(..) {
13580                            if !is_derived {
13581                                out.push(EdgeHistoryEvent {
13582                                    edge_type: et,
13583                                    commit,
13584                                    event: EdgeEvent::Retracted,
13585                                    rule: None,
13586                                });
13587                            }
13588                            // Derived: drop silently; marker carries the Retracted event.
13589                        }
13590                    }
13591                    WalRecord::DerivedEdgeAdded {
13592                        rule,
13593                        edge_type: et,
13594                        src_key,
13595                        dst_key,
13596                    } => {
13597                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13598                            && Self::aliases_match(&alias_b, dst_key, commit);
13599                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13600                            && Self::aliases_match(&alias_a, dst_key, commit);
13601                        if is_ab || is_ba {
13602                            active.push((et.clone(), src_key.clone(), dst_key.clone(), true));
13603                            out.push(EdgeHistoryEvent {
13604                                edge_type: et.clone(),
13605                                commit,
13606                                event: EdgeEvent::Added,
13607                                rule: Some(rule.clone()),
13608                            });
13609                        }
13610                    }
13611                    WalRecord::DerivedEdgeRetracted {
13612                        rule,
13613                        edge_type: et,
13614                        src_key,
13615                        dst_key,
13616                    } => {
13617                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13618                            && Self::aliases_match(&alias_b, dst_key, commit);
13619                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13620                            && Self::aliases_match(&alias_a, dst_key, commit);
13621                        if is_ab || is_ba {
13622                            // Push unconditionally: a derived edge whose Added marker
13623                            // predates the history horizon has no `active` entry, but
13624                            // the retraction is still a real in-window event.
13625                            // Remove from active idempotently if present.
13626                            active.retain(|(aet, s, d, _)| {
13627                                !(aet == et && s == src_key && d == dst_key)
13628                            });
13629                            out.push(EdgeHistoryEvent {
13630                                edge_type: et.clone(),
13631                                commit,
13632                                event: EdgeEvent::Retracted,
13633                                rule: Some(rule.clone()),
13634                            });
13635                        }
13636                    }
13637                    // All other records (InsertNode, SetProp, CreateRule, etc.)
13638                    // do not affect edges between a and b.
13639                    _ => {}
13640                }
13641            }
13642        }
13643
13644        Ok(HistoryResult {
13645            items: out,
13646            total_commits,
13647            horizon: floor,
13648        })
13649    }
13650
13651    /// Return `true` iff an edge of `edge_type` existed between `a` and `b`
13652    /// (in either direction) at the WAL commit `at_commit`.
13653    ///
13654    /// ## Horizon
13655    ///
13656    /// Valid commit indices are `0..total_commits` where `total_commits` is the
13657    /// number of WAL frames. An `at_commit >= total_commits` is outside the
13658    /// visible horizon and returns [`GraphError::CommitOutOfRange`].
13659    ///
13660    /// ## Derived edges
13661    ///
13662    /// Rule-derived edges are tracked via `DerivedEdgeAdded` / `DerivedEdgeRetracted`
13663    /// WAL markers appended at firing time (Task 1). `was_linked` reads these markers
13664    /// and therefore includes derived edges in its point-in-time evaluation,
13665    /// matching `edge_history`'s fidelity.
13666    ///
13667    /// ## Which nodes
13668    ///
13669    /// `a` and `b` name the nodes that hold those keys now, and the answer
13670    /// follows each of them back through its renames. A node that held one of
13671    /// the keys earlier and was deleted is another node: for a commit at
13672    /// which that earlier node was linked, the answer is `false`.
13673    pub fn was_linked(&self, a: &str, b: &str, edge_type: &str, at_commit: u64) -> Result<bool> {
13674        use core_storage::wal::WalRecord;
13675
13676        // The floor that belongs with these frames: both are read from disk
13677        // here. A handle that has not refreshed since another process moved
13678        // the floor would otherwise number new frames from its old one (row 81).
13679        let floor = self.fs.read_horizon_floor()?;
13680        let (frames, _) = self.all_frames()?;
13681        let total_commits = floor + frames.len() as u64;
13682
13683        // Horizon floor: commits in pruned archives are unreachable.
13684        if at_commit < floor {
13685            return Err(GraphError::CommitOutOfRange {
13686                commit: at_commit,
13687                total: total_commits,
13688                floor,
13689            });
13690        }
13691        if at_commit >= total_commits {
13692            return Err(GraphError::CommitOutOfRange {
13693                commit: at_commit,
13694                total: total_commits,
13695                floor,
13696            });
13697        }
13698
13699        // Resolve all historical names for a and b (handles RenameNode in the WAL).
13700        // Intervals are commit-bounded so recycled keys don't contaminate point-in-time reads.
13701        let alias_a = self.key_alias_intervals(&frames, a, floor, true);
13702        let alias_b = self.key_alias_intervals(&frames, b, floor, true);
13703        // The two nodes themselves, when both are live. A record that names
13704        // its endpoints by dense id is then matched by id: a retired id keeps
13705        // the key it last had, and matching on that string took an earlier,
13706        // deleted holder of the key for the node that has it now.
13707        let live_ids = self.ids.get(a).zip(self.ids.get(b));
13708
13709        // Local index into surviving frames (0 = first frame of oldest archive).
13710        let local_commit = at_commit - floor;
13711
13712        // Replay local frames 0..=local_commit, tracking active edges between
13713        // the two nodes: the edge type and whether it runs from `a` to `b`.
13714        // Tracked by which node, not by the key each record names it with: an
13715        // edge written while a node was called one thing and deleted while it
13716        // was called another is the same edge.
13717        let mut active: BTreeSet<(String, bool)> = BTreeSet::new();
13718
13719        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13720            let commit = floor + local_i as u64;
13721            let records: &[WalRecord] = match frame {
13722                WalRecord::Batch(inner) => inner.as_slice(),
13723                single => std::slice::from_ref(single),
13724            };
13725
13726            for rec in records {
13727                match rec {
13728                    WalRecord::InsertEdge {
13729                        edge_type: et,
13730                        src_key,
13731                        dst_key,
13732                    } => {
13733                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13734                            && Self::aliases_match(&alias_b, dst_key, commit);
13735                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13736                            && Self::aliases_match(&alias_a, dst_key, commit);
13737                        if is_ab {
13738                            active.insert((et.clone(), true));
13739                        }
13740                        if is_ba {
13741                            active.insert((et.clone(), false));
13742                        }
13743                    }
13744                    WalRecord::InsertEdgeId { etype, src, dst } => {
13745                        let etype_str = match self.syms.resolve(*etype) {
13746                            Some(s) => s.to_string(),
13747                            None => continue,
13748                        };
13749                        // Use key_of_historical so tombstoned nodes resolve.
13750                        let src_key = self.ids.key_of_historical(*src);
13751                        let dst_key = self.ids.key_of_historical(*dst);
13752                        let (is_ab, is_ba) = match live_ids {
13753                            Some((id_a, id_b)) => {
13754                                (*src == id_a && *dst == id_b, *src == id_b && *dst == id_a)
13755                            }
13756                            None => (
13757                                src_key == Some(a) && dst_key == Some(b),
13758                                src_key == Some(b) && dst_key == Some(a),
13759                            ),
13760                        };
13761                        if is_ab {
13762                            active.insert((etype_str.clone(), true));
13763                        }
13764                        if is_ba {
13765                            active.insert((etype_str, false));
13766                        }
13767                    }
13768                    WalRecord::DeleteEdge {
13769                        edge_type: et,
13770                        src_key,
13771                        dst_key,
13772                    } => {
13773                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13774                            && Self::aliases_match(&alias_b, dst_key, commit);
13775                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13776                            && Self::aliases_match(&alias_a, dst_key, commit);
13777                        if is_ab {
13778                            active.remove(&(et.clone(), true));
13779                        }
13780                        if is_ba {
13781                            active.remove(&(et.clone(), false));
13782                        }
13783                    }
13784                    WalRecord::DeleteNode { key: k }
13785                        if Self::aliases_match(&alias_a, k, commit)
13786                            || Self::aliases_match(&alias_b, k, commit) =>
13787                    {
13788                        // One of the two nodes is gone, and every edge
13789                        // between them with it.
13790                        active.clear();
13791                    }
13792                    WalRecord::DerivedEdgeAdded {
13793                        edge_type: et,
13794                        src_key,
13795                        dst_key,
13796                        ..
13797                    } => {
13798                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13799                            && Self::aliases_match(&alias_b, dst_key, commit);
13800                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13801                            && Self::aliases_match(&alias_a, dst_key, commit);
13802                        if is_ab {
13803                            active.insert((et.clone(), true));
13804                        }
13805                        if is_ba {
13806                            active.insert((et.clone(), false));
13807                        }
13808                    }
13809                    WalRecord::DerivedEdgeRetracted {
13810                        edge_type: et,
13811                        src_key,
13812                        dst_key,
13813                        ..
13814                    } => {
13815                        let is_ab = Self::aliases_match(&alias_a, src_key, commit)
13816                            && Self::aliases_match(&alias_b, dst_key, commit);
13817                        let is_ba = Self::aliases_match(&alias_b, src_key, commit)
13818                            && Self::aliases_match(&alias_a, dst_key, commit);
13819                        if is_ab {
13820                            active.remove(&(et.clone(), true));
13821                        }
13822                        if is_ba {
13823                            active.remove(&(et.clone(), false));
13824                        }
13825                    }
13826                    _ => {}
13827                }
13828            }
13829        }
13830
13831        Ok(active.iter().any(|(et, _)| et == edge_type))
13832    }
13833
13834    /// Every edge incident to `key` — either endpoint — that existed at WAL
13835    /// commit `commit`, from ONE scan of the WAL.
13836    ///
13837    /// This is the bulk form of [`was_linked`](GraphDb::was_linked): answering
13838    /// "what did K's relationships look like at commit C" with one call instead
13839    /// of one [`edge_history`](GraphDb::edge_history) per candidate partner.
13840    /// The two agree edge for edge.
13841    ///
13842    /// Results are sorted by `(edge_type, src_key, dst_key)`.
13843    ///
13844    /// ## Horizon
13845    ///
13846    /// Valid commit indices are `wal_horizon_floor()..wal_total_commits()`;
13847    /// anything outside is [`GraphError::CommitOutOfRange`], exactly like
13848    /// `was_linked`. An unknown key is not an error — it simply had no edges.
13849    ///
13850    /// ## Derived edges
13851    ///
13852    /// `DerivedEdgeAdded` / `DerivedEdgeRetracted` markers carry rule
13853    /// attribution, so a rule-owned edge comes back with `derived: true` and
13854    /// `rule: Some(name)`.
13855    ///
13856    /// ## Renames
13857    ///
13858    /// `key` is matched through the same commit-bounded alias intervals
13859    /// `edge_history` uses, so querying a node's *current* key surfaces edges
13860    /// written under an earlier name. Endpoint keys in the result are reported
13861    /// under the name the node carries today, so they can be fed straight back
13862    /// into `node_info`, `explain` or another `edges_at`.
13863    ///
13864    /// ## Masks
13865    ///
13866    /// Like `edge_history` and `node_history`, this reads the WAL regardless of
13867    /// any role mask. Apply masking at the caller level.
13868    pub fn edges_at(&self, key: &str, commit: u64) -> Result<Vec<EdgeAt>> {
13869        use core_storage::wal::WalRecord;
13870
13871        // Floor and frames from the same read of the disk (row 81).
13872        let floor = self.fs.read_horizon_floor()?;
13873        let (frames, _) = self.all_frames()?;
13874        let total_commits = floor + frames.len() as u64;
13875
13876        // Horizon floor: commits in pruned archives are unreachable.
13877        if commit < floor || commit >= total_commits {
13878            return Err(GraphError::CommitOutOfRange {
13879                commit,
13880                total: total_commits,
13881                floor,
13882            });
13883        }
13884
13885        // Commit-bounded historical names of `key` (handles RenameNode).
13886        let alias = self.build_key_alias_intervals(&frames, key, floor);
13887
13888        // Forward rename chain, for reporting endpoints under their current
13889        // names: old key → [(commit, new key)] in ascending commit order.
13890        // Built over the whole WAL, not just the prefix up to `commit`, because
13891        // a rename after `commit` still changes what the node is called today.
13892        let mut renames: HashMap<String, Vec<(u64, String)>> = HashMap::new();
13893        for (local_i, frame) in frames.iter().enumerate() {
13894            let c = floor + local_i as u64;
13895            let records: &[WalRecord] = match frame {
13896                WalRecord::Batch(inner) => inner.as_slice(),
13897                single => std::slice::from_ref(single),
13898            };
13899            for rec in records {
13900                if let WalRecord::RenameNode { old_key, new_key } = rec {
13901                    renames
13902                        .entry(old_key.clone())
13903                        .or_default()
13904                        .push((c, new_key.clone()));
13905                }
13906            }
13907        }
13908
13909        // The name a node written as `k` at commit `from` carries today.
13910        // Follows the first rename at or after `from`, then keeps going. The
13911        // iteration cap bounds a rename cycle inside a single batch.
13912        let canon = |k: &str, from: u64| -> String {
13913            if renames.is_empty() {
13914                return k.to_string();
13915            }
13916            let mut cur = k.to_string();
13917            let mut at = from;
13918            for _ in 0..64 {
13919                match renames
13920                    .get(&cur)
13921                    .and_then(|v| v.iter().find(|(c, _)| *c >= at))
13922                {
13923                    Some((c, new)) => {
13924                        at = *c;
13925                        cur = new.clone();
13926                    }
13927                    None => break,
13928                }
13929            }
13930            cur
13931        };
13932
13933        let local_commit = commit - floor;
13934        // (edge_type, src_key, dst_key) → (derived, rule)
13935        let mut active: BTreeMap<(String, String, String), (bool, Option<String>)> =
13936            BTreeMap::new();
13937
13938        for (local_i, frame) in frames.iter().enumerate().take((local_commit + 1) as usize) {
13939            let c = floor + local_i as u64;
13940            let records: &[WalRecord] = match frame {
13941                WalRecord::Batch(inner) => inner.as_slice(),
13942                single => std::slice::from_ref(single),
13943            };
13944
13945            for rec in records {
13946                match rec {
13947                    WalRecord::InsertEdge {
13948                        edge_type,
13949                        src_key,
13950                        dst_key,
13951                    } => {
13952                        if Self::aliases_match(&alias, src_key, c)
13953                            || Self::aliases_match(&alias, dst_key, c)
13954                        {
13955                            active.insert(
13956                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
13957                                (false, None),
13958                            );
13959                        }
13960                    }
13961                    WalRecord::InsertEdgeId { etype, src, dst } => {
13962                        let Some(etype_str) = self.syms.resolve(*etype) else {
13963                            continue;
13964                        };
13965                        // `key_of_historical` resolves tombstoned ids too, and
13966                        // already returns the node's current key — no rename
13967                        // canonicalisation needed on this arm.
13968                        let (Some(src_key), Some(dst_key)) = (
13969                            self.ids.key_of_historical(*src),
13970                            self.ids.key_of_historical(*dst),
13971                        ) else {
13972                            continue;
13973                        };
13974                        if src_key == key || dst_key == key {
13975                            active.insert(
13976                                (
13977                                    etype_str.to_string(),
13978                                    src_key.to_string(),
13979                                    dst_key.to_string(),
13980                                ),
13981                                (false, None),
13982                            );
13983                        }
13984                    }
13985                    WalRecord::DeleteEdge {
13986                        edge_type,
13987                        src_key,
13988                        dst_key,
13989                    } => {
13990                        if Self::aliases_match(&alias, src_key, c)
13991                            || Self::aliases_match(&alias, dst_key, c)
13992                        {
13993                            active.remove(&(
13994                                edge_type.clone(),
13995                                canon(src_key, c),
13996                                canon(dst_key, c),
13997                            ));
13998                        }
13999                    }
14000                    WalRecord::DeleteNode { key: k } => {
14001                        if active.is_empty() {
14002                            continue;
14003                        }
14004                        if Self::aliases_match(&alias, k, c) {
14005                            // Our node is gone; every incident edge goes with it.
14006                            active.clear();
14007                        } else {
14008                            // A partner is gone; its edges to us go with it.
14009                            let ck = canon(k, c);
14010                            active.retain(|(_, s, d), _| *s != ck && *d != ck);
14011                        }
14012                    }
14013                    WalRecord::DerivedEdgeAdded {
14014                        rule,
14015                        edge_type,
14016                        src_key,
14017                        dst_key,
14018                    } => {
14019                        if Self::aliases_match(&alias, src_key, c)
14020                            || Self::aliases_match(&alias, dst_key, c)
14021                        {
14022                            active.insert(
14023                                (edge_type.clone(), canon(src_key, c), canon(dst_key, c)),
14024                                (true, Some(rule.clone())),
14025                            );
14026                        }
14027                    }
14028                    WalRecord::DerivedEdgeRetracted {
14029                        edge_type,
14030                        src_key,
14031                        dst_key,
14032                        ..
14033                    } => {
14034                        if Self::aliases_match(&alias, src_key, c)
14035                            || Self::aliases_match(&alias, dst_key, c)
14036                        {
14037                            active.remove(&(
14038                                edge_type.clone(),
14039                                canon(src_key, c),
14040                                canon(dst_key, c),
14041                            ));
14042                        }
14043                    }
14044                    // InsertNode, SetProp, CreateRule, … do not move edges.
14045                    _ => {}
14046                }
14047            }
14048        }
14049
14050        // BTreeMap iteration is already (edge_type, src, dst) order.
14051        Ok(active
14052            .into_iter()
14053            .map(|((edge_type, src_key, dst_key), (derived, rule))| EdgeAt {
14054                edge_type,
14055                src_key,
14056                dst_key,
14057                derived,
14058                rule,
14059            })
14060            .collect())
14061    }
14062
14063    /// The derived edges that would be retracted and derived if `key.field`
14064    /// were set to `value` — computed WITHOUT writing anything.
14065    ///
14066    /// Nothing is committed and nothing on `self` is mutated: the rule engine's
14067    /// provenance, its candidate indexes, the topology and the property columns
14068    /// are all cloned first, the change is applied to the clone, and the real
14069    /// per-node re-derivation (`RuleEngine::on_node_changed` — the same call
14070    /// `set_prop` makes during apply) runs against it. The derived-edge deltas
14071    /// it emits are the answer, so rule semantics — predicates, top-k,
14072    /// via-hops, chaining, weights — are the engine's, not a re-implementation.
14073    ///
14074    /// Works on a read-only handle.
14075    ///
14076    /// **While a rule's vector index is still building** (`RuleStats::building`)
14077    /// the clone carries no pending-build state, so this reports the edges that
14078    /// rule would derive — which the live store will not derive until its
14079    /// backfill runs. Right about the end state, early about the timing.
14080    ///
14081    /// Returns `Err(KeyNotFound)` for an unknown or tombstoned key and
14082    /// `Err(ViewPropReadOnly)` for a field a view owns — matching
14083    /// [`set_prop`](GraphDb::set_prop)'s validation. A change with no effect
14084    /// (the node already holds `value`, or no rule watches `field`) returns
14085    /// empty lists.
14086    ///
14087    /// ## Cost
14088    ///
14089    /// One clone of the property columns, the topology overlay, the symbol
14090    /// interner, the edge properties and the provenance map, plus one candidate
14091    /// re-index (O(nodes × rules)). That is much cheaper than copying the store
14092    /// directory, but it is not free — this is an interactive "what if", not a
14093    /// hot path.
14094    pub fn what_if_set_prop(&self, key: &str, field: &str, value: Value) -> Result<WhatIf> {
14095        // The engine's provenance, HNSW and IVF state live in the mmap'd base
14096        // until something asks for them. On a store opened cold from a snapshot
14097        // this is the first ask, and without it the clone below starts from an
14098        // empty provenance map: nothing to retract, so `lost` comes back empty.
14099        self.ensure_v8_base_sections_loaded();
14100
14101        let empty = WhatIf {
14102            lost: Vec::new(),
14103            gained: Vec::new(),
14104        };
14105
14106        if let Some(view_name) = self.view_store.view_for_prop(field) {
14107            return Err(GraphError::ViewPropReadOnly {
14108                view_name: view_name.to_string(),
14109            });
14110        }
14111        MutPreview::new(self).check_live_key(key)?;
14112        let id = self
14113            .ids
14114            .get(key)
14115            .ok_or_else(|| GraphError::KeyNotFound { key: key.into() })?;
14116
14117        let rules: Vec<RuleDef> = self.engine.rules().cloned().collect();
14118        if rules.is_empty() {
14119            return Ok(empty);
14120        }
14121
14122        // No rule watches this field → no derivation can change.
14123        if !rules.iter().any(|r| r.watched_fields().contains(field)) {
14124            return Ok(empty);
14125        }
14126
14127        let old_value = build_props_view(&self.props, &self.base)
14128            .get(id, field)
14129            .map(|vr| vr.into_value());
14130        if old_value.as_ref() == Some(&value) {
14131            return Ok(empty);
14132        }
14133
14134        // --- Clone every piece of state the re-derivation writes to. ---
14135        // `what_if` mutates its own throwaway copies and writes nothing, so it
14136        // takes owned clones rather than sharing the `Arc`s. Deref-clone, not
14137        // `Arc::clone`: sharing here would make `make_mut` copy on first touch
14138        // anyway, and an owned local keeps the rest of this function unchanged.
14139        let mut props = (*self.props).clone();
14140        let mut topo = (*self.topo).clone();
14141        let mut syms = (*self.syms).clone();
14142        let mut edge_props = (*self.edge_props).clone();
14143
14144        let mut tripped: BTreeMap<String, bool> = BTreeMap::new();
14145        let mut fires: BTreeMap<String, u64> = BTreeMap::new();
14146        for r in &rules {
14147            tripped.insert(r.name.clone(), self.engine.is_tripped(&r.name));
14148            fires.insert(r.name.clone(), self.engine.fire_count(&r.name));
14149        }
14150        // `provenance()` decodes retained snapshot bytes on first use; the
14151        // engine clone needs the real map, not an empty one.
14152        let provenance = self.engine.provenance().clone();
14153        let mut engine = core_rules::RuleEngine::from_persist(rules, provenance, tripped, fires);
14154
14155        // Build the candidate indexes from the state BEFORE the change, exactly
14156        // as apply() sees them: `on_node_changed` withdraws the node under its
14157        // old value and refiles it under the new one, so the index must not
14158        // already reflect the change.
14159        engine.reindex_all_load_state(
14160            &self.ids,
14161            &syms,
14162            &self.labels,
14163            build_props_view(&self.props, &self.base),
14164            self.engine.export_ivf_state(),
14165            self.engine.export_hnsw_state_passthrough(),
14166        );
14167        engine.set_emit_deltas(true);
14168
14169        // --- Apply the hypothetical change and re-derive. ---
14170        props.set(id, field, value);
14171        {
14172            let mut gm = make_graph_mut(
14173                &self.ids,
14174                &mut syms,
14175                &self.labels,
14176                build_props_view(&props, &self.base),
14177                &mut topo,
14178                &self.base,
14179                &mut edge_props,
14180            );
14181            engine.on_node_changed(id, Some((field, old_value)), &mut gm);
14182        }
14183
14184        let mut lost: BTreeSet<EdgeAt> = BTreeSet::new();
14185        let mut gained: BTreeSet<EdgeAt> = BTreeSet::new();
14186        for d in engine.drain_deltas() {
14187            let edge = EdgeAt {
14188                edge_type: d.edge_type,
14189                src_key: d.src_key,
14190                dst_key: d.dst_key,
14191                derived: true,
14192                rule: Some(d.rule),
14193            };
14194            if d.fired {
14195                gained.insert(edge);
14196            } else {
14197                lost.insert(edge);
14198            }
14199        }
14200        // An edge retracted and re-derived within the same re-derivation (top-k
14201        // churn) is not a change the caller would see.
14202        let churn: Vec<EdgeAt> = lost.intersection(&gained).cloned().collect();
14203        for e in churn {
14204            lost.remove(&e);
14205            gained.remove(&e);
14206        }
14207
14208        Ok(WhatIf {
14209            lost: lost.into_iter().collect(),
14210            gained: gained.into_iter().collect(),
14211        })
14212    }
14213
14214    pub fn edge_count(&self) -> u64 {
14215        self.topo_view().edge_count()
14216    }
14217
14218    /// Live/tombstone/edge counts plus per-rule provenance size, trip latch,
14219    /// and fire counter (includes rebuild evaluations). Rules are sorted by name.
14220    pub fn stats(&self) -> Stats {
14221        self.ensure_v8_base_sections_loaded();
14222        let building = self.engine.builds_in_progress();
14223        let rules: Vec<RuleStats> = self
14224            .engine
14225            .rules()
14226            .map(|r| RuleStats {
14227                name: r.name.clone(),
14228                edges: self
14229                    .engine
14230                    .provenance()
14231                    .get(&r.name)
14232                    .map(|s| s.len() as u64)
14233                    .unwrap_or(0),
14234                tripped: self.engine.is_tripped(&r.name),
14235                fires: self.engine.fire_count(&r.name),
14236                approximate: r.approximate,
14237                building: building.iter().find(|b| b.rule == r.name).cloned(),
14238            })
14239            .collect();
14240        Stats {
14241            nodes_live: self.ids.live_len(),
14242            nodes_tombstoned: self.ids.len() - self.ids.live_len(),
14243            edges: self.topo_view().edge_count(),
14244            rules,
14245            chain_truncations: self.engine.chain_truncations(),
14246            history_floor: self.wal_horizon_floor,
14247            namespaces: self.namespace_stats(),
14248        }
14249    }
14250
14251    /// On-disk size of the WAL file in bytes.
14252    ///
14253    /// Reads file metadata without loading WAL contents.  Returns `Err` for
14254    /// in-memory (`SimFs`) databases where no WAL file exists on disk.
14255    pub fn wal_size_bytes(&self) -> std::io::Result<u64> {
14256        let path = self.fs.wal_path().ok_or_else(|| {
14257            std::io::Error::new(
14258                std::io::ErrorKind::Unsupported,
14259                "wal_path not available for this Fs implementation",
14260            )
14261        })?;
14262        Ok(std::fs::metadata(path)?.len())
14263    }
14264
14265    /// Set the slow-query threshold.  Queries whose execution time equals or
14266    /// exceeds `ms` milliseconds are logged.  Pass `0` to disable.
14267    ///
14268    /// Use this setter in tests — the environment variable
14269    /// `MUSHROOMDB_SLOW_QUERY_MS` is process-global and races parallel test
14270    /// threads.
14271    pub fn set_slow_query_threshold_ms(&mut self, ms: u64) {
14272        self.slow_query_threshold_ms = ms;
14273    }
14274
14275    /// Snapshot of the slow-query ring buffer and lifetime counter.
14276    pub fn slow_query_snapshot(&self) -> SlowQuerySnapshot {
14277        let log = self.slow_queries.lock().unwrap_or_else(|e| e.into_inner());
14278        SlowQuerySnapshot {
14279            threshold_ms: self.slow_query_threshold_ms,
14280            count: log.total,
14281            last: log.entries.iter().cloned().collect(),
14282        }
14283    }
14284
14285    /// Instant the database was opened.  Used by consumers (e.g. `/metrics`)
14286    /// to compute uptime.
14287    pub fn started_at(&self) -> std::time::Instant {
14288        self.started_at
14289    }
14290
14291    /// The on-disk snapshot version a store that has opted in to nothing
14292    /// writes — the **floor**, not the whole answer.
14293    ///
14294    /// It is not "the version this binary writes", and it is not "the version
14295    /// this binary reads". Since v0.6.10 this binary writes 9 **or** 10
14296    /// depending on the store — [`snapshot::version_for`] decides, and a store
14297    /// that has called [`enable_multiplicity`](Self::enable_multiplicity)
14298    /// writes 10 — and it reads 5 through 10. A caller comparing a store's
14299    /// stamp against this value must use `>=`, not `==`, or it will report an
14300    /// opted-in store as needing a migration *down*; `cli::run_migrate` is the
14301    /// worked example.
14302    ///
14303    /// The name is kept for compatibility: it is public API reachable from the
14304    /// CLI and from any embedder, and respelling it would break them for a
14305    /// doc-level clarification.
14306    ///
14307    /// [`snapshot::version_for`]: core_storage::snapshot::version_for
14308    pub fn format_version() -> u16 {
14309        core_storage::snapshot::VERSION
14310    }
14311
14312    /// Test-support: total bytes appended (SimFs only usage).
14313    pub fn fs_total_appended(&self) -> usize
14314    where
14315        F: FsIntrospect,
14316    {
14317        self.fs.total_appended()
14318    }
14319
14320    /// Test-support: successful `Fs::sync` calls (SimFs / counting fs).
14321    pub fn fs_sync_count(&self) -> usize
14322    where
14323        F: FsIntrospect,
14324    {
14325        self.fs.sync_count()
14326    }
14327
14328    /// Consume the db, returning its fs (for crash simulation).
14329    pub fn into_fs(self) -> F {
14330        self.fs
14331    }
14332
14333    pub fn snapshot(&mut self) -> Result<()> {
14334        self.snapshot_with(SnapshotOptions::default())
14335    }
14336
14337    /// Snapshot with explicit options.
14338    ///
14339    /// # `keep_wal`
14340    ///
14341    /// When `keep_wal` is `false` (the default, same as [`snapshot`]):
14342    ///   - The WAL is replaced with a minimal baseline containing one
14343    ///     `EnableFulltext` record per active declaration.  All pre-snapshot
14344    ///     history is discarded; `open_at` can only reach post-snapshot commits.
14345    ///
14346    /// When `keep_wal` is `true`:
14347    ///   - The WAL is left intact.  All pre-snapshot commits remain reachable
14348    ///     via `open_at`.  The existing WAL already contains the original
14349    ///     `EnableFulltext` records, so no baseline re-write is needed; the
14350    ///     recovery guards in `apply()` silently skip any duplicate records on
14351    ///     replay.
14352    ///   - Crash window: a crash after the snapshot write but before the next
14353    ///     WAL write leaves the full pre-snapshot WAL intact.  On reopen the
14354    ///     snapshot is loaded and the WAL replayed idempotently over it — safe
14355    ///     because every `apply()` arm is idempotent when replayed over an
14356    ///     already-current snapshot.
14357    pub fn snapshot_with(&mut self, opts: SnapshotOptions) -> Result<()> {
14358        if self.read_only {
14359            return Err(GraphError::ReadOnly);
14360        }
14361        // A snapshot rewrites `wal.bin` through a tmp+rename, so a peer that is
14362        // appending ends up holding a descriptor on an unlinked inode and loses
14363        // commits it believes durable. Snapshotting therefore requires the
14364        // cross-process write lock, exactly as appending does. Unlike the WAL
14365        // append path this does not go through `log_then_apply_with`, so both
14366        // guards are repeated here.
14367        if self.degraded {
14368            return Err(GraphError::Io(std::io::Error::other(
14369                "database degraded after group-commit fsync failure; reopen required",
14370            )));
14371        }
14372        if self.lock_denied {
14373            return Err(GraphError::Busy { holder: None });
14374        }
14375        // Capture whether snapshot.bin already existed BEFORE this snapshot write.
14376        // Used by the archive path's conservative genesis-chain check: if a prior
14377        // snapshot exists but wal.truncated does not, we cannot distinguish a
14378        // legacy store (may have been truncated in an older code version) from a
14379        // new store that only used keep_wal=true.  Conservative: refuse genesis in
14380        // both cases.  Must be sampled here, before the snapshot write below.
14381        //
14382        // `snapshot_preserved_history` is the one case where the answer is not a
14383        // guess: a snapshot *this handle* took, on a store that had none when it
14384        // opened, and that kept the WAL. The proxy defers to it, because
14385        // otherwise `enable_multiplicity` — whose forced snapshot is exactly
14386        // that — would permanently disqualify the store from a genesis chain it
14387        // is fully entitled to (defect #23).
14388        let had_prior_snapshot = self.fs.snapshot_path().map(|p| p.exists()).unwrap_or(false)
14389            && !self.snapshot_preserved_history;
14390        // The genesis marker, for the first archive of a store whose history
14391        // has never been cut — written now, before anything is replaced.
14392        //
14393        // It says that this store's archives and WAL together reach back to
14394        // its first write, and that is as true now, with no archive yet, as
14395        // it will be when this snapshot has made one. It used to be written
14396        // last, after the snapshot, the rename and the new WAL, and a
14397        // snapshot interrupted anywhere before that never got another
14398        // chance: the next one found the interrupted one's `snapshot.bin`,
14399        // could not tell it from a truncating snapshot's, and declined. Every
14400        // commit in an archive was `out of range` from then on (row 98).
14401        //
14402        // Both conditions as before. No archive exists, and no snapshot did
14403        // before this one: a truncating snapshot always leaves `snapshot.bin`,
14404        // so one found here means the WAL may not start at the beginning, and
14405        // the chain is not claimed (never silently wrong data). The exception
14406        // is a snapshot this handle took itself with the WAL kept, which
14407        // `snapshot_preserved_history` records (defect #23). On SimFs
14408        // (`snapshot_path() == None`) there is never a prior snapshot.
14409        //
14410        // A marker left by a snapshot that then fails is still true. What
14411        // makes it false removes it: a truncating snapshot, and a retention
14412        // sweep that prunes an archive.
14413        if opts.archive_wal
14414            && !had_prior_snapshot
14415            && !self.archive_genesis_chain
14416            && self.fs.list_archives()?.is_empty()
14417        {
14418            self.fs.write_genesis_marker()?;
14419            self.archive_genesis_chain = true;
14420        }
14421        // Which version this store writes. V9 unless it has opted in to
14422        // multiplicity, in which case V10 — the stamp that makes a reader which
14423        // does not know WAL discriminant 23 refuse the open instead of
14424        // truncating the WAL at the first such frame. The container is
14425        // identical either way; only these two header bytes move.
14426        let snapshot_version = core_storage::snapshot::version_for(self.multiplicity);
14427        // A snapshot that leaves the WAL where it is records how much of it
14428        // it holds, so the next open does not apply those writes again on top
14429        // of the state they produced. This handle holds the write lock, so
14430        // every byte in the WAL now is a write it has applied.
14431        let replaces_wal = opts.archive_wal || !opts.keep_wal;
14432        let wal_now = self.fs.read(FileId::Wal)?;
14433        let wal_mark = if replaces_wal {
14434            Vec::new()
14435        } else {
14436            core_storage::v8::WalMark::of(&wal_now).encode()
14437        };
14438        let standing_intent = if replaces_wal {
14439            self.standing_intent(&wal_now)?.0
14440        } else {
14441            None
14442        };
14443        let stop = self.snapshot_stop.take();
14444        let stopped = |at: SnapshotStop| -> Result<()> {
14445            if stop == Some(at) {
14446                return Err(GraphError::Io(std::io::Error::other(format!(
14447                    "snapshot stopped {at:?} (test hook)"
14448                ))));
14449            }
14450            Ok(())
14451        };
14452        self.ensure_v8_base_sections_loaded();
14453        // Ensure provenance is decoded before to_persist() clones it.
14454        self.engine.ensure_provenance_loaded_mut();
14455        let (rule_defs_typed, provenance, rule_tripped, rule_fires) = self.engine.to_persist();
14456        let rule_defs = rule_defs_typed
14457            .iter()
14458            .map(|r| bincode::serialize(r).expect("RuleDef serialize cannot fail"))
14459            .collect();
14460        // Collect HNSW state and IVF state.  When indexes are not yet
14461        // populated (clean open, no mutation since open), pass the retained
14462        // raw bytes through directly so that migrate/snapshot does not
14463        // silently discard fitted approximate-rule indexes.
14464        let hnsw_state = self.engine.export_hnsw_state_passthrough();
14465        let ivf_bytes = if !self.engine.indexes_populated() {
14466            // Pass retained IVF bytes through unchanged (no re-encode).
14467            self.engine.retained_ivf_bytes_clone().unwrap_or_default()
14468        } else {
14469            // Indexes live: encode from current state.
14470            let raw_ivf = self.engine.export_ivf_state();
14471            let ivf_state_map: BTreeMap<String, core_storage::snapshot::PerRuleIvfState> = raw_ivf
14472                .into_iter()
14473                .map(|(name, ((sc, sa, sd), (dc, da, dd)))| {
14474                    (
14475                        name,
14476                        core_storage::snapshot::PerRuleIvfState {
14477                            src: core_storage::snapshot::SideIvfState {
14478                                centroids: sc,
14479                                clusters: sa,
14480                                drift: sd,
14481                            },
14482                            dst: core_storage::snapshot::SideIvfState {
14483                                centroids: dc,
14484                                clusters: da,
14485                                drift: dd,
14486                            },
14487                        },
14488                    )
14489                })
14490                .collect();
14491            if ivf_state_map.is_empty() {
14492                Vec::new()
14493            } else {
14494                bincode::serialize(&ivf_state_map).expect("IVF state serialize cannot fail")
14495            }
14496        };
14497        let view_defs: Vec<Vec<u8>> = self
14498            .view_store
14499            .views()
14500            .map(|v| bincode::serialize(v).expect("ViewDef serialize cannot fail"))
14501            .collect();
14502        if self.base.is_some() {
14503            // V8 merge-snapshot path: encode base+overlay into a new V8 snapshot,
14504            // write it atomically, remap it as the new base, then clear the overlay.
14505            let meta = V8Meta {
14506                labels: (*self.labels).clone(),
14507                edge_props: (*self.edge_props).clone(),
14508                rule_defs,
14509                provenance,
14510                rule_tripped,
14511                rule_fires,
14512                ivf_bytes,
14513                view_defs,
14514                wal_truncated: !opts.keep_wal,
14515                hnsw: hnsw_state,
14516                last_change: self.last_change.clone(),
14517                fulltext_blob: self.fulltext_blob(),
14518                wal_mark: wal_mark.clone(),
14519            };
14520            let mut buf: Vec<u8> = Vec::new();
14521            {
14522                // Clone the Arc so the old base stays alive while we encode.
14523                // The borrow of archived_csr (into old_base's mmap) is released
14524                // at the end of this block, before we replace self.base.
14525                let old_base = self.base.clone().expect("is_some checked above");
14526                let archived_csr = old_base.topology().map_err(|e| GraphError::Corrupt {
14527                    detail: format!("v8 snapshot: topology section: {e:?}"),
14528                })?;
14529                let archived_cols = old_base.columns().map_err(|e| GraphError::Corrupt {
14530                    detail: format!("v8 snapshot: columns section: {e:?}"),
14531                })?;
14532                // `None` when the base predates V9 — the migration path: its
14533                // string columns still carry their own tables and this snapshot
14534                // is the rewrite that collapses them into section 12.
14535                let archived_strings =
14536                    old_base
14537                        .string_table()
14538                        .transpose()
14539                        .map_err(|e| GraphError::Corrupt {
14540                            detail: format!("v8 snapshot: strings section: {e:?}"),
14541                        })?;
14542                let archived_edge_props =
14543                    old_base
14544                        .edge_props_section()
14545                        .map_err(|e| GraphError::Corrupt {
14546                            detail: format!("v8 snapshot: edge_props section: {e:?}"),
14547                        })?;
14548                let edge_props_raw =
14549                    old_base
14550                        .edge_props_raw_bytes()
14551                        .map_err(|e| GraphError::Corrupt {
14552                            detail: format!("v8 snapshot: edge_props raw bytes: {e:?}"),
14553                        })?;
14554                encode_v8(
14555                    Some(archived_csr),
14556                    Some(archived_cols),
14557                    archived_strings,
14558                    Some((archived_edge_props, edge_props_raw)),
14559                    // Never the old snapshot's provenance bytes. The encoder
14560                    // passes them through when the live map is empty, taking
14561                    // empty for "never loaded" — but it is loaded above, so
14562                    // empty means every rule's edges are gone, and passing
14563                    // the old section through brought them back as owned by
14564                    // rules that no longer derive them (ledger row 95).
14565                    None,
14566                    &self.topo,
14567                    &self.props,
14568                    &self.ids,
14569                    &self.syms,
14570                    &meta,
14571                    &mut buf,
14572                )?;
14573            }
14574            core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14575            if replaces_wal {
14576                // Before the snapshot: what is about to be written, and the
14577                // WAL it holds. From here until the WAL is replaced a crash
14578                // leaves that WAL beside the new snapshot, and this is how
14579                // the next open knows not to apply it again (ledger row 92).
14580                //
14581                // An intent already standing — the last snapshot was
14582                // interrupted too, and this store opens because of it — is
14583                // kept beside the new one until the new snapshot is down. If
14584                // this attempt fails before then, it is still what names the
14585                // snapshot on disk.
14586                let mut intent = core_storage::v8::SnapshotIntent::new(&buf, &wal_now).encode();
14587                if let Some(standing) = &standing_intent {
14588                    intent.extend_from_slice(&standing.encode());
14589                }
14590                self.fs.write_atomic(FileId::SnapshotIntent, &intent)?;
14591                // Until this snapshot empties it. If it does not get that far
14592                // and this handle goes on writing, the next append looks at
14593                // whether the intent is still true.
14594                self.intent_unsettled = true;
14595            }
14596            stopped(SnapshotStop::BeforeSnapshotWrite)?;
14597            self.fs.write_atomic(FileId::Snapshot, &buf)?;
14598            // Remap the freshly-written snapshot as the new base.
14599            // C2: use file mmap on RealFs; fall back to from_bytes on SimFs.
14600            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14601                core_storage::v8::MappedBase::map(&snap_path)
14602            } else {
14603                core_storage::v8::MappedBase::from_bytes(buf)
14604            }
14605            .map_err(|e| GraphError::Corrupt {
14606                detail: format!("v8 snapshot: remap new base: {e:?}"),
14607            })?;
14608            self.base = Some(Arc::new(new_base));
14609            // Clear the overlay and prop tombstones — all data is now in the new base.
14610            self.topo = Arc::new(Topology::new());
14611            self.props = Arc::new(core_storage::columns::ColumnStore::new());
14612        } else {
14613            // Legacy path (V5–V7 stores without a V8 base).
14614            //
14615            // Memory-diet path: build V8Meta directly from &self — no SnapshotState
14616            // clone and no encode_v8_from_state intermediate clones.  The big
14617            // structures (self.topo, self.props) are borrowed, not cloned.
14618            // self.edge_props is moved (not cloned) because we immediately clear it
14619            // when we remap the new V8 snapshot as self.base (see below).
14620            //
14621            // Eliminates from peak RSS vs. the old SnapshotState path:
14622            //   • self.topo.clone()      (~topology HashMap footprint)
14623            //   • self.props.clone()     (~column-store footprint)
14624            //   • encode_v8_from_state V8Meta secondary clones (labels, edge_props, …)
14625            let mut meta = V8Meta {
14626                labels: (*self.labels).clone(),
14627                wal_truncated: !opts.keep_wal,
14628                // Moved, not cloned, for the encode — and moved straight back
14629                // below, before anything that can fail. They are released
14630                // only once the new snapshot is mapped and reads go through
14631                // it. Left out of the handle across the write, a snapshot
14632                // that failed there went on with no edge properties at all,
14633                // and the next one that worked wrote that down.
14634                edge_props: std::mem::take(Arc::make_mut(&mut self.edge_props)),
14635                rule_defs,
14636                provenance,
14637                rule_tripped,
14638                rule_fires,
14639                ivf_bytes,
14640                view_defs,
14641                hnsw: hnsw_state,
14642                last_change: self.last_change.clone(),
14643                fulltext_blob: self.fulltext_blob(),
14644                wal_mark: wal_mark.clone(),
14645            };
14646            let mut buf = Vec::new();
14647            let encoded = encode_v8(
14648                None,
14649                None,
14650                None,
14651                None,
14652                None,
14653                &self.topo,
14654                &self.props,
14655                &self.ids,
14656                &self.syms,
14657                &meta,
14658                &mut buf,
14659            );
14660            self.edge_props = Arc::new(std::mem::take(&mut meta.edge_props));
14661            drop(meta);
14662            encoded?;
14663            core_storage::snapshot::stamp_container_version(&mut buf, snapshot_version)?;
14664            if replaces_wal {
14665                // Before the snapshot: what is about to be written, and the
14666                // WAL it holds. From here until the WAL is replaced a crash
14667                // leaves that WAL beside the new snapshot, and this is how
14668                // the next open knows not to apply it again (ledger row 92).
14669                //
14670                // An intent already standing — the last snapshot was
14671                // interrupted too, and this store opens because of it — is
14672                // kept beside the new one until the new snapshot is down. If
14673                // this attempt fails before then, it is still what names the
14674                // snapshot on disk.
14675                let mut intent = core_storage::v8::SnapshotIntent::new(&buf, &wal_now).encode();
14676                if let Some(standing) = &standing_intent {
14677                    intent.extend_from_slice(&standing.encode());
14678                }
14679                self.fs.write_atomic(FileId::SnapshotIntent, &intent)?;
14680                // Until this snapshot empties it. If it does not get that far
14681                // and this handle goes on writing, the next append looks at
14682                // whether the intent is still true.
14683                self.intent_unsettled = true;
14684            }
14685            stopped(SnapshotStop::BeforeSnapshotWrite)?;
14686            self.fs.write_atomic(FileId::Snapshot, &buf)?;
14687            // Remap the freshly-written V8 snapshot as self.base.
14688            // On RealFs: drop the encode buffer before mmap to recover ~1.9 GiB.
14689            // On SimFs (tests): pass buf to from_bytes.
14690            let new_base = if let Some(snap_path) = self.fs.snapshot_path() {
14691                drop(buf);
14692                core_storage::v8::MappedBase::map(&snap_path)
14693            } else {
14694                core_storage::v8::MappedBase::from_bytes(buf)
14695            }
14696            .map_err(|e| GraphError::Corrupt {
14697                detail: format!("v8 snapshot: remap new base (legacy path): {e:?}"),
14698            })?;
14699            self.base = Some(Arc::new(new_base));
14700            // Free the large heap-allocated decoded state — all data is now in the
14701            // mmap'd base.  Mirrors the V8 merge-snapshot path (see above).
14702            self.edge_props = Arc::new(Default::default());
14703            self.topo = Arc::new(Topology::new());
14704            self.props = Arc::new(core_storage::columns::ColumnStore::new());
14705        }
14706
14707        stopped(SnapshotStop::AfterSnapshotWrite)?;
14708        if opts.archive_wal {
14709            // History-preserving snapshot (Task 4):
14710            //   1. Snapshot already written above (write_atomic → fsynced).
14711            //   2. Rename WAL → wal.<commit_seq>.archive  (atomic, same fs).
14712            //      Crash window B: crash here leaves archive present, WAL
14713            //      absent.  Reopen: snapshot loaded (full state), no WAL
14714            //      replay.  Archive is NOT replayed into live state — it is
14715            //      pre-snapshot by construction.  Safe.
14716            //   3. Write new minimal baseline WAL (write_atomic), with no
14717            //      fallible call between it and the rename.
14718            //      Crash window C: a crash between the two leaves the new
14719            //      archive and no live WAL.  Same as window B — handled above.
14720            //   4. Prune old archives (retention), update horizon floor.
14721            //      Pruning invalidates the genesis chain; delete marker.
14722            //   (The genesis marker is written before step 1; see above.
14723            //   Steps 2 and 3 are skipped when the WAL holds nothing new.)
14724            //
14725            // Sample existing archives BEFORE the rename: the last one names
14726            // where this one starts.
14727            let existing_archives = self.fs.list_archives()?;
14728
14729            // Compute a globally-monotonic archive name: the name equals the
14730            // cumulative end-frame index of the archive in global commit space.
14731            //
14732            // Using `commit_seq` directly is UNSOUND across sessions: on reopen
14733            // commit_seq is seeded from max(last_change), which underestimates
14734            // the WAL depth when trailing commits (e.g. insert_edge) do not
14735            // update last_change.  A session-2 archive could then receive a name
14736            // ≤ the session-1 archive, causing incorrect sort order or collision.
14737            //
14738            // Instead: read and decode the live WAL here (before the rename) to
14739            // get its exact frame count, then add it to the last known global
14740            // end-frame index (the name of the most recent existing archive, or
14741            // wal_horizon_floor if no archives exist).  This is O(WAL size) but
14742            // snapshot is already serialising the full graph state, so the cost
14743            // is dominated.
14744            let live_wal_bytes_for_name = self.fs.read(FileId::Wal)?;
14745            let (live_frames_for_name, _) = decode_all(&live_wal_bytes_for_name);
14746            let archive_n = existing_archives
14747                .last()
14748                .copied()
14749                .unwrap_or(self.wal_horizon_floor)
14750                + live_frames_for_name.len() as u64;
14751            // What the WAL is replaced with: the store's declarations and
14752            // nothing else. Built before the rename, because it is also what
14753            // says whether there is anything to archive.
14754            let mut baseline_wal: Vec<u8> = Vec::new();
14755            // The multiplicity opt-in is a declaration like the two below it,
14756            // and it is re-emitted for the same reason: truncation must not
14757            // silently opt the store back out and stop counting.
14758            if self.multiplicity {
14759                baseline_wal
14760                    .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14761            }
14762            for (label, field) in self.fulltext.enabled_pairs() {
14763                let rec = WalRecord::EnableFulltext {
14764                    label: label.clone(),
14765                    field: field.clone(),
14766                };
14767                baseline_wal.extend_from_slice(&encode_record(&rec));
14768            }
14769            for (label, field) in self.prop_index.enabled_pairs() {
14770                let rec = WalRecord::EnableIndex {
14771                    label: label.clone(),
14772                    field: field.clone(),
14773                };
14774                baseline_wal.extend_from_slice(&encode_record(&rec));
14775            }
14776            // A WAL that already holds exactly that has had nothing written
14777            // to it since the last snapshot put it there, and there is
14778            // nothing to archive. Archiving it anyway destroyed history: an
14779            // archive is named for the commit it ends at, so with no commit
14780            // since the last one this one took the last one's name, and the
14781            // rename put an empty file over it — after which the commit
14782            // numbers were handed out again and a read by commit answered
14783            // with a later state (row 100). The WAL stays where it is; it is
14784            // rewritten only if it ends in bytes that are no record. A store
14785            // with no archive yet has no name to collide with and is archived
14786            // as before.
14787            let (baseline_frames, _) = decode_all(&baseline_wal);
14788            //
14789            // An empty WAL has nothing to archive whatever the replacement
14790            // holds: it is what a kill between the rename and the write of
14791            // the replacement leaves, and on a store with a declaration it is
14792            // not the replacement. The rewrite below puts the declarations
14793            // back.
14794            let nothing_to_archive = (live_frames_for_name.is_empty()
14795                || live_frames_for_name == baseline_frames)
14796                && !self.fs.list_archives()?.is_empty();
14797            stopped(SnapshotStop::BeforeWalReplacement)?;
14798            if nothing_to_archive {
14799                if live_wal_bytes_for_name != baseline_wal {
14800                    self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14801                }
14802            } else {
14803                // And never over an archive that exists, whatever led to the
14804                // name: a snapshot that cannot be taken costs a longer open,
14805                // an archive written over costs the store's history.
14806                if existing_archives.contains(&archive_n) {
14807                    return Err(GraphError::Corrupt {
14808                        detail: format!(
14809                            "snapshot: wal.{archive_n}.archive already exists; \
14810                             not archiving the WAL over it"
14811                        ),
14812                    });
14813                }
14814                self.fs.archive_wal(archive_n)?;
14815
14816                // The replacement WAL goes in **immediately**, with no fallible call
14817                // between it and the rename above.
14818                //
14819                // The rename is what removes the store's live declarations — the
14820                // multiplicity opt-in, and every `EnableFulltext` / `EnableIndex` —
14821                // and this write is what puts them back. Every call that used to sit
14822                // in between (the genesis marker, the retention sweep's reads, the
14823                // floor write, the archive deletes) was a `?` that could leave the
14824                // store with neither, so a single transient `Err` was enough to lose
14825                // a declaration that no rebuild can recover (defect #22).
14826                //
14827                // Ordering alone cannot close the crash window between two
14828                // filesystem calls; for the multiplicity declaration the V10 stamp
14829                // does that on the open path. What ordering does close is the much
14830                // wider window in which an ordinary I/O error did it — and that half
14831                // covers all three declarations, not just the one with a stamp.
14832                self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14833            }
14834
14835            // The genesis marker was written before the snapshot, above.
14836
14837            // Retention pruning: keep newest `keep` archives; delete oldest.
14838            // Pruning is the ONLY deletion site for archives.
14839            //
14840            // Crash-safety ordering (C1 fix):
14841            //   1. Count frames in surplus archives (reads only — no mutation).
14842            //   2. Advance and PERSIST the horizon floor FIRST via write-then-
14843            //      rename (atomic).  A crash after this point leaves orphaned
14844            //      archives on disk, but the floor is correct.  The opening
14845            //      cleanup sweep (`cleanup_orphaned_archives`) removes them on
14846            //      the next open, so the store is always safe to reopen.
14847            //   3. Delete the genesis marker (floor > 0 already blocks open_at
14848            //      via the conjunctive gate; marker cleanup is belt-and-suspenders).
14849            //   4. Delete surplus archives.  A crash between any two deletes
14850            //      leaves the floor committed and orphaned archives cleaned at
14851            //      next open — never a stale floor with a missing archive prefix.
14852            if let Some(keep) = self.wal_archive_retention {
14853                if keep > 0 {
14854                    let archives = self.fs.list_archives()?;
14855                    // archives is sorted ascending (oldest first)
14856                    if archives.len() as u32 > keep {
14857                        let surplus = archives.len() - keep as usize;
14858                        // Step 1: count pruned frames (reads, no mutation).
14859                        let mut pruned_frames = 0u64;
14860                        for &n in &archives[..surplus] {
14861                            let bytes = self.fs.read_archive(n)?;
14862                            let (frames, _) = decode_all(&bytes);
14863                            pruned_frames += frames.len() as u64;
14864                        }
14865                        // Step 2: advance and persist floor FIRST.
14866                        self.wal_horizon_floor += pruned_frames;
14867                        self.fs.write_horizon_floor(self.wal_horizon_floor)?;
14868                        // The time map must not outlive the commits it
14869                        // describes: an entry below the new floor would resolve
14870                        // a date to a commit the engine can no longer replay,
14871                        // which is worse than having no entry at all.
14872                        self.commit_times.truncate_below(self.wal_horizon_floor);
14873                        self.rewrite_commit_times();
14874                        // Step 3: delete genesis marker (floor > 0 already
14875                        // blocks open_at; this is belt-and-suspenders cleanup).
14876                        if pruned_frames > 0 && self.archive_genesis_chain {
14877                            self.fs.delete_genesis_marker()?;
14878                            self.archive_genesis_chain = false;
14879                        }
14880                        // Step 4: delete surplus archives.  Crash here →
14881                        // orphaned archives; cleaned at next open.
14882                        for &n in &archives[..surplus] {
14883                            self.fs.delete_archive(n)?;
14884                        }
14885                    }
14886                }
14887            }
14888        } else if opts.keep_wal {
14889            // keep_wal=true: WAL is left untouched.  The existing WAL already
14890            // contains the EnableFulltext records from the original enable calls;
14891            // replay is idempotent (guards in apply() skip already-live entries).
14892            // No baseline re-write is needed or safe here — the full WAL history
14893            // must remain intact for open_at to reach pre-snapshot commits.
14894        } else {
14895            // keep_wal=false (default): truncate by replacing the WAL with a
14896            // minimal baseline of one EnableFulltext record per active pair.
14897            //
14898            // Crash-ordering: write_atomic is atomic.
14899            //   • Crash before snapshot write  → WAL unchanged.  Safe.
14900            //   • Crash after snapshot write but before this WAL write → full
14901            //     pre-snapshot WAL still present; open_with replays idempotently.
14902            //   • Crash after both writes → normal post-snapshot state.
14903            //
14904            // Genesis chain: a WAL-truncating snapshot breaks the archive chain
14905            // for any archives taken AFTER this point (their WAL slices would
14906            // not start at genesis).  Delete any existing genesis marker so that
14907            // open_at refuses archive-resident commits.  Future sessions are
14908            // covered by had_prior_snapshot: snapshot.bin written here persists
14909            // across sessions and prevents a later archiving session from
14910            // incorrectly claiming a complete genesis chain.
14911            if self.archive_genesis_chain {
14912                self.fs.delete_genesis_marker()?;
14913                self.archive_genesis_chain = false;
14914            }
14915            // And this handle can no longer prove the WAL is whole: it is about
14916            // to truncate it itself. Same-session archives after this point get
14917            // the conservative answer, exactly as cross-session ones do.
14918            self.snapshot_preserved_history = false;
14919            // The frames this discards keep their numbers. Everything the store
14920            // could still address — its archives and the live WAL — ends at
14921            // `wal_total_commits()`. That becomes the floor, and the replacement
14922            // WAL continues from it, exactly as the archive path's does after a
14923            // retention sweep.
14924            //
14925            // Leaving the floor where it was renumbered the new WAL over the
14926            // old one: `stats` reported `history_floor: 0`, which every reader
14927            // prints as "complete, nothing pruned", and a commit index a caller
14928            // already held resolved to an unrelated frame — a plausible graph,
14929            // not an error (ledger row 80).
14930            //
14931            // The floor is persisted FIRST, for the reason the retention sweep
14932            // gives: a crash after it leaves the old WAL and any archives on
14933            // disk under a floor that already disowns them. The next open
14934            // removes the archives as orphans and replays the WAL over the
14935            // snapshot idempotently. No number from before the snapshot is
14936            // answered in that state: all of them are below the floor.
14937            //
14938            // What that state does do is count the old WAL's frames again,
14939            // above the floor, until the next truncating snapshot — the same
14940            // history under new numbers, and a total inflated by its length.
14941            // Never a reused number, but not the original ones either. Closing
14942            // that needs the floor to land with the snapshot in one write,
14943            // which is a format change (ledger row 80, "what is left").
14944            let discarded_through = self.wal_total_commits()?;
14945            if discarded_through > self.wal_horizon_floor {
14946                self.fs.write_horizon_floor(discarded_through)?;
14947                self.wal_horizon_floor = discarded_through;
14948            }
14949            let mut baseline_wal: Vec<u8> = Vec::new();
14950            // The multiplicity opt-in is a declaration like the two below it,
14951            // and it is re-emitted for the same reason: truncation must not
14952            // silently opt the store back out and stop counting.
14953            if self.multiplicity {
14954                baseline_wal
14955                    .extend_from_slice(&encode_record(&core_storage::wal::MULTIPLICITY_ENABLED));
14956            }
14957            for (label, field) in self.fulltext.enabled_pairs() {
14958                let rec = WalRecord::EnableFulltext {
14959                    label: label.clone(),
14960                    field: field.clone(),
14961                };
14962                baseline_wal.extend_from_slice(&encode_record(&rec));
14963            }
14964            for (label, field) in self.prop_index.enabled_pairs() {
14965                let rec = WalRecord::EnableIndex {
14966                    label: label.clone(),
14967                    field: field.clone(),
14968                };
14969                baseline_wal.extend_from_slice(&encode_record(&rec));
14970            }
14971            stopped(SnapshotStop::BeforeWalReplacement)?;
14972            self.fs.write_atomic(FileId::Wal, &baseline_wal)?;
14973            // Every archive now ends at or below the floor. They are the
14974            // history this snapshot was asked to discard; removing them here is
14975            // what `cleanup_orphaned_archives` would do at the next open.
14976            for n in self.fs.list_archives()? {
14977                if n <= self.wal_horizon_floor {
14978                    self.fs.delete_archive(n)?;
14979                }
14980            }
14981            // This branch discards history rather than archiving it: the frames
14982            // the map describes are gone, and every entry it holds is below the
14983            // floor. A date before the floor has no commit to resolve to.
14984            //
14985            // A store that cannot answer a date says so by name
14986            // (`NoRecordedTime`). That is the honest state after discarding the
14987            // history the dates addressed.
14988            self.commit_times = core_storage::commit_times::CommitTimes::default();
14989            self.rewrite_commit_times();
14990        }
14991        // The WAL is the one that goes with the snapshot now. The intent has
14992        // done its work and must not outlive it.
14993        if replaces_wal {
14994            stopped(SnapshotStop::AfterWalReplacement)?;
14995            self.fs.write_atomic(FileId::SnapshotIntent, &[])?;
14996            self.intent_unsettled = false;
14997        }
14998        // After snapshot the overlay may have changed (V8 merge path clears
14999        // self.topo and self.props). Refresh the MVCC fold so future readers
15000        // see the post-snapshot state rather than stale overlay data.
15001        self.fold_now();
15002        // We wrote the snapshot and (unless keep_wal) replaced the WAL, so both
15003        // markers this handle uses to detect other processes' work must be
15004        // re-taken from disk. Skipping this would make our own snapshot look
15005        // like a peer's on the next staleness check and force a needless
15006        // reload.
15007        self.wal_consumed = self.fs.wal_len().map_err(GraphError::Io)?;
15008        // A snapshot can replace the live WAL with a baseline, which has its
15009        // own frames. Re-derive the frame cursor from what the store now holds
15010        // (the floor, plus what is reachable above it) rather than carrying
15011        // the pre-snapshot count forward. `wal_total_commits` is the same
15012        // sequence the history surfaces index, and the snapshot has already
15013        // paid a far larger cost than one decode.
15014        self.wal_frames_written = self.wal_total_commits()?;
15015        self.snapshot_ident = self.fs.snapshot_ident().map_err(GraphError::Io)?;
15016        Ok(())
15017    }
15018}
15019
15020/// What a batch node insert does when its key is already taken.
15021///
15022/// A mirror rebuild writes a frame onto a store that already has content, so
15023/// "the key exists" is a routine answer rather than a failure. The decision is
15024/// made during the batch's existing validate pass, from one id-map lookup per
15025/// row, so the frame stays atomic and re-ingest stays O(n).
15026#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
15027pub enum OnConflict {
15028    /// Refuse the whole frame with [`GraphError::DuplicateKey`]. The default,
15029    /// and the only behaviour before v0.6.10.
15030    #[default]
15031    Error,
15032    /// Leave the stored node exactly as it is — properties, label and edges —
15033    /// and count it in [`BatchOutcome::skipped`].
15034    Skip,
15035    /// Keep the key and make the node's properties **exactly** the supplied
15036    /// props: supplied fields are set, fields absent from the supplied props
15037    /// are removed. A supplied label that differs from the stored one, and a
15038    /// supplied `ns` that would move the node, are row errors — relabelling is
15039    /// [`GraphDb::rename_node`], not a side effect of a rebuild.
15040    ///
15041    /// Two properties are outside "exactly", both because they are not the
15042    /// caller's to supply:
15043    ///
15044    /// - `ns` is immutable, so an omitted `ns` leaves the node where it is
15045    ///   rather than moving it to `default`;
15046    /// - a property a **view** owns is kept, not removed. Supplying one is a
15047    ///   row error, so omitting it cannot be a request to delete it, and
15048    ///   refusing the row instead would make `Replace` impossible for the whole
15049    ///   population a view has written to. Each such field kept is counted in
15050    ///   [`BatchOutcome::kept_view_owned`] — the row is still `replaced` and
15051    ///   still raises no row error, so that count is the only signal a caller
15052    ///   gets that the stored node carries a field its frame did not describe.
15053    Replace,
15054}
15055
15056/// What one [`OnConflict::Replace`] row resolves to.
15057///
15058/// The property writes that make the node exactly the supplied props —
15059/// `Some(value)` is a set, `None` a removal — paired with how many view-owned
15060/// fields the row kept instead of removing, which is the one way the result is
15061/// not exactly the supplied props. See [`MutPreview::plan_replace`].
15062type ReplacePlan = (Vec<(String, Option<Value>)>, usize);
15063
15064/// What one committed batch did.
15065///
15066/// [`BatchBuilder::commit`] returns the first two fields as a tuple; the rest
15067/// exist for [`OnConflict`] and are always zero / empty without it.
15068#[derive(Clone, Debug, Default, PartialEq, Eq)]
15069pub struct BatchOutcome {
15070    /// Node records actually written.
15071    pub nodes_inserted: usize,
15072    /// Edge records actually written. A duplicate edge is a silent no-op under
15073    /// every policy — adjacency is a set — and is not counted.
15074    pub edges_inserted: usize,
15075    /// Rows whose key was taken and whose policy was [`OnConflict::Skip`].
15076    pub skipped: usize,
15077    /// Rows whose key was taken and whose policy was [`OnConflict::Replace`].
15078    pub replaced: usize,
15079    /// View-owned properties an [`OnConflict::Replace`] row **kept** although
15080    /// the caller did not supply them — counted per field, so one row that
15081    /// keeps two contributes two.
15082    ///
15083    /// This is the one respect in which `Replace` does not make a node's props
15084    /// exactly the supplied ones (see [`OnConflict::Replace`]). Those rows
15085    /// still count in `replaced` and still raise no `row_errors`, because
15086    /// nothing went wrong: a view's property is not the caller's to supply or
15087    /// to remove. A mirror rebuild that needs its copy to be byte-exact reads
15088    /// this to learn that the store kept fields its frame did not describe.
15089    pub kept_view_owned: usize,
15090    /// `(row, why)` for rows an [`OnConflict::Replace`] refused. `row` counts
15091    /// node-insert ops in this batch from zero, which for a caller that queues
15092    /// its nodes in order is the index of the offending node. The rest of the
15093    /// frame still commits; the refused row changes nothing.
15094    pub row_errors: Vec<(usize, String)>,
15095}
15096
15097/// The `(nodes_inserted, edges_inserted)` pair every pre-0.6.10 commit entry
15098/// point returns. Keeps those signatures unchanged now that the validate pass
15099/// produces a [`BatchOutcome`].
15100fn inserted_pair(outcome: BatchOutcome) -> (usize, usize) {
15101    (outcome.nodes_inserted, outcome.edges_inserted)
15102}
15103
15104/// One entry of a frame the validate pass has decided on, before
15105/// [`GraphDb::rewrite_wal_dense_planned`] turns it into dense-id records.
15106///
15107/// Almost every entry is already a finished [`WalRecord`]. The exception is a
15108/// duplicate edge insert: its count names a dense triple, and on the batch path
15109/// the endpoints and the edge type may all be created by earlier records in the
15110/// *same* frame, so no id for them exists until the dense rewrite allocates it.
15111/// Carrying the keys this far and resolving them there is what lets the count
15112/// survive the shape a mirror rebuild writes (defect #24).
15113enum PlannedRec {
15114    Rec(WalRecord),
15115    DuplicateCount {
15116        edge_type: String,
15117        src_key: String,
15118        dst_key: String,
15119    },
15120}
15121
15122/// Queued mutation for a [`BatchBuilder`] or [`GraphDb::commit_group`].
15123///
15124/// The `submit_batch` / `commit_group` APIs accept `Vec<BatchOp>` so that
15125/// callers can build a set of mutations without holding `&mut GraphDb` and
15126/// hand them off to the group-committing writer for durable, batched I/O.
15127pub enum BatchOp {
15128    InsertNode {
15129        label: String,
15130        key: String,
15131        props: Vec<(String, Value)>,
15132    },
15133    InsertEdge {
15134        edge_type: String,
15135        src_key: String,
15136        dst_key: String,
15137    },
15138    SetProp {
15139        key: String,
15140        field: String,
15141        value: Value,
15142    },
15143    RemoveProp {
15144        key: String,
15145        field: String,
15146    },
15147    DeleteEdge {
15148        edge_type: String,
15149        src_key: String,
15150        dst_key: String,
15151    },
15152    DeleteNode {
15153        key: String,
15154    },
15155    CreateRule(RuleDef),
15156    DeleteRule {
15157        name: String,
15158    },
15159    /// Rename a node's key. Validated: old must exist, new must not.
15160    RenameNode {
15161        old_key: String,
15162        new_key: String,
15163    },
15164    /// Insert an edge, auto-creating any missing endpoint as a plain node with
15165    /// `placeholder_label` and no props. Rules fire and last-change is updated
15166    /// for each created endpoint (normal InsertNode semantics in the batch frame).
15167    InsertEdgeUpsert {
15168        edge_type: String,
15169        src_key: String,
15170        dst_key: String,
15171        placeholder_label: String,
15172    },
15173    /// Insert `key`, or — when the key is already taken — do what `on_conflict`
15174    /// says. Queued by [`BatchBuilder::insert_node_on_conflict`]; `Error`
15175    /// queues a plain [`BatchOp::InsertNode`] instead, so this variant only
15176    /// ever carries `Skip` or `Replace`.
15177    InsertNodeOnConflict {
15178        label: String,
15179        key: String,
15180        props: Vec<(String, Value)>,
15181        on_conflict: OnConflict,
15182    },
15183}
15184
15185/// Three-way node visibility status used by `check_single_op_authz`.
15186enum NodeAuthzStatus {
15187    /// Node exists in the store and is in the role's read mask.
15188    Visible(String), // carries the node's label
15189    /// Node exists in the store but is NOT in the role's read mask.
15190    Hidden,
15191    /// Node does not exist in the store.
15192    Absent,
15193}
15194
15195/// Overlay of ops already accepted earlier in the same batch. Never written
15196/// back to the database — validation only.
15197#[derive(Default)]
15198struct Overlay {
15199    extra_keys: BTreeSet<String>,
15200    /// Label of each node inserted earlier in this batch. The store does not
15201    /// have these keys yet, so `label_of` cannot answer for them, and
15202    /// `OnConflict::Replace` has to compare labels.
15203    extra_labels: BTreeMap<String, String>,
15204    deleted_keys: BTreeSet<String>,
15205    /// `new key → store key` for every node an earlier op in this batch
15206    /// renamed. Such a key is in `extra_keys`, but unlike one a batch inserts
15207    /// it has a stored node behind it — under the old key until the batch
15208    /// commits — and its properties, label and edges are read through this.
15209    renamed_from: BTreeMap<String, String>,
15210    extra_props: BTreeMap<(String, String), Value>,
15211    removed_props: BTreeSet<(String, String)>,
15212    extra_edges: BTreeSet<(String, String, String)>,
15213    deleted_edges: BTreeSet<(String, String, String)>,
15214    extra_rules: BTreeSet<String>,
15215    deleted_rules: BTreeSet<String>,
15216    /// `rule name → (via_edge, edge_type)` for every via-hop rule accepted
15217    /// earlier in this batch. Feeds the rule-chain cycle check, which otherwise
15218    /// sees only the rules already committed to the engine. Keyed by name so a
15219    /// later `DeleteRule` in the same batch drops the arc with the rule.
15220    extra_rule_arcs: BTreeMap<String, (String, String)>,
15221}
15222
15223/// Read-only view of live db state plus a batch overlay. Shared by single-op
15224/// public methods (empty overlay) and `commit_batch`.
15225struct MutPreview<'a, F: Fs> {
15226    db: &'a GraphDb<F>,
15227    overlay: Overlay,
15228}
15229
15230/// Shortest path from `start` to `target` following `arcs` (`from → to`), or
15231/// `None` if `target` is unreachable.
15232///
15233/// Used for rule-chain cycle detection, where an arc is "a rule hops over
15234/// `from` and writes `to`". Breadth-first over BTree-ordered adjacency, so the
15235/// reported path is stable for a given rule set, and iterative so a pathological
15236/// rule graph cannot overflow the stack.
15237fn find_cycle_through(arcs: &[(String, String)], start: &str, target: &str) -> Option<Vec<String>> {
15238    let mut adj: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
15239    for (from, to) in arcs {
15240        adj.entry(from.as_str()).or_default().insert(to.as_str());
15241    }
15242    let mut parent: BTreeMap<&str, &str> = BTreeMap::new();
15243    let mut visited: BTreeSet<&str> = BTreeSet::new();
15244    let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
15245    visited.insert(start);
15246    queue.push_back(start);
15247    while let Some(node) = queue.pop_front() {
15248        if node == target {
15249            let mut path = vec![node.to_string()];
15250            let mut cur = node;
15251            while let Some(&p) = parent.get(cur) {
15252                path.push(p.to_string());
15253                cur = p;
15254            }
15255            path.reverse();
15256            return Some(path);
15257        }
15258        for &next in adj.get(node).into_iter().flatten() {
15259            if visited.insert(next) {
15260                parent.insert(next, node);
15261                queue.push_back(next);
15262            }
15263        }
15264    }
15265    None
15266}
15267
15268impl<'a, F: Fs> MutPreview<'a, F> {
15269    fn new(db: &'a GraphDb<F>) -> Self {
15270        Self {
15271            db,
15272            overlay: Overlay::default(),
15273        }
15274    }
15275
15276    fn has_key(&self, key: &str) -> bool {
15277        if self.overlay.extra_keys.contains(key) {
15278            return true;
15279        }
15280        if self.overlay.deleted_keys.contains(key) {
15281            return false;
15282        }
15283        self.db.ids.get(key).is_some()
15284    }
15285
15286    /// The key the store holds `key`'s node under, or `None` when the batch
15287    /// sees no stored node there: `key` is deleted, or was first inserted in
15288    /// this batch. A key an earlier op renamed stands for the node still
15289    /// stored under its old key.
15290    fn store_key<'k>(&'k self, key: &'k str) -> Option<&'k str> {
15291        if self.overlay.deleted_keys.contains(key) {
15292            return None;
15293        }
15294        if let Some(origin) = self.overlay.renamed_from.get(key) {
15295            return Some(origin.as_str());
15296        }
15297        if self.overlay.extra_keys.contains(key) {
15298            return None;
15299        }
15300        Some(key)
15301    }
15302
15303    fn has_prop(&self, key: &str, field: &str) -> bool {
15304        if !self.has_key(key) {
15305            return false;
15306        }
15307        let k = (key.to_string(), field.to_string());
15308        if self.overlay.removed_props.contains(&k) {
15309            return false;
15310        }
15311        if self.overlay.extra_props.contains_key(&k) {
15312            return true;
15313        }
15314        // Fresh identity (first insert in this batch, or delete+reinsert):
15315        // ignore props still sitting on the soon-to-be-tombstoned slot.
15316        let Some(stored) = self.store_key(key) else {
15317            return false;
15318        };
15319        self.db.get_prop(stored, field).is_some()
15320    }
15321
15322    fn has_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15323        let k = (
15324            edge_type.to_string(),
15325            src_key.to_string(),
15326            dst_key.to_string(),
15327        );
15328        if self.overlay.deleted_edges.contains(&k) {
15329            return false;
15330        }
15331        if self.overlay.extra_edges.contains(&k) {
15332            return true;
15333        }
15334        // A key created in this batch (including reinsert) has no db edges.
15335        let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
15336        else {
15337            return false;
15338        };
15339        let Some(src) = self.db.ids.get(src_key) else {
15340            return false;
15341        };
15342        let Some(dst) = self.db.ids.get(dst_key) else {
15343            return false;
15344        };
15345        let Some(sym) = self.db.syms.get(edge_type) else {
15346            return false;
15347        };
15348        self.db
15349            .topo_view()
15350            .neighbors(sym, Direction::Out, src)
15351            .binary_search(&dst)
15352            .is_ok()
15353    }
15354
15355    fn has_rule(&self, name: &str) -> bool {
15356        if self.overlay.extra_rules.contains(name) {
15357            return true;
15358        }
15359        if self.overlay.deleted_rules.contains(name) {
15360            return false;
15361        }
15362        self.db.engine.rules().any(|r| r.name == name)
15363    }
15364
15365    fn is_rule_owned(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15366        let (Some(src_key), Some(dst_key)) = (self.store_key(src_key), self.store_key(dst_key))
15367        else {
15368            return false;
15369        };
15370        let Some(src) = self.db.ids.get(src_key) else {
15371            return false;
15372        };
15373        let Some(dst) = self.db.ids.get(dst_key) else {
15374            return false;
15375        };
15376        let Some(et) = self.db.syms.get(edge_type) else {
15377            return false;
15378        };
15379        // extra_rules is deliberately not consulted: a CreateRule earlier in
15380        // this batch has not fired, so it contributes no provenance. That is
15381        // the documented rule-window gap (see GraphDb::batch).
15382        if self.overlay.deleted_rules.is_empty() {
15383            return self.db.engine.is_owned(et, src, dst);
15384        }
15385        for (rule, triples) in self.db.engine.provenance() {
15386            if self.overlay.deleted_rules.contains(rule) {
15387                continue;
15388            }
15389            if triples.contains(&(et, src, dst)) {
15390                return true;
15391            }
15392        }
15393        false
15394    }
15395
15396    /// The refusals a node creation makes, in the order it makes them.
15397    ///
15398    /// A view owns its property, and creating a node that carries one is a
15399    /// write to it exactly as `set_prop` is — so it is refused here, at the one
15400    /// choke-point `GraphDb::insert_node`, `BatchOp::InsertNode` and the
15401    /// no-conflict arm of `BatchOp::InsertNodeOnConflict` all pass through.
15402    ///
15403    /// Leaving creation exempt was not harmless. The value was stored and
15404    /// served: a created node the view has no reason to revisit keeps the
15405    /// caller's number for the life of the handle, and the backfill at the next
15406    /// open overwrites it — so the store answered `deg = 777` before a restart
15407    /// and `deg = 0` after, for a property every other surface calls read-only.
15408    /// It also split one op two ways: supplying a view-owned field under
15409    /// `OnConflict::Replace` was already a row error on a taken key while the
15410    /// same field on a fresh key was accepted.
15411    ///
15412    /// Checked before the key, like [`MutPreview::prepare_remove_prop`], so the
15413    /// answer does not depend on whether the key exists.
15414    fn check_insert_node(&self, key: &str, props: &[(String, Value)]) -> Result<()> {
15415        for (field, _) in props {
15416            if let Some(view_name) = self.db.view_store.view_for_prop(field) {
15417                return Err(GraphError::ViewPropReadOnly {
15418                    view_name: view_name.to_string(),
15419                });
15420            }
15421        }
15422        if self.has_key(key) {
15423            Err(GraphError::DuplicateKey { key: key.into() })
15424        } else {
15425            Ok(())
15426        }
15427    }
15428
15429    fn check_live_key(&self, key: &str) -> Result<()> {
15430        if self.has_key(key) {
15431            Ok(())
15432        } else {
15433            Err(GraphError::KeyNotFound { key: key.into() })
15434        }
15435    }
15436
15437    fn prepare_insert_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
15438        for k in [src_key, dst_key] {
15439            if !self.has_key(k) {
15440                return Err(GraphError::KeyNotFound { key: k.into() });
15441            }
15442        }
15443        if self.is_rule_owned(edge_type, src_key, dst_key) {
15444            return Err(GraphError::RuleOwned {
15445                detail: format!("edge {edge_type} {src_key}→{dst_key} is rule-owned"),
15446            });
15447        }
15448        // A user-written edge stays inside one namespace. Derived edges do not
15449        // come through here — the engine adds them directly — and the rule
15450        // scoping check is what keeps those pure.
15451        let src_ns = self.namespace_in_batch(src_key);
15452        let dst_ns = self.namespace_in_batch(dst_key);
15453        if src_ns != dst_ns {
15454            return Err(GraphError::CrossNamespace {
15455                src: src_key.to_string(),
15456                src_ns,
15457                dst: dst_key.to_string(),
15458                dst_ns,
15459            });
15460        }
15461        Ok(!self.has_edge(edge_type, src_key, dst_key))
15462    }
15463
15464    fn prepare_remove_prop(&self, key: &str, field: &str) -> Result<bool> {
15465        // A view owns its property, and the refusal has to live here rather
15466        // than on `GraphDb::remove_prop`: `BatchOp::RemoveProp` never meets
15467        // that one, and it is what the HTTP `DELETE /node/{key}/prop/{field}`
15468        // route, `Batch::remove_prop` and the CLI all submit. This is the one
15469        // choke-point every removal passes, exactly as it is for `ns` below.
15470        // Checked before the key, so the answer does not depend on whether the
15471        // key exists — which is also what `GraphDb::remove_prop` answered when
15472        // it carried the only copy of this guard.
15473        if let Some(view_name) = self.db.view_store.view_for_prop(field) {
15474            return Err(GraphError::ViewPropReadOnly {
15475                view_name: view_name.to_string(),
15476            });
15477        }
15478        self.check_live_key(key)?;
15479        // Removing `ns` is changing the namespace — to `default`, the namespace
15480        // an absent property names. It goes through this one choke-point and NOT
15481        // through `rewrite_wal_dense` (a `RemoveProp` needs no dense rewrite), so
15482        // the immutability rule has to be stated here as well. Without it the
15483        // node silently lands in `default` on the next open: the cross-namespace
15484        // edge guard is defeated and a default-bound role reads a tenant's node.
15485        if field == NS_PROP {
15486            let from = self.namespace_in_batch(key);
15487            if from != NS_DEFAULT {
15488                return Err(GraphError::NamespaceImmutable {
15489                    key: key.to_string(),
15490                    from,
15491                    to: NS_DEFAULT.to_string(),
15492                });
15493            }
15494            // Already in `default`: the removal changes no namespace. It is the
15495            // no-op `set_prop` to the current namespace is, not an error.
15496            return Ok(false);
15497        }
15498        Ok(self.has_prop(key, field))
15499    }
15500
15501    fn prepare_delete_edge(&self, edge_type: &str, src_key: &str, dst_key: &str) -> Result<bool> {
15502        for k in [src_key, dst_key] {
15503            if !self.has_key(k) {
15504                return Err(GraphError::KeyNotFound { key: k.into() });
15505            }
15506        }
15507        // Provenance-owned OR a live rule would derive this pair. User-first
15508        // edges that a later rule matches are not in `owned`, but deleting
15509        // them would leave a hole `rebuild_rule` immediately fills.
15510        if self.is_rule_owned(edge_type, src_key, dst_key) {
15511            return Err(GraphError::RuleOwned {
15512                detail: format!(
15513                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15514                     delete or change the owning rule"
15515                ),
15516            });
15517        }
15518        if self.would_derive(edge_type, src_key, dst_key) {
15519            return Err(GraphError::RuleOwned {
15520                detail: format!(
15521                    "edge {edge_type} {src_key}→{dst_key} is rule-owned; \
15522                     delete or change the owning rule, or a live rule would re-derive it"
15523                ),
15524            });
15525        }
15526        Ok(self.has_edge(edge_type, src_key, dst_key))
15527    }
15528
15529    /// True if any live rule (minus overlay-deleted names) would derive
15530    /// `(edge_type, src, dst)` from current overlay-visible props/labels.
15531    /// CreateRule names in `extra_rules` are ignored — same documented
15532    /// same-batch rule-window as [`Self::is_rule_owned`].
15533    ///
15534    /// Mirrored by `guard_matches` in `memory/forget.rs`, which names the
15535    /// rules this refused for: change the two together (ledger row 67).
15536    fn would_derive(&self, edge_type: &str, src_key: &str, dst_key: &str) -> bool {
15537        if src_key == dst_key {
15538            return false;
15539        }
15540        let Some(src_label) = self.label_of(src_key) else {
15541            return false;
15542        };
15543        let Some(dst_label) = self.label_of(dst_key) else {
15544            return false;
15545        };
15546        for rule in self.db.engine.rules() {
15547            if self.overlay.deleted_rules.contains(&rule.name) {
15548                continue;
15549            }
15550            if rule.edge_type != edge_type {
15551                continue;
15552            }
15553            if rule.src_label != src_label || rule.dst_label != dst_label {
15554                continue;
15555            }
15556            let src_props = |f: &str| self.prop_value(src_key, f);
15557            let dst_props = |f: &str| self.prop_value(dst_key, f);
15558            let src_view = NodeView {
15559                key: src_key,
15560                props: &src_props,
15561            };
15562            let dst_view = NodeView {
15563                key: dst_key,
15564                props: &dst_props,
15565            };
15566            if evaluate(&rule.predicate, &src_view, &dst_view).is_some() {
15567                return true;
15568            }
15569        }
15570        false
15571    }
15572
15573    fn label_of(&self, key: &str) -> Option<String> {
15574        if self.overlay.deleted_keys.contains(key) {
15575            return None;
15576        }
15577        // Fresh identities created in this batch have no stored label in the
15578        // overlay; they cannot be provenance-owned yet either. A key this
15579        // batch renamed has its label on the node stored under the old key.
15580        let stored = self
15581            .overlay
15582            .renamed_from
15583            .get(key)
15584            .map_or(key, String::as_str);
15585        let id = self.db.ids.get(stored)?;
15586        let sym = self.db.labels.get(id as usize).copied()?;
15587        if sym == u32::MAX {
15588            return None;
15589        }
15590        self.db.syms.resolve(sym).map(str::to_string)
15591    }
15592
15593    /// The label `key` carries as this batch sees it — including a node
15594    /// inserted earlier in the same batch, which the store does not have yet.
15595    fn label_in_batch(&self, key: &str) -> Option<String> {
15596        if self.overlay.deleted_keys.contains(key) {
15597            return None;
15598        }
15599        if let Some(label) = self.overlay.extra_labels.get(key) {
15600            return Some(label.clone());
15601        }
15602        self.label_of(key)
15603    }
15604
15605    /// The property writes that make `key`'s props exactly `props`, or why the
15606    /// row is refused.
15607    ///
15608    /// `Some(value)` is a set and `None` is a removal. `store_fields` is every
15609    /// field name the store knows, hoisted by the caller so a frame of N
15610    /// replaces reads the field list once rather than N times.
15611    ///
15612    /// The second half of the pair is how many view-owned fields this row kept
15613    /// rather than removed — the one part of "exactly the supplied props" that
15614    /// does not hold, and the caller's only signal that it did not.
15615    ///
15616    /// The refusals are row errors, not frame errors: a mirror rebuild should
15617    /// learn which of its rows disagree with the store without losing the rows
15618    /// that agree.
15619    fn plan_replace(
15620        &self,
15621        label: &str,
15622        key: &str,
15623        props: &[(String, Value)],
15624        store_fields: &[String],
15625    ) -> std::result::Result<ReplacePlan, String> {
15626        // A different label is a relabel, and a rebuild does not relabel: that
15627        // is `rename_node` or an explicit write, never a side effect here.
15628        let stored = self.label_in_batch(key).unwrap_or_default();
15629        if stored != label {
15630            return Err(format!(
15631                "node {key}: on_conflict=\"replace\" will not relabel {stored:?} to {label:?}; \
15632                 relabelling is rename_node or an explicit write"
15633            ));
15634        }
15635        // `ns` is immutable. Replace removes what the supplied props omit, so
15636        // an omitted `ns` is a move to `default` exactly as a different `ns` is
15637        // a move to that one; both are the same refusal.
15638        let from = self.namespace_in_batch(key);
15639        let to = match props.iter().find(|(field, _)| field == NS_PROP) {
15640            Some((_, Value::Str(ns))) => ns.clone(),
15641            Some((_, value)) => {
15642                return Err(format!(
15643                    "node {key}: {NS_PROP} must be a string naming a namespace, got {value:?}"
15644                ));
15645            }
15646            None => NS_DEFAULT.to_string(),
15647        };
15648        if to != from {
15649            return Err(format!(
15650                "node {key}: {NS_PROP} is immutable; on_conflict=\"replace\" cannot move it \
15651                 from {from:?} to {to:?}"
15652            ));
15653        }
15654
15655        if let Some(why) = self.supplied_view_owned_prop(key, props) {
15656            return Err(why);
15657        }
15658
15659        let supplied: BTreeSet<&str> = props.iter().map(|(field, _)| field.as_str()).collect();
15660        let mut writes = Vec::new();
15661        for (field, value) in props {
15662            // `ns` names the namespace the node is already in, so the write is
15663            // the no-op the dense-rewrite seam would drop anyway.
15664            if field == NS_PROP {
15665                continue;
15666            }
15667            // Already exactly this value: a rebuild of an unchanged row should
15668            // cost no WAL record.
15669            if self.prop_value(key, field).as_ref() == Some(value) {
15670                continue;
15671            }
15672            writes.push((field.clone(), Some(value.clone())));
15673        }
15674        // Everything the node still carries that the supplied props do not.
15675        // `ns` is never removed: it is immutable, and the check above has
15676        // already established the node stays where it is.
15677        let overlay_fields = self
15678            .overlay
15679            .extra_props
15680            .keys()
15681            .filter(|(k, _)| k == key)
15682            .map(|(_, field)| field.as_str());
15683        //
15684        // A view-owned field is filtered out rather than refused. It is not the
15685        // caller's to supply (supplying one is still the row error above) and
15686        // so it is not part of what "exactly the supplied ones" ranges over:
15687        // omitting it is not a request to delete it. Refusing here instead
15688        // would make `replace` impossible for every node a view has written to
15689        // — which on a store carrying a view is the whole population a mirror
15690        // rebuild has to cover.
15691        let omitted: BTreeSet<&str> = store_fields
15692            .iter()
15693            .map(String::as_str)
15694            .chain(overlay_fields)
15695            .filter(|field| {
15696                *field != NS_PROP && !supplied.contains(field) && self.has_prop(key, field)
15697            })
15698            .collect();
15699        // The view-owned half is kept, and counted: the row still commits and
15700        // still reports no error, so without this number a mirror rebuild is
15701        // told it got exactly what it asked for when it did not (defect #18).
15702        let (stale, kept): (Vec<&str>, Vec<&str>) = omitted
15703            .into_iter()
15704            .partition(|field| self.db.view_store.view_for_prop(field).is_none());
15705        writes.extend(stale.into_iter().map(|field| (field.to_string(), None)));
15706        Ok((writes, kept.len()))
15707    }
15708
15709    /// The row error a supplied view-owned field earns, or `None`.
15710    ///
15711    /// Shared by [`MutPreview::plan_replace`] and the no-conflict arm of
15712    /// `BatchOp::InsertNodeOnConflict` so that one op answers a supplied
15713    /// view-owned field the same way whether or not the key was already taken.
15714    fn supplied_view_owned_prop(&self, key: &str, props: &[(String, Value)]) -> Option<String> {
15715        props.iter().find_map(|(field, _)| {
15716            self.db.view_store.view_for_prop(field).map(|view_name| {
15717                format!(
15718                    "node {key}: property {field:?} is owned by view {view_name:?} and is \
15719                     read-only"
15720                )
15721            })
15722        })
15723    }
15724
15725    /// The namespace `key` is in as this batch sees it — including a node
15726    /// inserted earlier in the same batch, which the store does not have yet.
15727    fn namespace_in_batch(&self, key: &str) -> String {
15728        namespace_of_value(self.prop_value(key, NS_PROP).as_ref()).to_string()
15729    }
15730
15731    fn prop_value(&self, key: &str, field: &str) -> Option<Value> {
15732        if !self.has_key(key) {
15733            return None;
15734        }
15735        let k = (key.to_string(), field.to_string());
15736        if self.overlay.removed_props.contains(&k) {
15737            return None;
15738        }
15739        if let Some(v) = self.overlay.extra_props.get(&k) {
15740            return Some(v.clone());
15741        }
15742        self.db.get_prop(self.store_key(key)?, field)
15743    }
15744
15745    fn check_create_rule(&self, def: &RuleDef) -> Result<()> {
15746        def.validate()
15747            .map_err(|e| GraphError::RuleInvalid { detail: e })?;
15748        if self.has_rule(&def.name) {
15749            return Err(GraphError::RuleInvalid {
15750                detail: format!("rule {:?} already exists", def.name),
15751            });
15752        }
15753        // Rule-chain cycle rejection. Derived edges feed via-hop rules, so a
15754        // rule set forms a graph whose arcs are "hops over `via_edge`, writes
15755        // `edge_type`". A cycle in that graph is a rule set that would re-fire
15756        // itself forever; the engine's depth cap would silently truncate it
15757        // instead, leaving an arbitrary partial result. Reject it here, the one
15758        // place that sees the whole rule set.
15759        //
15760        // Rules accepted earlier in the same batch count too: the overlay
15761        // carries their arcs, so a cycle cannot be assembled one op at a time.
15762        if let Some(via) = def.via_edge.as_deref() {
15763            if via == def.edge_type {
15764                return Err(GraphError::RuleInvalid {
15765                    detail: format!("rule chain cycle: {} -> {}", via, def.edge_type),
15766                });
15767            }
15768            let mut arcs: Vec<(String, String)> = self
15769                .db
15770                .engine
15771                .rules()
15772                .filter(|r| !self.overlay.deleted_rules.contains(&r.name))
15773                .filter_map(|r| r.via_edge.clone().map(|v| (v, r.edge_type.clone())))
15774                .collect();
15775            arcs.extend(self.overlay.extra_rule_arcs.values().cloned());
15776            arcs.push((via.to_string(), def.edge_type.clone()));
15777            if let Some(path) = find_cycle_through(&arcs, &def.edge_type, via) {
15778                return Err(GraphError::RuleInvalid {
15779                    detail: format!("rule chain cycle: {} -> {}", via, path.join(" -> ")),
15780                });
15781            }
15782        }
15783        Ok(())
15784    }
15785
15786    fn check_delete_rule(&self, name: &str) -> Result<()> {
15787        if self.has_rule(name) {
15788            Ok(())
15789        } else {
15790            Err(GraphError::RuleNotFound { name: name.into() })
15791        }
15792    }
15793
15794    fn note_insert_node(&mut self, label: &str, key: &str, props: &[(String, Value)]) {
15795        self.overlay.deleted_keys.remove(key);
15796        self.overlay.renamed_from.remove(key);
15797        self.overlay.extra_keys.insert(key.to_string());
15798        self.overlay
15799            .extra_labels
15800            .insert(key.to_string(), label.to_string());
15801        self.overlay.extra_props.retain(|(k, _), _| k != key);
15802        self.overlay.removed_props.retain(|(k, _)| k != key);
15803        for (field, value) in props {
15804            self.overlay
15805                .extra_props
15806                .insert((key.to_string(), field.clone()), value.clone());
15807        }
15808    }
15809
15810    fn note_insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15811        let k = (
15812            edge_type.to_string(),
15813            src_key.to_string(),
15814            dst_key.to_string(),
15815        );
15816        self.overlay.deleted_edges.remove(&k);
15817        self.overlay.extra_edges.insert(k);
15818    }
15819
15820    fn note_set_prop(&mut self, key: &str, field: &str, value: &Value) {
15821        let k = (key.to_string(), field.to_string());
15822        self.overlay.removed_props.remove(&k);
15823        self.overlay.extra_props.insert(k, value.clone());
15824    }
15825
15826    fn note_remove_prop(&mut self, key: &str, field: &str) {
15827        let k = (key.to_string(), field.to_string());
15828        self.overlay.extra_props.remove(&k);
15829        self.overlay.removed_props.insert(k);
15830    }
15831
15832    fn note_delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) {
15833        let k = (
15834            edge_type.to_string(),
15835            src_key.to_string(),
15836            dst_key.to_string(),
15837        );
15838        self.overlay.extra_edges.remove(&k);
15839        self.overlay.deleted_edges.insert(k);
15840    }
15841
15842    fn note_delete_node(&mut self, key: &str) {
15843        self.overlay.extra_keys.remove(key);
15844        self.overlay.renamed_from.remove(key);
15845        self.overlay.deleted_keys.insert(key.to_string());
15846        self.overlay.extra_props.retain(|(k, _), _| k != key);
15847        self.overlay.removed_props.retain(|(k, _)| k != key);
15848        self.overlay
15849            .extra_edges
15850            .retain(|(_, s, d)| s != key && d != key);
15851        self.overlay
15852            .deleted_edges
15853            .retain(|(_, s, d)| s != key && d != key);
15854    }
15855
15856    fn note_create_rule(&mut self, def: &RuleDef) {
15857        self.overlay.deleted_rules.remove(&def.name);
15858        self.overlay.extra_rules.insert(def.name.clone());
15859        // Rules accepted earlier in this batch are not in the engine yet, so
15860        // the cycle check would not see their arcs. Keep the arc, not just the
15861        // name, so a batch cannot smuggle in a cycle one op at a time.
15862        if let Some(via) = def.via_edge.clone() {
15863            self.overlay
15864                .extra_rule_arcs
15865                .insert(def.name.clone(), (via, def.edge_type.clone()));
15866        }
15867    }
15868
15869    fn check_rename_node(&self, old: &str, new: &str) -> Result<()> {
15870        if !self.has_key(old) {
15871            return Err(GraphError::KeyNotFound { key: old.into() });
15872        }
15873        if self.has_key(new) {
15874            return Err(GraphError::DuplicateKey { key: new.into() });
15875        }
15876        Ok(())
15877    }
15878
15879    fn note_rename_node(&mut self, old: &str, new: &str) {
15880        // The stored node `new` will stand for: the one `old` stood for if an
15881        // earlier op renamed it here, `old` itself if the store has it, and
15882        // none if `old` was first inserted in this batch.
15883        let origin = match self.overlay.renamed_from.remove(old) {
15884            Some(origin) => Some(origin),
15885            None if self.overlay.extra_keys.contains(old) => None,
15886            None => Some(old.to_string()),
15887        };
15888        // Mark old as deleted so subsequent batch ops cannot reference it.
15889        self.overlay.extra_keys.remove(old);
15890        self.overlay.deleted_keys.insert(old.to_string());
15891        // Mark new as extra so subsequent batch ops can reference it.
15892        self.overlay.deleted_keys.remove(new);
15893        self.overlay.extra_keys.insert(new.to_string());
15894        // Migrate any overlay props from old key to new key.
15895        let new_str = new.to_string();
15896        let transferred: Vec<((String, String), Value)> = self
15897            .overlay
15898            .extra_props
15899            .iter()
15900            .filter(|((k, _), _)| k.as_str() == old)
15901            .map(|((_, f), v)| ((new_str.clone(), f.clone()), v.clone()))
15902            .collect();
15903        self.overlay
15904            .extra_props
15905            .retain(|(k, _), _| k.as_str() != old);
15906        for (k, v) in transferred {
15907            self.overlay.extra_props.insert(k, v);
15908        }
15909        // Migrate removed_props.
15910        let transferred_removed: Vec<(String, String)> = self
15911            .overlay
15912            .removed_props
15913            .iter()
15914            .filter(|(k, _)| k.as_str() == old)
15915            .map(|(_, f)| (new_str.clone(), f.clone()))
15916            .collect();
15917        self.overlay
15918            .removed_props
15919            .retain(|(k, _)| k.as_str() != old);
15920        for k in transferred_removed {
15921            self.overlay.removed_props.insert(k);
15922        }
15923        if let Some(label) = self.overlay.extra_labels.remove(old) {
15924            self.overlay.extra_labels.insert(new_str.clone(), label);
15925        }
15926        // Edges the batch added or deleted follow the node to its new key.
15927        let follow = |k: String| if k == old { new_str.clone() } else { k };
15928        self.overlay.extra_edges = std::mem::take(&mut self.overlay.extra_edges)
15929            .into_iter()
15930            .map(|(t, s, d)| (t, follow(s), follow(d)))
15931            .collect();
15932        self.overlay.deleted_edges = std::mem::take(&mut self.overlay.deleted_edges)
15933            .into_iter()
15934            .map(|(t, s, d)| (t, follow(s), follow(d)))
15935            .collect();
15936        if let Some(origin) = origin {
15937            self.overlay.renamed_from.insert(new_str, origin);
15938        }
15939    }
15940
15941    fn note_delete_rule(&mut self, name: &str) {
15942        self.overlay.extra_rules.remove(name);
15943        // Drop its chain arc too: a rule created and then deleted in the same
15944        // batch must not make a later, legal rule look like a cycle.
15945        self.overlay.extra_rule_arcs.remove(name);
15946        self.overlay.deleted_rules.insert(name.to_string());
15947        // Treat the deleted rule's current provenance as gone so a later
15948        // delete_edge of those triples is a no-op (matches sequential).
15949        //
15950        // Provenance names nodes by the key the store holds them under; the
15951        // overlay names them as the batch does. A node an earlier op renamed
15952        // is recorded under its new key, or a later read of that key would go
15953        // through to the store and find the rule's edge still there.
15954        let now: BTreeMap<&str, &str> = self
15955            .overlay
15956            .renamed_from
15957            .iter()
15958            .map(|(new, stored)| (stored.as_str(), new.as_str()))
15959            .collect();
15960        if let Some(triples) = self.db.engine.provenance().get(name) {
15961            for &(et, s, d) in triples {
15962                let Some(etype) = self.db.syms.resolve(et) else {
15963                    continue;
15964                };
15965                let Some(src) = self.db.ids.key_of(s) else {
15966                    continue;
15967                };
15968                let Some(dst) = self.db.ids.key_of(d) else {
15969                    continue;
15970                };
15971                let src = now.get(src).copied().unwrap_or(src);
15972                let dst = now.get(dst).copied().unwrap_or(dst);
15973                let k = (etype.to_string(), src.to_string(), dst.to_string());
15974                self.overlay.extra_edges.remove(&k);
15975                self.overlay.deleted_edges.insert(k);
15976            }
15977        }
15978    }
15979}
15980
15981/// Collects mutations and commits them as one WAL `Batch` frame.
15982///
15983/// Holds `&mut GraphDb` for its lifetime. Queue with the same method names
15984/// as [`GraphDb`]; call [`commit`](Self::commit) to validate, log, and apply.
15985/// See [`GraphDb::batch`] for validation and atomicity rules.
15986pub struct BatchBuilder<'a, F: Fs> {
15987    db: &'a mut GraphDb<F>,
15988    ops: Vec<BatchOp>,
15989}
15990
15991impl<'a, F: Fs> BatchBuilder<'a, F> {
15992    pub fn insert_node(
15993        &mut self,
15994        label: &str,
15995        key: &str,
15996        props: Vec<(String, Value)>,
15997    ) -> &mut Self {
15998        self.ops.push(BatchOp::InsertNode {
15999            label: label.into(),
16000            key: key.into(),
16001            props,
16002        });
16003        self
16004    }
16005
16006    /// Queue a node insert whose answer to a taken key is `on_conflict`.
16007    ///
16008    /// [`OnConflict::Error`] queues exactly the op [`insert_node`](Self::insert_node)
16009    /// does, so the default path is unchanged.
16010    pub fn insert_node_on_conflict(
16011        &mut self,
16012        label: &str,
16013        key: &str,
16014        props: Vec<(String, Value)>,
16015        on_conflict: OnConflict,
16016    ) -> &mut Self {
16017        self.ops.push(match on_conflict {
16018            OnConflict::Error => BatchOp::InsertNode {
16019                label: label.into(),
16020                key: key.into(),
16021                props,
16022            },
16023            on_conflict => BatchOp::InsertNodeOnConflict {
16024                label: label.into(),
16025                key: key.into(),
16026                props,
16027                on_conflict,
16028            },
16029        });
16030        self
16031    }
16032
16033    pub fn insert_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
16034        self.ops.push(BatchOp::InsertEdge {
16035            edge_type: edge_type.into(),
16036            src_key: src_key.into(),
16037            dst_key: dst_key.into(),
16038        });
16039        self
16040    }
16041
16042    pub fn set_prop(&mut self, key: &str, field: &str, value: Value) -> &mut Self {
16043        self.ops.push(BatchOp::SetProp {
16044            key: key.into(),
16045            field: field.into(),
16046            value,
16047        });
16048        self
16049    }
16050
16051    pub fn remove_prop(&mut self, key: &str, field: &str) -> &mut Self {
16052        self.ops.push(BatchOp::RemoveProp {
16053            key: key.into(),
16054            field: field.into(),
16055        });
16056        self
16057    }
16058
16059    pub fn delete_edge(&mut self, edge_type: &str, src_key: &str, dst_key: &str) -> &mut Self {
16060        self.ops.push(BatchOp::DeleteEdge {
16061            edge_type: edge_type.into(),
16062            src_key: src_key.into(),
16063            dst_key: dst_key.into(),
16064        });
16065        self
16066    }
16067
16068    pub fn delete_node(&mut self, key: &str) -> &mut Self {
16069        self.ops.push(BatchOp::DeleteNode { key: key.into() });
16070        self
16071    }
16072
16073    pub fn create_rule(&mut self, def: RuleDef) -> &mut Self {
16074        self.ops.push(BatchOp::CreateRule(def));
16075        self
16076    }
16077
16078    pub fn delete_rule(&mut self, name: &str) -> &mut Self {
16079        self.ops.push(BatchOp::DeleteRule { name: name.into() });
16080        self
16081    }
16082
16083    /// Queue a node-rename in this batch.
16084    ///
16085    /// Validation (old exists, new not taken) runs at commit time.
16086    pub fn rename_node(&mut self, old_key: &str, new_key: &str) -> &mut Self {
16087        self.ops.push(BatchOp::RenameNode {
16088            old_key: old_key.into(),
16089            new_key: new_key.into(),
16090        });
16091        self
16092    }
16093
16094    /// Queue an edge insert with endpoint auto-creation.
16095    ///
16096    /// Any missing endpoint is created as a plain node `{key, label:
16097    /// placeholder_label, no props}` inside this batch frame. Rules fire and
16098    /// last-change is updated for each auto-created node.
16099    pub fn insert_edge_upsert(
16100        &mut self,
16101        edge_type: &str,
16102        src_key: &str,
16103        dst_key: &str,
16104        placeholder_label: &str,
16105    ) -> &mut Self {
16106        self.ops.push(BatchOp::InsertEdgeUpsert {
16107            edge_type: edge_type.into(),
16108            src_key: src_key.into(),
16109            dst_key: dst_key.into(),
16110            placeholder_label: placeholder_label.into(),
16111        });
16112        self
16113    }
16114
16115    /// Validate every queued op, then log one `Batch` frame and apply.
16116    /// Empty / all-noop batches return `Ok(())` without writing the WAL.
16117    /// A second `commit()` after a successful one is an empty-batch no-op
16118    /// (queued ops were taken).
16119    /// Takes `&mut self` so it chains after the queue methods (`b.insert_node(..).commit()`)
16120    /// and also works as `let mut b = db.batch(); b.insert_node(..); b.commit()`.
16121    ///
16122    /// **Rule-window limitation:** batch validation cannot see edges that a
16123    /// rule created earlier in the *same* batch will derive at apply time, so
16124    /// a `delete_edge` / `insert_edge` in that window is silently no-oped
16125    /// where sequential calls would return `Err(RuleOwned)`. State integrity
16126    /// is unaffected (idempotent apply, provenance intact). Create rules in
16127    /// their own batch, or sequentially, when later ops may touch derived
16128    /// edges.
16129    /// Validate every queued op and commit atomically.
16130    ///
16131    /// Returns `(nodes_inserted, edges_inserted)` — the counts of node and edge
16132    /// WAL records actually written (duplicate edges are silent no-ops and are
16133    /// NOT counted). Both are 0 when the batch is empty or all-noop.
16134    pub fn commit(&mut self) -> Result<(usize, usize)> {
16135        let ops = std::mem::take(&mut self.ops);
16136        self.db.commit_batch(ops)
16137    }
16138
16139    /// [`commit`](Self::commit) with the full [`BatchOutcome`] — the counts a
16140    /// caller needs when its rows carry an [`OnConflict`] policy.
16141    pub fn commit_outcome(&mut self) -> Result<BatchOutcome> {
16142        let ops = std::mem::take(&mut self.ops);
16143        self.db.commit_logged_batch(ops, None, None)
16144    }
16145
16146    /// Same as [`commit`](Self::commit) but tail the inner events with
16147    /// [`MutationEvent::Ingested`] instead of [`MutationEvent::BatchApplied`].
16148    pub(crate) fn commit_ingest(&mut self, label: &str, inserted: usize) -> Result<(usize, usize)> {
16149        let ops = std::mem::take(&mut self.ops);
16150        self.db
16151            .commit_logged_batch(ops, Some((label.to_string(), inserted)), None)
16152            .map(inserted_pair)
16153    }
16154}
16155
16156pub struct NodeRef<'a, F: Fs> {
16157    db: &'a GraphDb<F>,
16158    id: u32,
16159}
16160
16161impl<'a, F: Fs> NodeRef<'a, F> {
16162    pub fn key(&self) -> &str {
16163        self.db.ids.key_of(self.id).expect("dense ids")
16164    }
16165
16166    pub fn label(&self) -> &str {
16167        let sym = self
16168            .db
16169            .labels
16170            .get(self.id as usize)
16171            .copied()
16172            .filter(|&s| s != u32::MAX)
16173            .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
16174        self.db.syms.resolve(sym).expect("interned label symbol")
16175    }
16176
16177    pub fn prop(&self, field: &str) -> Option<Value> {
16178        self.db
16179            .props_view()
16180            .get(self.id, field)
16181            .map(|vr| vr.into_value())
16182    }
16183
16184    /// All stored fields for this node, sorted by field name.
16185    ///
16186    /// Reads from the full base+overlay view so that props stored only in the
16187    /// V8 snapshot base (i.e. before any post-snapshot WAL writes) are visible.
16188    pub fn props(&self) -> BTreeMap<String, Value> {
16189        let mut out = BTreeMap::new();
16190        let pv = self.db.props_view();
16191        for field in pv.field_names() {
16192            if let Some(vr) = pv.get(self.id, &field) {
16193                out.insert(field, vr.into_value());
16194            }
16195        }
16196        out
16197    }
16198
16199    /// depth-N BFS as a ResultSet: columns ["key","label","depth"], BFS order.
16200    pub fn neighborhood(&self, depth: u32, edge_types: Option<&[&str]>, dir: Dir) -> ResultSet {
16201        let view = self.db.view();
16202        let resolved: Option<Vec<u32>> = edge_types.map(|names| {
16203            names
16204                .iter()
16205                .filter_map(|name| view.syms.get(name))
16206                .collect()
16207        });
16208        let nb = neighborhood(&view, self.id, depth, resolved.as_deref(), dir);
16209        let mut rs = ResultSet::new(vec!["key".into(), "label".into(), "depth".into()]);
16210        for (nid, d) in nb.nodes {
16211            let key = view.key_of(nid);
16212            let label = view
16213                .label_of(nid)
16214                .expect("real nodes always have a label; u32::MAX sentinel cannot occur");
16215            rs.push_row(vec![
16216                Some(Value::Str(key.to_string())),
16217                Some(Value::Str(label.to_string())),
16218                Some(Value::Int(d as i64)),
16219            ]);
16220        }
16221        rs
16222    }
16223
16224    /// 1-hop, Both directions: edge-type name → sorted unique neighbor keys.
16225    pub fn grouped_by_edge_type(&self) -> BTreeMap<String, Vec<String>> {
16226        let view = self.db.view();
16227        let mut groups: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
16228        for e in expand(&view, self.id, None, Dir::Both) {
16229            // Skip edges with unknown etypes (only possible from corrupt large
16230            // TOPOLOGY section; function returns BTreeMap not Result).
16231            let Some(etype) = view.syms.resolve(e.etype) else {
16232                continue;
16233            };
16234            let etype = etype.to_string();
16235            let nbr = if e.src == self.id { e.dst } else { e.src };
16236            groups
16237                .entry(etype)
16238                .or_default()
16239                .insert(view.key_of(nbr).to_string());
16240        }
16241        groups
16242            .into_iter()
16243            .map(|(k, v)| (k, v.into_iter().collect()))
16244            .collect()
16245    }
16246}
16247
16248#[cfg(test)]
16249mod tests {
16250    use super::*;
16251    use core_rules::Predicate;
16252
16253    fn tmp_dir(name: &str) -> std::path::PathBuf {
16254        let d =
16255            std::env::temp_dir().join(format!("graphdb-db-unit-{}-{}", name, std::process::id()));
16256        let _ = std::fs::remove_dir_all(&d);
16257        d
16258    }
16259
16260    fn fk_rule() -> RuleDef {
16261        RuleDef {
16262            name: "works_at".into(),
16263            src_label: "Person".into(),
16264            dst_label: "Org".into(),
16265            predicate: Predicate::KeyMatch {
16266                field: "org_id".into(),
16267            },
16268            edge_type: "WORKS_AT".into(),
16269            weight_prop: None,
16270            max_edges: None,
16271            approximate: false,
16272            via_label: None,
16273            via_edge: None,
16274            via_dir: None,
16275            namespace: None,
16276        }
16277    }
16278
16279    /// Regression guard for the no-views delta-copy fast path.
16280    ///
16281    /// When no views are defined, `pending_deltas_since().to_vec()` must never
16282    /// be called — even during a large CreateRule backfill. The DELTA_COPY_COUNT
16283    /// thread-local is incremented inside every `if !view_store.is_empty()` block;
16284    /// a count of 0 after the entire sequence proves the guard fires correctly.
16285    #[test]
16286    fn no_delta_copy_when_no_views() {
16287        DELTA_COPY_COUNT.with(|c| c.set(0));
16288        let dir = tmp_dir("no-delta-copy");
16289        {
16290            let mut db = GraphDb::open(&dir).unwrap();
16291            // Insert 50 Org + 50 Person nodes with FK links.
16292            for i in 0..50u32 {
16293                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16294            }
16295            for i in 0..50u32 {
16296                db.insert_node(
16297                    "Person",
16298                    &format!("p{i}"),
16299                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
16300                )
16301                .unwrap();
16302            }
16303            // CreateRule backfill should NOT invoke to_vec() when no views are defined.
16304            db.create_rule(fk_rule()).unwrap();
16305
16306            // Counter must stay 0 — no views, no copies.
16307            let copies = DELTA_COPY_COUNT.with(|c| c.get());
16308            assert_eq!(
16309                copies, 0,
16310                "pending_deltas_since().to_vec() called despite no views"
16311            );
16312
16313            // Derived edges must still be correct (the guard skips only the
16314            // empty delta propagation loop, not the rule application itself).
16315            let nbrs = db.neighbors("p0", "WORKS_AT", Direction::Out).unwrap();
16316            assert_eq!(
16317                nbrs,
16318                vec!["o0"],
16319                "rule must derive edges even with no views"
16320            );
16321        }
16322        let _ = std::fs::remove_dir_all(&dir);
16323    }
16324
16325    /// Gating regression: subscribe AFTER a backfill must see no stale events.
16326    /// subscribe BEFORE a backfill must see every edge-fire event.
16327    #[test]
16328    fn subscribe_after_backfill_no_stale_events() {
16329        let dir = tmp_dir("sub-after-backfill");
16330        {
16331            let mut db = GraphDb::open(&dir).unwrap();
16332            for i in 0..10u32 {
16333                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16334                db.insert_node(
16335                    "Person",
16336                    &format!("p{i}"),
16337                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
16338                )
16339                .unwrap();
16340            }
16341            // Create rule BEFORE subscribing — emit_deltas is false during backfill.
16342            db.create_rule(fk_rule()).unwrap();
16343
16344            // Subscribe AFTER the backfill — queue must be empty (no stale events).
16345            let sub = db.subscribe_all_rules().unwrap();
16346            // No events should have queued for the prior backfill.
16347            assert!(
16348                sub.try_recv().is_none(),
16349                "subscribe after backfill must see no stale events"
16350            );
16351
16352            // Inserting a new node now should fire an event (emit_deltas is now true).
16353            db.insert_node("Org", "o_new", vec![]).unwrap();
16354            db.insert_node(
16355                "Person",
16356                "p_new",
16357                vec![("org_id".into(), Value::Str("o_new".into()))],
16358            )
16359            .unwrap();
16360            let ev = sub.recv_timeout(std::time::Duration::from_millis(200));
16361            assert!(
16362                ev.is_some(),
16363                "edge-fire event must arrive after subscribe (emit_deltas=true)"
16364            );
16365        }
16366        let _ = std::fs::remove_dir_all(&dir);
16367    }
16368
16369    /// Gating regression: subscribe BEFORE a backfill → events flow.
16370    #[test]
16371    fn subscribe_before_backfill_events_flow() {
16372        let dir = tmp_dir("sub-before-backfill");
16373        {
16374            let mut db = GraphDb::open(&dir).unwrap();
16375            // Subscribe FIRST — emit_deltas becomes true.
16376            let sub = db.subscribe_all_rules().unwrap();
16377
16378            for i in 0..5u32 {
16379                db.insert_node("Org", &format!("o{i}"), vec![]).unwrap();
16380                db.insert_node(
16381                    "Person",
16382                    &format!("p{i}"),
16383                    vec![("org_id".into(), Value::Str(format!("o{i}")))],
16384                )
16385                .unwrap();
16386            }
16387            // Backfill fires with emit_deltas=true → events queued.
16388            db.create_rule(fk_rule()).unwrap();
16389
16390            // Should receive at least one edge-fired event from the backfill.
16391            let mut received = 0usize;
16392            while sub.try_recv().is_some() {
16393                received += 1;
16394            }
16395            assert!(
16396                received > 0,
16397                "subscribe before backfill must receive edge-fire events (got 0)"
16398            );
16399        }
16400        let _ = std::fs::remove_dir_all(&dir);
16401    }
16402
16403    /// Companion: when a view IS defined, the delta path fires and view values update.
16404    #[test]
16405    fn delta_copy_fires_when_view_exists() {
16406        use core_rules::ViewSource;
16407        DELTA_COPY_COUNT.with(|c| c.set(0));
16408        let dir = tmp_dir("delta-copy-with-view");
16409        {
16410            let mut db = GraphDb::open(&dir).unwrap();
16411            db.insert_node("Org", "o1", vec![]).unwrap();
16412            db.insert_node(
16413                "Person",
16414                "p1",
16415                vec![("org_id".into(), Value::Str("o1".into()))],
16416            )
16417            .unwrap();
16418            // Declare a Degree view so is_empty() returns false.
16419            db.create_view(ViewDef {
16420                name: "degree_out".into(),
16421                label: "Person".into(),
16422                view_prop: "degree_out".into(),
16423                source: ViewSource::Degree {
16424                    edge_type: "WORKS_AT".into(),
16425                    direction: Direction::Out,
16426                },
16427            })
16428            .unwrap();
16429            db.create_rule(fk_rule()).unwrap();
16430
16431            // At least one delta copy should have happened (CreateRule backfill).
16432            let copies = DELTA_COPY_COUNT.with(|c| c.get());
16433            assert!(
16434                copies > 0,
16435                "expected delta copy to fire when a view is defined"
16436            );
16437
16438            // View value should be computed: p1 has one WORKS_AT out-edge.
16439            let info = db.node_info("p1").unwrap();
16440            let degree = info.props.get("degree_out");
16441            assert!(
16442                degree.is_some(),
16443                "view prop should be written to node props"
16444            );
16445        }
16446        let _ = std::fs::remove_dir_all(&dir);
16447    }
16448
16449    /// Regression: `open_at_with` must call `rebuild_all` after WAL replay so
16450    /// derived-edge-driven view values reflect the as-of state rather than just
16451    /// the initial backfill written at `CreateView` time.
16452    ///
16453    /// Base WAL frames (indices 0..=5 before history markers):
16454    ///   0: insert Org "o1"
16455    ///   1: create_view "employee_count" (Degree / WORKS_AT / In) on Org
16456    ///   2: create_rule fk_rule (WORKS_AT, Person→Org via org_id)
16457    ///   3: insert Person "p1" → rule fires WORKS_AT p1→o1 (degree = 1)  ← mid
16458    ///   4: insert Person "p2" → rule fires WORKS_AT p2→o1 (degree = 2)
16459    ///   5: insert Person "p3" → rule fires WORKS_AT p3→o1 (degree = 3)  ← latest
16460    ///
16461    /// Each rule-fire also appends a DerivedEdgeAdded history-marker frame (state
16462    /// no-op), so the total commit count is higher than the base frame count.
16463    /// The "latest" open_at commit is computed dynamically via `wal_commit_count_at`.
16464    ///
16465    /// Without `rebuild_all`, the as-of instance's "emp" view stays at the
16466    /// initial backfill value (0) instead of reflecting the replayed derived edges.
16467    #[test]
16468    fn open_at_derived_edge_view_values_correct() {
16469        use core_rules::ViewSource;
16470        let dir = tmp_dir("open-at-view-rebuild");
16471        {
16472            let mut db = GraphDb::open(&dir).unwrap();
16473            // frame 0
16474            db.insert_node("Org", "o1", vec![]).unwrap();
16475            // frame 1: create view — initial backfill sees 0 derived edges (none fired yet)
16476            db.create_view(ViewDef {
16477                name: "employee_count".into(),
16478                label: "Org".into(),
16479                view_prop: "emp".into(),
16480                source: ViewSource::Degree {
16481                    edge_type: "WORKS_AT".into(),
16482                    direction: Direction::In,
16483                },
16484            })
16485            .unwrap();
16486            // frame 2: create rule — no Persons yet; backfill is a no-op
16487            db.create_rule(fk_rule()).unwrap();
16488            // frame 3: p1 — rule fires WORKS_AT p1→o1; degree = 1
16489            db.insert_node(
16490                "Person",
16491                "p1",
16492                vec![("org_id".into(), Value::Str("o1".into()))],
16493            )
16494            .unwrap();
16495            // frame 4: p2 — degree = 2
16496            db.insert_node(
16497                "Person",
16498                "p2",
16499                vec![("org_id".into(), Value::Str("o1".into()))],
16500            )
16501            .unwrap();
16502            // frame 5: p3 — degree = 3
16503            db.insert_node(
16504                "Person",
16505                "p3",
16506                vec![("org_id".into(), Value::Str("o1".into()))],
16507            )
16508            .unwrap();
16509            // Sanity: normal open sees degree = 3.
16510            assert_eq!(
16511                db.get_view_prop("o1", "emp"),
16512                Some(Value::Int(3)),
16513                "normal db must show degree 3 after 3 derived edges"
16514            );
16515        } // WAL flushed
16516
16517        // Re-open normally to get the authoritative reference value.
16518        let normal_db = GraphDb::open(&dir).unwrap();
16519        let normal_emp = normal_db.get_view_prop("o1", "emp");
16520        assert_eq!(
16521            normal_emp,
16522            Some(Value::Int(3)),
16523            "re-opened normal db must show degree 3"
16524        );
16525
16526        // Latest as-of (last WAL commit): must match the normal open.
16527        // History-marker frames are appended after each rule-fire, so the total
16528        // commit count is computed dynamically rather than hardcoded.
16529        let total = crate::wal_commit_count_at(&dir).unwrap();
16530        let aof_latest = GraphDb::open_at(&dir, total - 1).unwrap();
16531        assert_eq!(
16532            aof_latest.get_view_prop("o1", "emp"),
16533            normal_emp,
16534            "open_at latest: derived-edge view must equal normal open (rebuild_all required)"
16535        );
16536
16537        // Mid-history as-of (commit 3 = p1 insert Batch frame): only p1; degree = 1.
16538        // The DerivedEdgeAdded marker for p1 is at frame 4 (state no-op on replay),
16539        // so replaying 0..=3 correctly re-derives only the p1→o1 edge.
16540        let aof_mid = GraphDb::open_at(&dir, 3).unwrap();
16541        assert_eq!(
16542            aof_mid.get_view_prop("o1", "emp"),
16543            Some(Value::Int(1)),
16544            "open_at mid-history: only p1 exists at frame 3, degree must be 1"
16545        );
16546
16547        let _ = std::fs::remove_dir_all(&dir);
16548    }
16549
16550    /// Pin: subscribe_* on an as-of instance must return Err(ReadOnly) —
16551    /// as-of instances never commit, so distribute_events never runs and any
16552    /// subscription would wait forever.
16553    #[test]
16554    fn subscribe_on_as_of_returns_read_only_error() {
16555        let dir = tmp_dir("sub-as-of-read-only");
16556        {
16557            let mut db = GraphDb::open(&dir).unwrap();
16558            db.insert_node("Org", "o1", vec![]).unwrap();
16559            db.create_rule(fk_rule()).unwrap();
16560        }
16561        let mut aof = GraphDb::open_at(&dir, 0).unwrap();
16562
16563        assert!(
16564            matches!(
16565                aof.subscribe_all_rules(),
16566                Err(core_storage::GraphError::ReadOnly)
16567            ),
16568            "subscribe_all_rules on as-of must return ReadOnly"
16569        );
16570        assert!(
16571            matches!(
16572                aof.subscribe_writes(),
16573                Err(core_storage::GraphError::ReadOnly)
16574            ),
16575            "subscribe_writes on as-of must return ReadOnly"
16576        );
16577        assert!(
16578            matches!(
16579                aof.subscribe_rule("works_at"),
16580                Err(core_storage::GraphError::ReadOnly)
16581            ),
16582            "subscribe_rule on as-of must return ReadOnly"
16583        );
16584        let _ = std::fs::remove_dir_all(&dir);
16585    }
16586
16587    /// Regression: a failed dense WAL rewrite must not leave speculative
16588    /// interns in `syms`. If it does, the next successful mutation logs an
16589    /// `Intern` record with an inflated id; replay (which never saw the
16590    /// orphans) assigns a smaller id and the WAL becomes unreplayable.
16591    #[test]
16592    fn dense_rewrite_error_rolls_back_speculative_interns() {
16593        let dir = tmp_dir("dense-rewrite-rollback");
16594        {
16595            let mut db = GraphDb::open(&dir).unwrap();
16596            db.insert_node("Person", "a", vec![]).unwrap();
16597
16598            // Bypass MutPreview validation to hit the rewrite's own error path
16599            // (same shape as an id-exhaustion failure mid-rewrite). The
16600            // InsertEdge arm interns the edge type before it resolves keys.
16601            let err = db.rewrite_wal_dense(vec![WalRecord::InsertEdge {
16602                edge_type: "ORPHAN_TYPE".into(),
16603                src_key: "missing".into(),
16604                dst_key: "a".into(),
16605            }]);
16606            assert!(err.is_err(), "rewrite of a missing src key must fail");
16607            assert_eq!(
16608                db.syms.get("ORPHAN_TYPE"),
16609                None,
16610                "failed rewrite must roll back speculative interns"
16611            );
16612
16613            // A later successful mutation must produce a replayable WAL.
16614            db.set_prop("a", "later_field", Value::Int(2)).unwrap();
16615        }
16616        let db = GraphDb::open(&dir).expect("WAL must replay after failed rewrite");
16617        assert_eq!(db.get_prop("a", "later_field"), Some(Value::Int(2)));
16618        let _ = std::fs::remove_dir_all(&dir);
16619    }
16620}