Skip to main content

fathomdb_engine/
lib.rs

1pub mod lifecycle;
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6use std::fs::{File, OpenOptions};
7use std::io::{Seek, SeekFrom, Write};
8use std::path::{Path, PathBuf};
9use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
10use std::sync::mpsc::{self, Receiver, SyncSender};
11use std::sync::Once;
12use std::sync::{Arc, Condvar, Mutex};
13use std::thread::{self, JoinHandle};
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15
16use fathomdb_embedder_api::{Embedder, EmbedderError as RuntimeEmbedderError, EmbedderIdentity};
17use fathomdb_query::compile_text_query;
18use fathomdb_schema::{
19    migrate_with_event_sink, MigrationError as SchemaMigrationError, MigrationStepReport,
20    CANONICAL_TABLES, LOCK_SUFFIX, MIGRATIONS, SCHEMA_VERSION,
21};
22use jsonschema::JSONSchema;
23use rusqlite::{params, Connection};
24use serde_json::Value;
25use sha2::Digest;
26use sqlite_vec::sqlite3_vec_init;
27
28#[cfg(unix)]
29use std::os::unix::fs::OpenOptionsExt;
30
31const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-noop";
32const DEFAULT_EMBEDDER_REVISION: &str = "0.6.0-scaffold";
33const DEFAULT_EMBEDDER_DIMENSION: u32 = 384;
34
35/// REQ-006a / AC-007a default slow-statement threshold. Mutated at runtime
36/// via [`Engine::set_slow_threshold_ms`].
37const DEFAULT_SLOW_THRESHOLD_MS: u64 = 100;
38const DEFAULT_VECTOR_PROFILE: &str = "default";
39const DEFAULT_VECTOR_PARTITION: &str = "vector_default";
40/// Default drain budget for `rebuild_projections` / `rebuild_vec0`. The
41/// rebuild path freezes the scheduler before truncating shadow rows, so
42/// the only outstanding work is whatever workers were mid-flight when
43/// the call landed; 30 s is generous for normal job sizes and bounded
44/// for tests.
45const REBUILD_DRAIN_TIMEOUT_MS: u64 = 30_000;
46const DEFAULT_PROVENANCE_ROW_CAP: u64 = 1_000_000;
47const PROJECTION_CURSOR_KEY: &str = "projection_cursor";
48const PROJECTION_WORKERS: usize = 2;
49const PROJECTION_INFLIGHT_LIMIT: usize = PROJECTION_WORKERS * 4;
50const PROJECTION_COMMIT_BATCH: usize = 16;
51const DEFAULT_PROJECTION_RETRY_DELAYS_MS: [u64; 3] = [1_000, 4_000, 16_000];
52
53/// Reader pool size. Per `dev/design/engine.md` § Writer / reader split,
54/// reader connections are pooled and never serialize behind one
55/// connection. AC-021 exercises 8 concurrent readers.
56const READER_POOL_SIZE: usize = 8;
57
58/// Per-reader-connection lookaside slot size, in bytes. Pack 6.G G.1.
59/// Picked from G.0 telemetry (`allocator_lookaside` 26.67% conc cycles
60/// with 3.89× ratio) + the SQLite docs' typical-workload sizing
61/// guidance (https://www.sqlite.org/malloc.html §3): 1200-byte slots
62/// cover the small allocations from `sqlite3DbMallocRaw`,
63/// `sqlite3Fts5ExprNew`, and `vec0Filter_knn` visible at the top of the
64/// concurrent profile.
65const READER_LOOKASIDE_SLOT_SIZE: std::os::raw::c_int = 1200;
66
67/// Per-reader-connection lookaside slot count. SQLite default is 128;
68/// we use 500 to absorb the per-statement allocation footprint of the
69/// hybrid search workload across a sticky worker connection without
70/// falling back to the glibc malloc-arena mutex.
71const READER_LOOKASIDE_SLOT_COUNT: std::os::raw::c_int = 500;
72
73pub struct Engine {
74    path: PathBuf,
75    next_cursor: AtomicU64,
76    closed: AtomicBool,
77    lock: Mutex<Option<File>>,
78    connection: Mutex<Option<Connection>>,
79    reader_pool: ReaderWorkerPool,
80    counters: lifecycle::Counters,
81    subscribers: Arc<lifecycle::SubscriberRegistry>,
82    profiling_enabled: Arc<AtomicBool>,
83    slow_threshold_ms: Arc<AtomicU64>,
84    runtime_embedder: Option<Arc<dyn Embedder>>,
85    runtime_embedder_identity: EmbedderIdentity,
86    projection_runtime: ProjectionRuntime,
87    provenance_row_cap: AtomicU64,
88    /// Per-connection profile-callback contexts. Each box's pointer is
89    /// installed into the connection's `sqlite3_profile` userdata; the
90    /// box must outlive the connection so the callback never reads
91    /// freed memory. Connections are dropped before this vec on
92    /// `close`/`Drop`, so the lifetime ordering holds.
93    ///
94    /// Why `Box<ProfileContext>` and not `ProfileContext` directly: the
95    /// FFI pointer captured during `install_profile_callback` MUST
96    /// remain stable for the connection's lifetime; pushing onto a
97    /// `Vec<ProfileContext>` could reallocate and invalidate that
98    /// pointer.
99    #[allow(clippy::vec_box)]
100    profile_contexts: Mutex<Vec<Box<ProfileContext>>>,
101    /// Pack 6.G G.1 — `sqlite3_db_config(LOOKASIDE)` rc per reader
102    /// worker, captured at open time before any PRAGMA / prepare ran
103    /// on the connection. Read only by the debug-only test accessor
104    /// `reader_lookaside_config_rcs_for_test`; held in release builds
105    /// too because the field is set unconditionally at open and a cfg
106    /// gate would force two open-locked return shapes.
107    #[allow(dead_code)]
108    reader_lookaside_rcs: Vec<i32>,
109    #[cfg(debug_assertions)]
110    force_next_commit_failure: AtomicBool,
111}
112
113#[derive(Clone, Debug)]
114struct ProjectionJob {
115    cursor: u64,
116    kind: String,
117    body: String,
118}
119
120#[derive(Debug, Default)]
121struct ProjectionRuntimeState {
122    active_jobs: usize,
123    queued_jobs: usize,
124    frozen: bool,
125    pending_scan: bool,
126    stopping: bool,
127    in_flight: BTreeSet<u64>,
128}
129
130struct ProjectionRuntimeShared {
131    path: PathBuf,
132    embedder: Option<Arc<dyn Embedder>>,
133    embedder_identity: EmbedderIdentity,
134    state: Mutex<ProjectionRuntimeState>,
135    state_cvar: Condvar,
136    queue: Mutex<VecDeque<ProjectionJob>>,
137    queue_cvar: Condvar,
138    retry_delays_ms: Mutex<Vec<u64>>,
139}
140
141impl std::fmt::Debug for ProjectionRuntimeShared {
142    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
143        f.debug_struct("ProjectionRuntimeShared")
144            .field("path", &self.path)
145            .field("embedder_identity", &self.embedder_identity)
146            .finish_non_exhaustive()
147    }
148}
149
150#[derive(Debug)]
151struct ProjectionRuntime {
152    shared: Arc<ProjectionRuntimeShared>,
153    dispatcher: Mutex<Option<JoinHandle<()>>>,
154    workers: Mutex<Vec<JoinHandle<()>>>,
155}
156
157impl std::fmt::Debug for Engine {
158    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
159        f.debug_struct("Engine")
160            .field("path", &self.path)
161            .field("closed", &self.closed.load(Ordering::SeqCst))
162            .field("runtime_embedder_identity", &self.runtime_embedder_identity)
163            .finish_non_exhaustive()
164    }
165}
166
167/// Per-connection profile-callback context.
168///
169/// Holds the registry handle the callback dispatches to, plus shared
170/// references to the engine's profiling toggle and slow-statement
171/// threshold. The `Arc` clones here mirror the same atomics held by
172/// `Engine`, so `set_profiling` / `set_slow_threshold_ms` mutations are
173/// visible inside the callback without restart (REQ-006a / AC-005a /
174/// AC-007b runtime-toggle contract).
175#[derive(Debug)]
176struct ProfileContext {
177    subscribers: Arc<lifecycle::SubscriberRegistry>,
178    profiling_enabled: Arc<AtomicBool>,
179    slow_threshold_ms: Arc<AtomicU64>,
180}
181
182/// Thread-affine reader worker pool (Pack 6 F.0).
183///
184/// Per `dev/design/engine.md` § Writer / reader split, reader connections
185/// must not serialize behind a single mutex. Each worker thread owns
186/// exactly one read-only `Connection` for its lifetime; `Connection`
187/// objects never cross thread boundaries after startup. `Engine::search`
188/// dispatches a request via a per-worker bounded channel using a
189/// lock-free round-robin counter on the hot path.
190struct ReaderWorkerPool {
191    senders: Vec<SyncSender<ReaderRequest>>,
192    handles: Mutex<Option<Vec<JoinHandle<()>>>>,
193    next: AtomicUsize,
194    shutdown: AtomicBool,
195    live_workers: Arc<AtomicUsize>,
196}
197
198impl std::fmt::Debug for ReaderWorkerPool {
199    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("ReaderWorkerPool")
201            .field("worker_count", &self.senders.len())
202            .field("live_workers", &self.live_workers.load(Ordering::Relaxed))
203            .field("shutdown", &self.shutdown.load(Ordering::Relaxed))
204            .finish()
205    }
206}
207
208/// One request handled by exactly one reader worker. The response is
209/// returned through a fresh oneshot channel so requests cannot be
210/// routed to or duplicated across workers.
211enum ReaderRequest {
212    Search {
213        compiled: fathomdb_query::CompiledQuery,
214        query_vector: Option<String>,
215        respond: SyncSender<ReaderResponse>,
216    },
217    Shutdown,
218    /// Pack 6.G G.1 — debug-only request that asks a worker to read its
219    /// own connection's `SQLITE_DBSTATUS_LOOKASIDE_USED` and return the
220    /// high-water mark (`hiwtr` out-param). Used solely by the integration
221    /// test that asserts post-warmup lookaside slots were consumed; not
222    /// on any production path.
223    #[cfg(debug_assertions)]
224    LookasideStatus {
225        respond: SyncSender<i32>,
226    },
227    /// Pack 6.G G.3.5 — debug-only request that asks a worker to read
228    /// `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and `_CACHE_USED`
229    /// off its own connection and return them as `(hit, miss, used_bytes)`.
230    /// `snapshot_label` is opaque to the worker; the caller uses it to
231    /// distinguish pre/post snapshots in its own bookkeeping.
232    #[cfg(debug_assertions)]
233    CacheStatus {
234        snapshot_label: String,
235        respond: SyncSender<(String, i32, i32, i32)>,
236    },
237}
238
239type ReaderResponse = rusqlite::Result<(u64, Option<SoftFallback>, Vec<String>)>;
240
241/// Pack 6.G G.3.5 — per-worker cache-pressure snapshot. Carried only on
242/// the debug-only `CacheStatus` broadcast path and the test accessor;
243/// not part of the public 0.6.0 surface.
244#[cfg(debug_assertions)]
245#[doc(hidden)]
246#[derive(Clone, Debug)]
247pub struct CacheStatusReply {
248    pub worker_idx: usize,
249    pub snapshot_label: String,
250    pub cache_hit: i32,
251    pub cache_miss: i32,
252    pub cache_used_bytes: i32,
253}
254
255/// Per-worker outbound channel capacity. Round-robin dispatch keeps
256/// queue depth at ~0 on hot paths; the small slack absorbs jitter
257/// without a runtime mutex.
258const READER_WORKER_CHANNEL_CAPACITY: usize = 4;
259
260impl ReaderWorkerPool {
261    fn new(connections: Vec<Connection>) -> Self {
262        let live_workers = Arc::new(AtomicUsize::new(0));
263        let mut senders = Vec::with_capacity(connections.len());
264        let mut handles = Vec::with_capacity(connections.len());
265        for (idx, connection) in connections.into_iter().enumerate() {
266            let (tx, rx) = mpsc::sync_channel::<ReaderRequest>(READER_WORKER_CHANNEL_CAPACITY);
267            let live = Arc::clone(&live_workers);
268            let handle = thread::Builder::new()
269                .name(format!("fathomdb-reader-{idx}"))
270                .spawn(move || reader_worker_loop(connection, rx, live))
271                .expect("spawn reader worker");
272            senders.push(tx);
273            handles.push(handle);
274        }
275        Self {
276            senders,
277            handles: Mutex::new(Some(handles)),
278            next: AtomicUsize::new(0),
279            shutdown: AtomicBool::new(false),
280            live_workers,
281        }
282    }
283
284    fn worker_count(&self) -> usize {
285        self.senders.len()
286    }
287
288    fn live_count(&self) -> usize {
289        self.live_workers.load(Ordering::SeqCst)
290    }
291
292    /// Pack 6.G G.1 — broadcast a `LookasideStatus` request to every
293    /// worker (not round-robin) and collect each worker's
294    /// `SQLITE_DBSTATUS_LOOKASIDE_USED`. Used only by the debug
295    /// integration test for post-warmup lookaside-slot consumption.
296    #[cfg(debug_assertions)]
297    fn lookaside_used_per_worker(&self) -> Vec<i32> {
298        let mut results = Vec::with_capacity(self.senders.len());
299        for sender in &self.senders {
300            let (tx, rx) = mpsc::sync_channel::<i32>(1);
301            if sender.send(ReaderRequest::LookasideStatus { respond: tx }).is_ok() {
302                results.push(rx.recv().unwrap_or(-1));
303            } else {
304                results.push(-1);
305            }
306        }
307        results
308    }
309
310    /// Pack 6.G G.3.5 — broadcast a `CacheStatus` request to every
311    /// worker and collect each worker's `(cache_hit, cache_miss,
312    /// cache_used_bytes)` triple. Same broadcast pattern as G.1's
313    /// `lookaside_used_per_worker`. Returns one `CacheStatusReply` per
314    /// worker in worker-index order.
315    #[cfg(debug_assertions)]
316    fn cache_status_per_worker(&self, snapshot_label: &str) -> Vec<CacheStatusReply> {
317        let mut results = Vec::with_capacity(self.senders.len());
318        for (idx, sender) in self.senders.iter().enumerate() {
319            let (tx, rx) = mpsc::sync_channel::<(String, i32, i32, i32)>(1);
320            let request = ReaderRequest::CacheStatus {
321                snapshot_label: snapshot_label.to_string(),
322                respond: tx,
323            };
324            if sender.send(request).is_ok() {
325                if let Ok((label, hit, miss, used)) = rx.recv() {
326                    results.push(CacheStatusReply {
327                        worker_idx: idx,
328                        snapshot_label: label,
329                        cache_hit: hit,
330                        cache_miss: miss,
331                        cache_used_bytes: used,
332                    });
333                    continue;
334                }
335            }
336            results.push(CacheStatusReply {
337                worker_idx: idx,
338                snapshot_label: snapshot_label.to_string(),
339                cache_hit: -1,
340                cache_miss: -1,
341                cache_used_bytes: -1,
342            });
343        }
344        results
345    }
346
347    /// Hot path. Lock-free dispatch: `AtomicUsize::fetch_add` selects
348    /// the worker, then a single `SyncSender::send` enqueues the
349    /// request. No global mutex is taken on the request path.
350    fn dispatch(&self, request: ReaderRequest) -> Result<(), ReaderRequest> {
351        if self.shutdown.load(Ordering::Relaxed) {
352            return Err(request);
353        }
354        let n = self.senders.len();
355        if n == 0 {
356            return Err(request);
357        }
358        let idx = self.next.fetch_add(1, Ordering::Relaxed) % n;
359        self.senders[idx].send(request).map_err(|err| err.0)
360    }
361
362    /// Signal every worker to exit and join its thread. Idempotent —
363    /// safe to call from `Engine::close` and again from
364    /// `ReaderWorkerPool::Drop`.
365    fn shutdown(&self) {
366        if self.shutdown.swap(true, Ordering::SeqCst) {
367            return;
368        }
369        for sender in &self.senders {
370            let _ = sender.send(ReaderRequest::Shutdown);
371        }
372        if let Ok(mut slot) = self.handles.lock() {
373            if let Some(handles) = slot.take() {
374                for handle in handles {
375                    let _ = handle.join();
376                }
377            }
378        }
379    }
380}
381
382impl Drop for ReaderWorkerPool {
383    fn drop(&mut self) {
384        self.shutdown();
385    }
386}
387
388fn reader_worker_loop(
389    mut connection: Connection,
390    rx: Receiver<ReaderRequest>,
391    live_workers: Arc<AtomicUsize>,
392) {
393    live_workers.fetch_add(1, Ordering::SeqCst);
394    // Drop guard so the live counter decrements even on panic.
395    struct LiveGuard(Arc<AtomicUsize>);
396    impl Drop for LiveGuard {
397        fn drop(&mut self) {
398            self.0.fetch_sub(1, Ordering::SeqCst);
399        }
400    }
401    let _guard = LiveGuard(live_workers);
402
403    while let Ok(request) = rx.recv() {
404        match request {
405            ReaderRequest::Shutdown => break,
406            ReaderRequest::Search { compiled, query_vector, respond } => {
407                let result = read_search_in_tx(&mut connection, &compiled, query_vector.as_deref());
408                // Receiver may have been dropped if the caller went
409                // away; nothing to do in that case.
410                let _ = respond.send(result);
411            }
412            #[cfg(debug_assertions)]
413            ReaderRequest::LookasideStatus { respond } => {
414                let _ = respond.send(read_lookaside_used_hiwtr(&connection));
415            }
416            #[cfg(debug_assertions)]
417            ReaderRequest::CacheStatus { snapshot_label, respond } => {
418                let (hit, miss, used) = read_cache_status(&connection);
419                let _ = respond.send((snapshot_label, hit, miss, used));
420            }
421        }
422    }
423
424    // Per `dev/design/engine.md` § Close path, uninstall the profile
425    // callback before dropping the connection so SQLite cannot fire
426    // one last callback against a `ProfileContext` whose Box is about
427    // to free.
428    uninstall_profile_callback(&connection);
429    drop(connection);
430}
431
432impl ProjectionRuntime {
433    fn new(
434        path: PathBuf,
435        embedder: Option<Arc<dyn Embedder>>,
436        embedder_identity: EmbedderIdentity,
437    ) -> Self {
438        let shared = Arc::new(ProjectionRuntimeShared {
439            path,
440            embedder,
441            embedder_identity,
442            state: Mutex::new(ProjectionRuntimeState::default()),
443            state_cvar: Condvar::new(),
444            queue: Mutex::new(VecDeque::new()),
445            queue_cvar: Condvar::new(),
446            retry_delays_ms: Mutex::new(DEFAULT_PROJECTION_RETRY_DELAYS_MS.to_vec()),
447        });
448
449        let dispatcher_shared = Arc::clone(&shared);
450        let dispatcher = thread::spawn(move || projection_dispatcher_loop(dispatcher_shared));
451
452        let mut workers = Vec::with_capacity(PROJECTION_WORKERS);
453        for _ in 0..PROJECTION_WORKERS {
454            let worker_shared = Arc::clone(&shared);
455            workers.push(thread::spawn(move || projection_worker_loop(worker_shared)));
456        }
457
458        Self { shared, dispatcher: Mutex::new(Some(dispatcher)), workers: Mutex::new(workers) }
459    }
460
461    fn notify_new_work(&self) {
462        if let Ok(mut state) = self.shared.state.lock() {
463            state.pending_scan = true;
464            self.shared.state_cvar.notify_all();
465        }
466    }
467
468    fn set_frozen(&self, frozen: bool) {
469        if let Ok(mut state) = self.shared.state.lock() {
470            state.frozen = frozen;
471            if !frozen {
472                state.pending_scan = true;
473            }
474            self.shared.state_cvar.notify_all();
475        }
476    }
477
478    fn wait_for_idle(&self, timeout_ms: u64) -> bool {
479        let deadline = Instant::now() + Duration::from_millis(timeout_ms);
480        let mut state = match self.shared.state.lock() {
481            Ok(state) => state,
482            Err(_) => return false,
483        };
484        loop {
485            if state.active_jobs == 0 && state.queued_jobs == 0 {
486                drop(state);
487                if !database_has_pending_projection_work(&self.shared.path).unwrap_or(true) {
488                    return true;
489                }
490                state = match self.shared.state.lock() {
491                    Ok(state) => state,
492                    Err(_) => return false,
493                };
494            }
495            let now = Instant::now();
496            if now >= deadline {
497                return false;
498            }
499            let wait = deadline.saturating_duration_since(now);
500            let Ok((next_state, _)) = self.shared.state_cvar.wait_timeout(state, wait) else {
501                return false;
502            };
503            state = next_state;
504        }
505    }
506
507    fn set_retry_delays_for_test(&self, delays_ms: &[u64]) {
508        if let Ok(mut delays) = self.shared.retry_delays_ms.lock() {
509            *delays = delays_ms.to_vec();
510        }
511    }
512
513    fn stop(&self) {
514        if let Ok(mut state) = self.shared.state.lock() {
515            if state.stopping {
516                return;
517            }
518            state.stopping = true;
519            state.pending_scan = false;
520            self.shared.state_cvar.notify_all();
521        }
522        if let Ok(mut queue) = self.shared.queue.lock() {
523            queue.clear();
524            self.shared.queue_cvar.notify_all();
525        }
526
527        if let Ok(mut dispatcher) = self.dispatcher.lock() {
528            if let Some(handle) = dispatcher.take() {
529                let _ = handle.join();
530            }
531        }
532        if let Ok(mut workers) = self.workers.lock() {
533            for handle in workers.drain(..) {
534                let _ = handle.join();
535            }
536        }
537    }
538}
539
540#[derive(Clone, Debug, Eq, PartialEq)]
541pub struct OpenReport {
542    pub schema_version_before: u32,
543    pub schema_version_after: u32,
544    pub migration_steps: Vec<MigrationStepReport>,
545    pub embedder_warmup_ms: u64,
546    pub query_backend: &'static str,
547    pub default_embedder: EmbedderIdentity,
548}
549
550#[derive(Debug)]
551pub struct OpenedEngine {
552    pub engine: Engine,
553    pub report: OpenReport,
554}
555
556#[derive(Clone, Debug, Eq, PartialEq)]
557pub struct WriteReceipt {
558    pub cursor: u64,
559}
560
561/// Soft-fallback signal carried on hybrid `search` results.
562///
563/// Per `dev/design/retrieval.md` § Soft-fallback signal, this record is
564/// present only when one non-essential branch could not contribute. Total
565/// request failure is not expressed via this carrier.
566#[derive(Clone, Debug, Eq, PartialEq)]
567pub struct SoftFallback {
568    pub branch: SoftFallbackBranch,
569}
570
571/// Which retrieval branch could not contribute to a hybrid search.
572///
573/// `Vector` means the vector branch could not contribute; `Text` means the
574/// text branch could not contribute. Owned by `dev/design/retrieval.md`;
575/// the 0.6.0 enum is exactly these two members.
576#[derive(Clone, Copy, Debug, Eq, PartialEq)]
577pub enum SoftFallbackBranch {
578    Vector,
579    Text,
580}
581
582#[derive(Clone, Debug, Eq, PartialEq)]
583pub struct SearchResult {
584    pub projection_cursor: u64,
585    pub soft_fallback: Option<SoftFallback>,
586    pub results: Vec<String>,
587}
588
589/// Batch input shape for [`Engine::write`].
590///
591/// Marked `#[non_exhaustive]` per ADR-0.6.0-prepared-write-shape; new
592/// entity variants land in 0.6.x without a major bump. Adding fields to
593/// existing variants remains a binding-coordination change.
594#[non_exhaustive]
595#[derive(Clone, Debug, Eq, PartialEq)]
596pub enum PreparedWrite {
597    Node {
598        kind: String,
599        body: String,
600        /// REQ-026 / AC-028 / AC-042 recovery seam. `None` is the
601        /// back-compat default and lands as NULL on disk; callers that
602        /// participate in `excise_source` / `trace_source_ref` must
603        /// supply a stable identifier.
604        source_id: Option<String>,
605    },
606    Edge {
607        kind: String,
608        from: String,
609        to: String,
610        /// REQ-026 / AC-028 / AC-042 recovery seam — see Node.
611        source_id: Option<String>,
612    },
613    OpStore {
614        collection: String,
615        record_key: String,
616        schema_id: Option<String>,
617        body: String,
618    },
619    AdminSchema {
620        name: String,
621        kind: String,
622        schema_json: String,
623        retention_json: String,
624    },
625}
626
627/// Snapshot of engine-internal counters returned by [`Engine::counters`].
628///
629/// Public key set is owned by `dev/design/lifecycle.md` § Public key set
630/// and locked by AC-004a. Reading a snapshot is non-perturbing per
631/// AC-004c. The 0.6.0 surface exposes exactly these seven fields.
632#[derive(Clone, Debug, Default, Eq, PartialEq)]
633pub struct CounterSnapshot {
634    pub queries: u64,
635    pub writes: u64,
636    pub write_rows: u64,
637    pub errors_by_code: BTreeMap<String, u64>,
638    pub admin_ops: u64,
639    pub cache_hit: u64,
640    pub cache_miss: u64,
641}
642
643pub use lifecycle::Subscription;
644
645/// Stable corruption-on-open detail carried by
646/// [`EngineOpenError::Corruption`].
647///
648/// Layout owned by `dev/design/errors.md` § Corruption detail owner.
649#[derive(Clone, Debug, Eq, PartialEq)]
650pub struct CorruptionDetail {
651    pub kind: CorruptionKind,
652    pub stage: OpenStage,
653    pub locator: CorruptionLocator,
654    pub recovery_hint: RecoveryHint,
655}
656
657/// Open-path corruption category.
658///
659/// 0.6.0 emits exactly the four members below; per
660/// `dev/design/errors.md` § Engine.open corruption table, doctor-only
661/// finding codes are not represented here.
662#[derive(Clone, Copy, Debug, Eq, PartialEq)]
663pub enum CorruptionKind {
664    WalReplayFailure,
665    HeaderMalformed,
666    SchemaInconsistent,
667    EmbedderIdentityDrift,
668}
669
670/// `Engine.open` stage at which corruption was detected.
671///
672/// Per ADR-0.6.0-corruption-open-behavior, `LockAcquisition` is intentionally
673/// not a member here; lock contention is surfaced via
674/// [`EngineOpenError::DatabaseLocked`].
675#[derive(Clone, Copy, Debug, Eq, PartialEq)]
676pub enum OpenStage {
677    WalReplay,
678    HeaderProbe,
679    SchemaProbe,
680    EmbedderIdentity,
681}
682
683/// Locator pointing at the corrupted region of the database file.
684///
685/// Variant set owned by `dev/design/errors.md` § CorruptionLocator
686/// ownership. `OpaqueSqliteError` is the required fallback when SQLite
687/// surfaces corruption without a usable structured locator.
688#[derive(Clone, Copy, Debug, Eq, PartialEq)]
689pub enum CorruptionLocator {
690    FileOffset { offset: u64 },
691    PageId { page: u32 },
692    TableRow { table: &'static str, rowid: i64 },
693    Vec0ShadowRow { partition: &'static str, rowid: i64 },
694    MigrationStep { from: u32, to: u32 },
695    OpaqueSqliteError { sqlite_extended_code: i32 },
696}
697
698/// Recovery dispatch surface attached to a corruption detail.
699///
700/// `code` is the stable dispatch key used by bindings and doctor output;
701/// `doc_anchor` points at the documentation section that explains the
702/// remediation path.
703#[derive(Clone, Copy, Debug, Eq, PartialEq)]
704pub struct RecoveryHint {
705    pub code: &'static str,
706    pub doc_anchor: &'static str,
707}
708
709#[derive(Clone, Debug, Eq, PartialEq)]
710pub enum EngineOpenError {
711    DatabaseLocked { holder_pid: Option<u32> },
712    Corruption(CorruptionDetail),
713    IncompatibleSchemaVersion { seen: u32, supported: u32 },
714    MigrationError { schema_version_before: u32, schema_version_current: u32, step_id: u32 },
715    EmbedderIdentityMismatch { stored: EmbedderIdentity, supplied: EmbedderIdentity },
716    EmbedderDimensionMismatch { stored: u32, supplied: u32 },
717    Io { message: String },
718}
719
720impl Display for EngineOpenError {
721    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
722        match self {
723            Self::DatabaseLocked { holder_pid } => match holder_pid {
724                Some(pid) => write!(f, "database is locked by process {pid}"),
725                None => write!(f, "database is locked by another engine instance"),
726            },
727            Self::Corruption(detail) => {
728                write!(
729                    f,
730                    "engine corruption at {:?} stage: {}",
731                    detail.stage, detail.recovery_hint.code
732                )
733            }
734            Self::IncompatibleSchemaVersion { seen, supported } => write!(
735                f,
736                "database schema version {seen} is incompatible with supported version {supported}"
737            ),
738            Self::MigrationError {
739                schema_version_before,
740                schema_version_current,
741                step_id,
742            } => write!(
743                f,
744                "schema migration failed at step {step_id}; schema version remained between {schema_version_before} and {schema_version_current}"
745            ),
746            Self::EmbedderIdentityMismatch { stored, supplied } => write!(
747                f,
748                "embedder identity mismatch: stored {}@{}, supplied {}@{}",
749                stored.name, stored.revision, supplied.name, supplied.revision,
750            ),
751            Self::EmbedderDimensionMismatch { stored, supplied } => write!(
752                f,
753                "embedder vector dimension mismatch: stored {stored}, supplied {supplied}",
754            ),
755            Self::Io { message } => write!(f, "database I/O error: {message}"),
756        }
757    }
758}
759
760impl Error for EngineOpenError {}
761
762#[derive(Clone, Debug, Eq, PartialEq)]
763pub enum EngineError {
764    Storage,
765    Projection,
766    Vector,
767    Embedder,
768    EmbedderNotConfigured,
769    KindNotVectorIndexed,
770    EmbedderDimensionMismatch { expected: u32, actual: u32 },
771    Scheduler,
772    OpStore,
773    WriteValidation,
774    SchemaValidation,
775    Overloaded,
776    Closing,
777}
778
779impl Display for EngineError {
780    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
781        match self {
782            Self::Storage => write!(f, "storage error"),
783            Self::Projection => write!(f, "projection error"),
784            Self::Vector => write!(f, "vector error"),
785            Self::Embedder => write!(f, "embedder error"),
786            Self::EmbedderNotConfigured => write!(f, "embedder is not configured"),
787            Self::KindNotVectorIndexed => write!(f, "kind is not configured for vector indexing"),
788            Self::EmbedderDimensionMismatch { expected, actual } => {
789                write!(f, "embedder dimension mismatch: expected {expected}, actual {actual}")
790            }
791            Self::Scheduler => write!(f, "scheduler error"),
792            Self::OpStore => write!(f, "op-store error"),
793            Self::WriteValidation => write!(f, "write validation error"),
794            Self::SchemaValidation => write!(f, "schema validation error"),
795            Self::Overloaded => write!(f, "engine overloaded"),
796            Self::Closing => write!(f, "engine is closing"),
797        }
798    }
799}
800
801impl EngineError {
802    /// Stable machine-readable code for `errors_by_code` keys.
803    ///
804    /// Names match the binding-facing class stems in
805    /// `dev/design/errors.md` § Binding-facing class matrix.
806    fn stable_code(&self) -> &'static str {
807        match self {
808            Self::Storage => "StorageError",
809            Self::Projection => "ProjectionError",
810            Self::Vector => "VectorError",
811            Self::Embedder => "EmbedderError",
812            Self::EmbedderNotConfigured => "EmbedderNotConfiguredError",
813            Self::KindNotVectorIndexed => "KindNotVectorIndexedError",
814            Self::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
815            Self::Scheduler => "SchedulerError",
816            Self::OpStore => "OpStoreError",
817            Self::WriteValidation => "WriteValidationError",
818            Self::SchemaValidation => "SchemaValidationError",
819            Self::Overloaded => "OverloadedError",
820            Self::Closing => "ClosingError",
821        }
822    }
823}
824
825impl Error for EngineError {}
826
827/// Doctor `check-integrity` invocation flags. `quick` and `round_trip`
828/// are accepted in 0.6.0 but treated as default; only `full` activates
829/// `PRAGMA integrity_check`. Per `dev/design/recovery.md` § Doctor-only
830/// flags.
831#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
832pub struct CheckIntegrityOpts {
833    pub quick: bool,
834    pub full: bool,
835    pub round_trip: bool,
836}
837
838/// One section of an [`IntegrityReport`]. Either every check in the
839/// section was clean, or one or more typed [`Finding`]s describe the
840/// detected issue. Per AC-043b.
841#[derive(Clone, Debug, Eq, PartialEq)]
842pub enum Section {
843    Clean,
844    Findings(Vec<Finding>),
845}
846
847/// Single doctor finding record. Stable report-shape per AC-043c. The
848/// `code` and `doc_anchor` strings are stable dispatch keys owned by
849/// `dev/design/recovery.md` § Code-to-operator-action cross-reference.
850#[derive(Clone, Debug, Eq, PartialEq)]
851pub struct Finding {
852    pub code: &'static str,
853    pub stage: &'static str,
854    pub locator: CorruptionLocator,
855    pub doc_anchor: &'static str,
856    pub detail: String,
857}
858
859/// Three-section integrity report. AC-043a pins exactly these three
860/// keys.
861#[derive(Clone, Debug, Eq, PartialEq)]
862pub struct IntegrityReport {
863    pub physical: Section,
864    pub logical: Section,
865    pub semantic: Section,
866}
867
868/// Result of a successful [`Engine::safe_export`] call. The returned
869/// `manifest_sha256` equals the SHA-256 of the export file bytes (per
870/// AC-039a) and matches the `sha256` field written into the manifest
871/// JSON.
872#[derive(Clone, Debug, Eq, PartialEq)]
873pub struct SafeExportArtifact {
874    pub export_path: PathBuf,
875    pub manifest_path: PathBuf,
876    pub manifest_sha256: String,
877}
878
879/// Phase 9 Pack B trace report (AC-042). One event per canonical row
880/// attributable to the requested `source_id`, ordered by `write_cursor`
881/// ascending.
882#[derive(Clone, Debug, Eq, PartialEq)]
883pub struct TraceReport {
884    pub source_ref: String,
885    pub events: Vec<TraceEvent>,
886}
887
888/// Single canonical-row tracing record. `table` is one of
889/// `"canonical_nodes"` or `"canonical_edges"`.
890#[derive(Clone, Debug, Eq, PartialEq)]
891pub struct TraceEvent {
892    pub write_cursor: u64,
893    pub kind: String,
894    pub table: &'static str,
895}
896
897/// Which shadow-state surface a [`RebuildReport`] describes.
898/// `Projections` covers the full FTS5 + vec0 + projection-terminal
899/// rebuild emitted by [`Engine::rebuild_projections`]. `Vec0` covers
900/// the vec0-only path emitted by [`Engine::rebuild_vec0`].
901#[derive(Clone, Copy, Debug, Eq, PartialEq)]
902pub enum RebuildKind {
903    Projections,
904    Vec0,
905}
906
907/// Structured result of a rebuild operation. `rows_invalidated` is the
908/// total shadow-state rows truncated before re-derivation; `rows_rebuilt`
909/// is the count of rows the synchronous rebuild loop re-materialised
910/// (asynchronous re-enqueue work performed by the projection scheduler is
911/// not counted here). `projection_cursor_after` is the post-rebuild value
912/// of the projection cursor.
913#[derive(Clone, Debug, Eq, PartialEq)]
914pub struct RebuildReport {
915    pub kind: RebuildKind,
916    pub rows_invalidated: u64,
917    pub rows_rebuilt: u64,
918    pub projection_cursor_after: u64,
919}
920
921/// Phase 9 Pack B excise report (AC-028a/b/c). Counts are post-excise
922/// totals; `projections_invalidated` reports the shadow-row invalidation
923/// total (FTS5 + vec0 + projection terminal) for the excised source.
924#[derive(Clone, Debug, Eq, PartialEq)]
925pub struct ExciseReport {
926    pub source_ref: String,
927    pub nodes_excised: u64,
928    pub edges_excised: u64,
929    pub projections_invalidated: u64,
930}
931
932/// Typed outcome of [`Engine::verify_embedder`]. Mismatches do not raise
933/// `EngineError`; the operator workflow needs to see the stored vs.
934/// supplied pair to decide on next action.
935#[derive(Clone, Copy, Debug, Eq, PartialEq)]
936pub enum VerifyEmbedderStatus {
937    Match,
938    IdentityMismatch,
939    DimensionMismatch,
940    BothMismatch,
941}
942
943/// Result of [`Engine::verify_embedder`]. `stored_identity` is the
944/// `name:revision` pair persisted in `_fathomdb_embedder_profiles`;
945/// `supplied_identity` echoes the operator's input verbatim.
946#[derive(Clone, Debug, Eq, PartialEq)]
947pub struct VerifyEmbedderReport {
948    pub stored_identity: String,
949    pub stored_dimension: u32,
950    pub supplied_identity: String,
951    pub supplied_dimension: u32,
952    pub status: VerifyEmbedderStatus,
953}
954
955/// Single table or index entry emitted by [`Engine::dump_schema`].
956#[derive(Clone, Debug, Eq, PartialEq)]
957pub struct SchemaObject {
958    pub name: String,
959    pub sql: String,
960}
961
962/// Result of [`Engine::dump_schema`]. `user_version` is the
963/// `PRAGMA user_version` sentinel. Canonical tables appear first per
964/// [`fathomdb_schema::CANONICAL_TABLES`], then remaining non-`sqlite_*`
965/// tables alphabetically. Indexes follow the same alphabetical rule.
966#[derive(Clone, Debug, Eq, PartialEq)]
967pub struct DumpSchemaReport {
968    pub user_version: u32,
969    pub tables: Vec<SchemaObject>,
970    pub indexes: Vec<SchemaObject>,
971}
972
973/// Single canonical-table row count emitted by [`Engine::dump_row_counts`].
974#[derive(Clone, Debug, Eq, PartialEq)]
975pub struct TableRowCount {
976    pub name: String,
977    pub rows: u64,
978}
979
980/// Result of [`Engine::dump_row_counts`]. Canonical tables only;
981/// projection / FTS / vec0 shadow tables are excluded. Order matches
982/// [`fathomdb_schema::CANONICAL_TABLES`].
983#[derive(Clone, Debug, Eq, PartialEq)]
984pub struct DumpRowCountsReport {
985    pub counts: Vec<TableRowCount>,
986}
987
988/// Result of [`Engine::dump_profile`]. Mirrors the open-time embedder
989/// posture + the per-kind vector configuration registered in
990/// `_fathomdb_vector_kinds`.
991#[derive(Clone, Debug, Eq, PartialEq)]
992pub struct DumpProfileReport {
993    pub embedder_identity: String,
994    pub embedder_dimension: u32,
995    pub vectorized_kinds: Vec<String>,
996}
997
998/// Typed outcome of [`Engine::truncate_wal`]. `Done` matches SQLite's
999/// `busy = 0` return from `PRAGMA wal_checkpoint(TRUNCATE)`; any other
1000/// value surfaces as `Busy`.
1001#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1002pub enum TruncateWalStatus {
1003    Done,
1004    Busy,
1005}
1006
1007/// Result of [`Engine::truncate_wal`]. Carries the three counters
1008/// returned by `PRAGMA wal_checkpoint(TRUNCATE)`: `busy`, `log_frames`,
1009/// `checkpointed_frames`.
1010#[derive(Clone, Debug, Eq, PartialEq)]
1011pub struct TruncateWalReport {
1012    pub status: TruncateWalStatus,
1013    pub busy: u32,
1014    pub log_frames: u32,
1015    pub checkpointed_frames: u32,
1016}
1017
1018impl Drop for Engine {
1019    fn drop(&mut self) {
1020        let _ = self.close();
1021    }
1022}
1023
1024impl Engine {
1025    pub fn open(path: impl Into<PathBuf>) -> Result<OpenedEngine, EngineOpenError> {
1026        Self::open_with_embedder_and_subscriber(
1027            path,
1028            default_embedder_identity(),
1029            None,
1030            None,
1031            &mut |_| {},
1032        )
1033    }
1034
1035    pub fn open_with_migration_event_sink(
1036        path: impl Into<PathBuf>,
1037        mut emit_migration_event: impl FnMut(&MigrationStepReport),
1038    ) -> Result<OpenedEngine, EngineOpenError> {
1039        Self::open_with_embedder_and_subscriber(
1040            path,
1041            default_embedder_identity(),
1042            None,
1043            None,
1044            &mut emit_migration_event,
1045        )
1046    }
1047
1048    #[cfg(debug_assertions)]
1049    #[doc(hidden)]
1050    pub fn open_with_migrations_for_test(
1051        path: impl Into<PathBuf>,
1052        migrations: &'static [fathomdb_schema::Migration],
1053        mut emit_migration_event: impl FnMut(&MigrationStepReport),
1054    ) -> Result<OpenedEngine, EngineOpenError> {
1055        Self::open_with_migrations(
1056            path,
1057            migrations,
1058            default_embedder_identity(),
1059            None,
1060            &mut emit_migration_event,
1061            None,
1062        )
1063    }
1064
1065    #[doc(hidden)]
1066    pub fn open_with_subscriber_for_test(
1067        path: impl Into<PathBuf>,
1068        subscriber: Arc<dyn lifecycle::Subscriber>,
1069    ) -> Result<OpenedEngine, EngineOpenError> {
1070        Self::open_with_embedder_and_subscriber(
1071            path,
1072            default_embedder_identity(),
1073            None,
1074            Some(subscriber),
1075            &mut |_| {},
1076        )
1077    }
1078
1079    #[doc(hidden)]
1080    pub fn open_without_embedder_for_test(
1081        path: impl Into<PathBuf>,
1082    ) -> Result<OpenedEngine, EngineOpenError> {
1083        Self::open_with_embedder_and_subscriber(
1084            path,
1085            default_embedder_identity(),
1086            None,
1087            None,
1088            &mut |_| {},
1089        )
1090    }
1091
1092    #[doc(hidden)]
1093    pub fn open_with_embedder_for_test(
1094        path: impl Into<PathBuf>,
1095        embedder: Arc<dyn Embedder>,
1096    ) -> Result<OpenedEngine, EngineOpenError> {
1097        let identity = embedder.identity();
1098        Self::open_with_embedder_and_subscriber(path, identity, Some(embedder), None, &mut |_| {})
1099    }
1100
1101    fn open_with_embedder_and_subscriber(
1102        path: impl Into<PathBuf>,
1103        embedder_identity: EmbedderIdentity,
1104        runtime_embedder: Option<Arc<dyn Embedder>>,
1105        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
1106        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
1107    ) -> Result<OpenedEngine, EngineOpenError> {
1108        Self::open_with_migrations(
1109            path,
1110            MIGRATIONS,
1111            embedder_identity,
1112            runtime_embedder,
1113            emit_migration_event,
1114            initial_subscriber,
1115        )
1116    }
1117
1118    fn open_with_migrations(
1119        path: impl Into<PathBuf>,
1120        migrations: &'static [fathomdb_schema::Migration],
1121        embedder_identity: EmbedderIdentity,
1122        runtime_embedder: Option<Arc<dyn Embedder>>,
1123        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
1124        initial_subscriber: Option<Arc<dyn lifecycle::Subscriber>>,
1125    ) -> Result<OpenedEngine, EngineOpenError> {
1126        let canonical_path = canonical_database_path(&path.into())?;
1127        let lock = acquire_lock(&canonical_path)?;
1128        let open_result = Self::open_locked(
1129            canonical_path.clone(),
1130            migrations,
1131            &embedder_identity,
1132            emit_migration_event,
1133        );
1134
1135        match open_result {
1136            Ok((connection, readers, report, reader_lookaside_rcs)) => {
1137                let next_cursor = load_next_cursor(&connection);
1138                let subscribers = Arc::new(lifecycle::SubscriberRegistry::new());
1139                let profiling_enabled = Arc::new(AtomicBool::new(false));
1140                let slow_threshold_ms = Arc::new(AtomicU64::new(DEFAULT_SLOW_THRESHOLD_MS));
1141                let mut profile_contexts: Vec<Box<ProfileContext>> = Vec::new();
1142                let projection_runtime = ProjectionRuntime::new(
1143                    canonical_path.clone(),
1144                    runtime_embedder.clone(),
1145                    embedder_identity.clone(),
1146                );
1147
1148                install_profile_callback(
1149                    &connection,
1150                    &subscribers,
1151                    &profiling_enabled,
1152                    &slow_threshold_ms,
1153                    &mut profile_contexts,
1154                );
1155                for reader in &readers {
1156                    install_profile_callback(
1157                        reader,
1158                        &subscribers,
1159                        &profiling_enabled,
1160                        &slow_threshold_ms,
1161                        &mut profile_contexts,
1162                    );
1163                }
1164
1165                let opened = OpenedEngine {
1166                    engine: Self {
1167                        path: canonical_path.clone(),
1168                        next_cursor: AtomicU64::new(next_cursor),
1169                        closed: AtomicBool::new(false),
1170                        lock: Mutex::new(Some(lock)),
1171                        connection: Mutex::new(Some(connection)),
1172                        reader_pool: ReaderWorkerPool::new(readers),
1173                        counters: lifecycle::Counters::new(),
1174                        subscribers,
1175                        profiling_enabled,
1176                        slow_threshold_ms,
1177                        runtime_embedder,
1178                        runtime_embedder_identity: embedder_identity,
1179                        projection_runtime,
1180                        provenance_row_cap: AtomicU64::new(DEFAULT_PROVENANCE_ROW_CAP),
1181                        profile_contexts: Mutex::new(profile_contexts),
1182                        reader_lookaside_rcs,
1183                        #[cfg(debug_assertions)]
1184                        force_next_commit_failure: AtomicBool::new(false),
1185                    },
1186                    report,
1187                };
1188                if let Some(subscriber) = initial_subscriber {
1189                    opened.engine.subscribers.attach_persistent(subscriber);
1190                }
1191                if database_has_pending_projection_work(&canonical_path).unwrap_or(false) {
1192                    opened.engine.projection_runtime.notify_new_work();
1193                }
1194                Ok(opened)
1195            }
1196            Err(err) => {
1197                if let Some(subscriber) = initial_subscriber {
1198                    emit_open_error_event(&subscriber, &err);
1199                }
1200                drop(lock);
1201                Err(err)
1202            }
1203        }
1204    }
1205
1206    fn open_locked(
1207        path: PathBuf,
1208        migrations: &'static [fathomdb_schema::Migration],
1209        embedder_identity: &EmbedderIdentity,
1210        emit_migration_event: &mut impl FnMut(&MigrationStepReport),
1211    ) -> Result<(Connection, Vec<Connection>, OpenReport, Vec<i32>), EngineOpenError> {
1212        register_sqlite_vec_extension();
1213        let connection = Connection::open(&path)
1214            .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
1215        // Order pinned by `dev/design/errors.md` § OpenStage matrix: each
1216        // step routes its own SQLite-level error to a distinct
1217        // `CorruptionKind` (Header → WalReplay → Schema → EmbedderIdentity).
1218        // The schema and WAL probes both happen BEFORE `pragma WAL`
1219        // because that pragma also reads page 1 — letting it run first
1220        // would reclassify schema-side corruption as a WAL replay
1221        // failure, breaking the AC-035b stable-code contract.
1222        probe_database_header(&connection)?;
1223        probe_open_integrity(&connection)?;
1224        probe_wal_sidecar(&path)?;
1225        connection
1226            .pragma_update(None, "journal_mode", "WAL")
1227            .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
1228
1229        reject_legacy_shape(&connection)?;
1230        let migration = migrate_with_event_sink(&connection, migrations, emit_migration_event)
1231            .map_err(map_migration_error)?;
1232        check_embedder_profile(&connection, embedder_identity)?;
1233        ensure_vector_partition(&connection, embedder_identity.dimension).map_err(|_| {
1234            EngineOpenError::Io { message: "could not initialize vector partition".to_string() }
1235        })?;
1236
1237        let warmup_started = Instant::now();
1238        let report = OpenReport {
1239            schema_version_before: migration.schema_version_before,
1240            schema_version_after: migration.schema_version_after,
1241            migration_steps: migration.migration_steps,
1242            embedder_warmup_ms: u64::try_from(warmup_started.elapsed().as_millis())
1243                .unwrap_or(u64::MAX),
1244            query_backend: "fathomdb-query + sqlite-vec",
1245            default_embedder: embedder_identity.clone(),
1246        };
1247
1248        let mut readers = Vec::with_capacity(READER_POOL_SIZE);
1249        let mut lookaside_rcs: Vec<i32> = Vec::with_capacity(READER_POOL_SIZE);
1250        for _ in 0..READER_POOL_SIZE {
1251            let reader = Connection::open(&path)
1252                .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))?;
1253            // Pack 6.G G.1: configure per-connection lookaside BEFORE
1254            // any PRAGMA / prepare runs on this reader. Reordering this
1255            // after the journal-mode / query_only PRAGMAs would let
1256            // SQLite silently ignore the lookaside setting.
1257            let rc: i32 = configure_reader_lookaside(&reader);
1258            debug_assert_eq!(
1259                rc,
1260                rusqlite::ffi::SQLITE_OK,
1261                "sqlite3_db_config(LOOKASIDE) must return SQLITE_OK on a freshly opened reader",
1262            );
1263            lookaside_rcs.push(rc);
1264            reader
1265                .pragma_update(None, "journal_mode", "WAL")
1266                .map_err(|err| map_open_sqlite_error(err, OpenStage::WalReplay))?;
1267            reader
1268                .pragma_update(None, "query_only", "ON")
1269                .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))?;
1270            readers.push(reader);
1271        }
1272
1273        Ok((connection, readers, report, lookaside_rcs))
1274    }
1275
1276    #[must_use]
1277    pub fn path(&self) -> &Path {
1278        &self.path
1279    }
1280
1281    pub fn write(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
1282        let category = if batch_is_admin(batch) {
1283            lifecycle::EventCategory::Admin
1284        } else {
1285            lifecycle::EventCategory::Writer
1286        };
1287        self.emit_event(lifecycle::Phase::Started, category, None);
1288        let started = Instant::now();
1289        let outcome = self.write_inner(batch);
1290        self.detect_slow(started, category);
1291        match outcome {
1292            Ok(receipt) => {
1293                let rows = u64::try_from(batch.len()).unwrap_or(u64::MAX);
1294                if batch_is_admin(batch) {
1295                    self.counters.record_admin();
1296                } else {
1297                    self.counters.record_write(rows);
1298                }
1299                self.emit_event(lifecycle::Phase::Finished, category, None);
1300                Ok(receipt)
1301            }
1302            Err(err) => {
1303                let code = err.stable_code();
1304                self.counters.record_error(code);
1305                // AC-003d: capture-ordinal < raise-ordinal — Failed and Error
1306                // events both fire before the EngineError returns to the caller.
1307                self.emit_event(lifecycle::Phase::Failed, category, Some(code));
1308                self.emit_event(
1309                    lifecycle::Phase::Failed,
1310                    lifecycle::EventCategory::Error,
1311                    Some(code),
1312                );
1313                Err(err)
1314            }
1315        }
1316    }
1317
1318    fn write_inner(&self, batch: &[PreparedWrite]) -> Result<WriteReceipt, EngineError> {
1319        self.ensure_open()?;
1320
1321        if batch.is_empty() {
1322            return Err(EngineError::WriteValidation);
1323        }
1324
1325        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1326        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
1327        let plans = validate_batch(connection, batch)?;
1328        let projection_jobs = collect_projection_jobs(connection, batch)?;
1329        #[cfg(debug_assertions)]
1330        if self.force_next_commit_failure.swap(false, Ordering::SeqCst) {
1331            return Err(EngineError::Storage);
1332        }
1333        let increment = u64::try_from(batch.len()).unwrap_or(u64::MAX);
1334        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(increment);
1335        let pending_projection = !projection_jobs.is_empty();
1336
1337        if let Err(err) = commit_batch(
1338            connection,
1339            batch,
1340            &plans,
1341            cursor,
1342            pending_projection,
1343            self.provenance_row_cap.load(Ordering::Relaxed),
1344        ) {
1345            self.emit_sqlite_internal_error(&err);
1346            return Err(EngineError::Storage);
1347        }
1348        self.next_cursor.store(cursor, Ordering::SeqCst);
1349        if pending_projection {
1350            self.projection_runtime.notify_new_work();
1351        }
1352
1353        Ok(WriteReceipt { cursor })
1354    }
1355
1356    pub fn search(&self, query: &str) -> Result<SearchResult, EngineError> {
1357        self.emit_event(lifecycle::Phase::Started, lifecycle::EventCategory::Search, None);
1358        let started = Instant::now();
1359        let outcome = self.search_inner(query);
1360        self.detect_slow(started, lifecycle::EventCategory::Search);
1361        match outcome {
1362            Ok(result) => {
1363                self.counters.record_query();
1364                self.emit_event(lifecycle::Phase::Finished, lifecycle::EventCategory::Search, None);
1365                Ok(result)
1366            }
1367            Err(err) => {
1368                let code = err.stable_code();
1369                self.counters.record_error(code);
1370                self.emit_event(
1371                    lifecycle::Phase::Failed,
1372                    lifecycle::EventCategory::Search,
1373                    Some(code),
1374                );
1375                self.emit_event(
1376                    lifecycle::Phase::Failed,
1377                    lifecycle::EventCategory::Error,
1378                    Some(code),
1379                );
1380                Err(err)
1381            }
1382        }
1383    }
1384
1385    fn detect_slow(&self, started: Instant, category: lifecycle::EventCategory) {
1386        let elapsed = started.elapsed();
1387        let threshold = self.slow_threshold_ms.load(Ordering::Relaxed);
1388        let threshold_duration = std::time::Duration::from_millis(threshold);
1389        if elapsed > threshold_duration {
1390            // `dev/design/lifecycle.md` § Slow and heartbeat policy: a slow
1391            // operation produces TWO correlated facts. The
1392            // statement-level slow-statement signal is dispatched by the
1393            // sqlite3_profile callback (`profile_callback_trampoline`).
1394            // This site emits the lifecycle `Phase::Slow` event for the
1395            // outer operation envelope (AC-008).
1396            self.emit_event(lifecycle::Phase::Slow, category, None);
1397        }
1398    }
1399
1400    fn emit_event(
1401        &self,
1402        phase: lifecycle::Phase,
1403        category: lifecycle::EventCategory,
1404        code: Option<&'static str>,
1405    ) {
1406        let event =
1407            lifecycle::Event { phase, source: lifecycle::EventSource::Engine, category, code };
1408        self.subscribers.dispatch(&event);
1409    }
1410
1411    /// Emit a `(SqliteInternal, Error, code: <SQLITE_*>)` lifecycle
1412    /// event for a rusqlite error. Per `dev/design/lifecycle.md`
1413    /// § Diagnostic source and category, SQLite-originated diagnostics
1414    /// route through the same host subscriber as engine-originated
1415    /// events with `source` preserved. AC-021 dispatches on
1416    /// `code == "SQLITE_SCHEMA"`.
1417    fn emit_sqlite_internal_error(&self, err: &rusqlite::Error) {
1418        if let Some(code) = sqlite_extended_code_name(err) {
1419            let event = lifecycle::Event {
1420                phase: lifecycle::Phase::Failed,
1421                source: lifecycle::EventSource::SqliteInternal,
1422                category: lifecycle::EventCategory::Error,
1423                code: Some(code),
1424            };
1425            self.subscribers.dispatch(&event);
1426        }
1427    }
1428
1429    fn search_inner(&self, query: &str) -> Result<SearchResult, EngineError> {
1430        self.ensure_open()?;
1431        if query.trim().is_empty() {
1432            return Err(EngineError::WriteValidation);
1433        }
1434
1435        let compiled = compile_text_query(query);
1436        // REQ-013 / AC-059b / REQ-055: the cursor returned with a search
1437        // MUST be derived from the same WAL snapshot the data was read
1438        // from. Loading `next_cursor` from the writer-side atomic before
1439        // the reader transaction acquires its snapshot races against
1440        // concurrent writers — see `dev/design/engine.md` § Cursor
1441        // contract. Run cursor probe + body query inside one read tx
1442        // (BEGIN DEFERRED on a `query_only=ON` connection in WAL mode is
1443        // a snapshot-stable read).
1444        let query_vector = self
1445            .runtime_embedder
1446            .as_ref()
1447            .and_then(|embedder| embedder.embed(query).ok())
1448            .and_then(|vector| serde_json::to_string(&vector).ok());
1449        let (response_tx, response_rx) = mpsc::sync_channel::<ReaderResponse>(1);
1450        let request = ReaderRequest::Search { compiled, query_vector, respond: response_tx };
1451        if self.reader_pool.dispatch(request).is_err() {
1452            return Err(EngineError::Closing);
1453        }
1454        let search_result = response_rx.recv().map_err(|_| EngineError::Storage)?;
1455        let (cursor, soft_fallback, results) = match search_result {
1456            Ok(result) => result,
1457            Err(err) => {
1458                self.emit_sqlite_internal_error(&err);
1459                return Err(EngineError::Storage);
1460            }
1461        };
1462
1463        Ok(SearchResult { projection_cursor: cursor, soft_fallback, results })
1464    }
1465
1466    pub fn close(&self) -> Result<(), EngineError> {
1467        self.closed.store(true, Ordering::SeqCst);
1468        self.projection_runtime.stop();
1469        // Uninstall profile callbacks before dropping the connections so
1470        // SQLite cannot fire one last callback against a profile context
1471        // whose Box is about to free. Per `dev/design/engine.md` § Close
1472        // path step 6, readers drain before the writer connection so
1473        // SQLite's last-handle checkpointer runs on the writer. Each
1474        // reader worker uninstalls its own callback inside
1475        // `reader_worker_loop` before dropping its connection, then
1476        // exits — `shutdown` joins those threads here.
1477        self.reader_pool.shutdown();
1478        if let Ok(mut connection) = self.connection.lock() {
1479            if let Some(conn) = connection.as_ref() {
1480                uninstall_profile_callback(conn);
1481            }
1482            connection.take();
1483        }
1484        if let Ok(mut contexts) = self.profile_contexts.lock() {
1485            contexts.clear();
1486        }
1487        if let Ok(mut lock) = self.lock.lock() {
1488            lock.take();
1489        }
1490        Ok(())
1491    }
1492
1493    /// Block until in-flight writes drain or `timeout_ms` elapses.
1494    ///
1495    /// Surface owned by `dev/interfaces/rust.md` § Engine-attached
1496    /// instrumentation; semantics are owned by `dev/design/lifecycle.md`.
1497    pub fn drain(&self, timeout_ms: u64) -> Result<(), EngineError> {
1498        self.ensure_open()?;
1499        if self.projection_runtime.wait_for_idle(timeout_ms) {
1500            Ok(())
1501        } else {
1502            Err(EngineError::Scheduler)
1503        }
1504    }
1505
1506    /// Snapshot of engine-internal counters.
1507    ///
1508    /// Field set owned by `dev/design/lifecycle.md`.
1509    #[must_use]
1510    pub fn counters(&self) -> CounterSnapshot {
1511        self.counters.snapshot()
1512    }
1513
1514    /// Toggle response-cycle profiling.
1515    ///
1516    /// Per `dev/design/lifecycle.md` § Per-statement profiling, profiling
1517    /// is an opt-in surface that is independently toggleable on a running
1518    /// engine without restart. AC-005a locks runtime toggleability.
1519    pub fn set_profiling(&self, enabled: bool) -> Result<(), EngineError> {
1520        self.profiling_enabled.store(enabled, Ordering::Relaxed);
1521        Ok(())
1522    }
1523
1524    /// Set the threshold above which an operation is reported as slow.
1525    ///
1526    /// Per `dev/design/lifecycle.md` § Slow and heartbeat policy, the
1527    /// threshold is runtime-configurable; mutating it changes detection
1528    /// behavior on subsequent statements without restart (AC-007b).
1529    pub fn set_slow_threshold_ms(&self, value: u64) -> Result<(), EngineError> {
1530        self.slow_threshold_ms.store(value, Ordering::Relaxed);
1531        Ok(())
1532    }
1533
1534    /// Attach a host subscriber to engine events.
1535    ///
1536    /// Dropping the returned [`Subscription`] detaches the subscriber.
1537    /// Payload shape owned by `dev/design/lifecycle.md` and
1538    /// `dev/design/migrations.md`.
1539    #[must_use]
1540    pub fn subscribe(&self, subscriber: Arc<dyn lifecycle::Subscriber>) -> Subscription {
1541        self.subscribers.attach(subscriber)
1542    }
1543
1544    #[cfg(debug_assertions)]
1545    #[doc(hidden)]
1546    pub fn reader_worker_count_for_test(&self) -> usize {
1547        self.reader_pool.worker_count()
1548    }
1549
1550    #[cfg(debug_assertions)]
1551    #[doc(hidden)]
1552    pub fn live_reader_worker_count_for_test(&self) -> usize {
1553        self.reader_pool.live_count()
1554    }
1555
1556    /// Pack 6.G G.1 — return the `sqlite3_db_config(LOOKASIDE)` rc
1557    /// captured for each reader worker at open time, in worker index
1558    /// order. SQLITE_OK (= 0) means the lookaside was configured
1559    /// before any allocation happened on the connection.
1560    #[cfg(debug_assertions)]
1561    #[doc(hidden)]
1562    pub fn reader_lookaside_config_rcs_for_test(&self) -> Vec<i32> {
1563        self.reader_lookaside_rcs.clone()
1564    }
1565
1566    /// Pack 6.G G.1 — query each reader worker's
1567    /// `SQLITE_DBSTATUS_LOOKASIDE_USED` counter. A value > 0 means at
1568    /// least one allocation was satisfied from the per-connection
1569    /// lookaside arena (proof the configuration was honored before the
1570    /// first prepare).
1571    #[cfg(debug_assertions)]
1572    #[doc(hidden)]
1573    pub fn reader_lookaside_used_per_worker_for_test(&self) -> Vec<i32> {
1574        self.reader_pool.lookaside_used_per_worker()
1575    }
1576
1577    /// Pack 6.G G.3.5 — broadcast a debug-only `CacheStatus` request to
1578    /// every reader worker and collect per-worker
1579    /// `SQLITE_DBSTATUS_CACHE_HIT` / `_CACHE_MISS` / `_CACHE_USED`
1580    /// values. Counters are monotonic (reset flag = 0); callers compute
1581    /// pre/post deltas explicitly.
1582    #[cfg(debug_assertions)]
1583    #[doc(hidden)]
1584    pub fn cache_status_per_worker_for_test(&self, label: &str) -> Vec<CacheStatusReply> {
1585        self.reader_pool.cache_status_per_worker(label)
1586    }
1587
1588    #[cfg(debug_assertions)]
1589    #[doc(hidden)]
1590    pub fn force_next_commit_failure_for_test(&self) {
1591        self.force_next_commit_failure.store(true, Ordering::SeqCst);
1592    }
1593
1594    /// Execute an arbitrary SQL statement on the writer connection through
1595    /// the same wall-clock + slow-detect path as `write` / `search`.
1596    ///
1597    /// Test-only helper for the deterministic-slow-cte fixture used by
1598    /// AC-007a / AC-007b. Not part of the public 0.6.0 surface; gated on
1599    /// `debug_assertions` so release builds do not expose it.
1600    #[cfg(debug_assertions)]
1601    #[doc(hidden)]
1602    pub fn execute_for_test(&self, sql: &str) -> Result<(), EngineError> {
1603        self.ensure_open()?;
1604        let started = Instant::now();
1605        {
1606            let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1607            let connection = connection.as_mut().ok_or(EngineError::Closing)?;
1608            connection.execute_batch(sql).map_err(|_| EngineError::Storage)?;
1609        }
1610        self.detect_slow(started, lifecycle::EventCategory::Search);
1611        Ok(())
1612    }
1613
1614    /// One-thread-poison robustness fixture (AC-009).
1615    ///
1616    /// Spawns four reader threads + one writer thread that all make
1617    /// forward progress (single canonical write + repeated searches),
1618    /// plus one designated poison thread that runs an empty-batch write
1619    /// — a deterministic `EngineError::WriteValidation`. The captured
1620    /// poison failure is dispatched as a `StressFailureContext` whose
1621    /// `last_error_chain` is `[EngineError::stable_code(),
1622    /// engine_error.to_string()]` per the lifecycle § Stress-failure
1623    /// context payload contract.
1624    #[doc(hidden)]
1625    #[cfg(debug_assertions)]
1626    pub fn run_one_thread_poison_for_test(&self) -> Result<(), EngineError> {
1627        self.ensure_open()?;
1628
1629        // Forward-progress writer seeds a row so readers + the poison
1630        // thread share a non-trivial canonical state.
1631        self.write(&[PreparedWrite::Node {
1632            kind: "doc".to_string(),
1633            body: "poison-fixture-seed".to_string(),
1634            source_id: None,
1635        }])?;
1636
1637        let poison_outcome: Mutex<Option<EngineError>> = Mutex::new(None);
1638        let poison_thread_id: AtomicU64 = AtomicU64::new(0);
1639
1640        thread::scope(|scope| {
1641            // N=4 reader threads make forward progress.
1642            for _ in 0..4 {
1643                scope.spawn(|| {
1644                    for _ in 0..4 {
1645                        let _ = self.search("poison-fixture-seed");
1646                    }
1647                });
1648            }
1649            // One forward-progress writer thread.
1650            scope.spawn(|| {
1651                let _ = self.write(&[PreparedWrite::Node {
1652                    kind: "doc".to_string(),
1653                    body: "writer-progress".to_string(),
1654                    source_id: None,
1655                }]);
1656            });
1657            // One poison thread — empty batch is a deterministic
1658            // WriteValidation failure.
1659            scope.spawn(|| {
1660                // Use a non-zero, deterministic group id so subscribers
1661                // see a stable identifier across runs of the fixture.
1662                poison_thread_id.store(1, Ordering::SeqCst);
1663                if let Err(err) = self.write(&[]) {
1664                    *poison_outcome.lock().expect("poison_outcome lock") = Some(err);
1665                }
1666            });
1667        });
1668
1669        let err = poison_outcome
1670            .into_inner()
1671            .expect("poison_outcome lock")
1672            .expect("poison thread must produce a deterministic error");
1673
1674        let projection_state = match self.projection_status_for_test("doc") {
1675            Ok(lifecycle::ProjectionStatus::Pending) => "Pending",
1676            Ok(lifecycle::ProjectionStatus::Failed) => "Failed",
1677            Ok(lifecycle::ProjectionStatus::UpToDate) => "UpToDate",
1678            // Default to UpToDate when projection status is unobservable
1679            // (e.g. embedder not configured for the seed kind). The
1680            // value is still one of the documented enum stringifications
1681            // per AC-010.
1682            Err(_) => "UpToDate",
1683        };
1684
1685        let context = lifecycle::StressFailureContext {
1686            thread_group_id: poison_thread_id.load(Ordering::SeqCst),
1687            op_kind: "write".to_string(),
1688            last_error_chain: vec![err.stable_code().to_string(), err.to_string()],
1689            projection_state: projection_state.to_string(),
1690        };
1691        self.subscribers.dispatch_stress_failure(&context);
1692        Ok(())
1693    }
1694
1695    #[doc(hidden)]
1696    pub fn set_projection_scheduler_frozen_for_test(&self, frozen: bool) {
1697        self.projection_runtime.set_frozen(frozen);
1698    }
1699
1700    #[doc(hidden)]
1701    pub fn set_projection_retry_delays_for_test(&self, delays_ms: &[u64]) {
1702        self.projection_runtime.set_retry_delays_for_test(delays_ms);
1703    }
1704
1705    #[doc(hidden)]
1706    pub fn projection_status_for_test(
1707        &self,
1708        kind: &str,
1709    ) -> Result<lifecycle::ProjectionStatus, EngineError> {
1710        self.ensure_open()?;
1711        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1712        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1713        projection_status(connection, kind)
1714    }
1715
1716    #[doc(hidden)]
1717    pub fn has_vector_for_cursor_for_test(&self, cursor: u64) -> Result<bool, EngineError> {
1718        self.ensure_open()?;
1719        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1720        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1721        terminal_state_for_cursor(connection, cursor)
1722            .map(|state| matches!(state.as_deref(), Some("up_to_date")))
1723            .map_err(|_| EngineError::Storage)
1724    }
1725
1726    #[doc(hidden)]
1727    pub fn projection_failure_count_for_test(&self, cursor: u64) -> Result<u64, EngineError> {
1728        self.ensure_open()?;
1729        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1730        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1731        connection
1732            .query_row(
1733                "SELECT COUNT(*) FROM operational_mutations
1734                 WHERE collection_name = 'projection_failures'
1735                   AND record_key = ?1",
1736                [cursor.to_string()],
1737                |row| row.get::<_, u64>(0),
1738            )
1739            .map_err(|_| EngineError::Storage)
1740    }
1741
1742    #[doc(hidden)]
1743    pub fn set_provenance_row_cap_for_test(&self, cap: Option<u64>) {
1744        self.provenance_row_cap.store(cap.unwrap_or(0), Ordering::Relaxed);
1745    }
1746
1747    #[doc(hidden)]
1748    pub fn provenance_row_count_for_test(&self) -> Result<u64, EngineError> {
1749        self.ensure_open()?;
1750        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1751        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1752        connection
1753            .query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get::<_, u64>(0))
1754            .map_err(|_| EngineError::Storage)
1755    }
1756
1757    #[doc(hidden)]
1758    pub fn oldest_provenance_record_key_for_test(
1759        &self,
1760        collection: &str,
1761    ) -> Result<Option<String>, EngineError> {
1762        self.ensure_open()?;
1763        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1764        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1765        connection
1766            .query_row(
1767                "SELECT record_key FROM operational_mutations
1768                 WHERE collection_name = ?1
1769                 ORDER BY id
1770                 LIMIT 1",
1771                [collection],
1772                |row| row.get::<_, String>(0),
1773            )
1774            .map(Some)
1775            .or_else(|err| match err {
1776                rusqlite::Error::QueryReturnedNoRows => Ok(None),
1777                _ => Err(EngineError::Storage),
1778            })
1779    }
1780
1781    #[doc(hidden)]
1782    pub fn configure_vector_kind_for_test(&self, kind: &str) -> Result<(), EngineError> {
1783        self.ensure_open()?;
1784        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1785        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
1786        connection
1787            .execute(
1788                "INSERT OR REPLACE INTO _fathomdb_vector_kinds(kind, profile, created_at)
1789                 VALUES(?1, ?2, 0)",
1790                params![kind, DEFAULT_VECTOR_PROFILE],
1791            )
1792            .map_err(|_| EngineError::Storage)?;
1793        Ok(())
1794    }
1795
1796    #[doc(hidden)]
1797    pub fn write_vector_for_test(
1798        &self,
1799        kind: &str,
1800        text: &str,
1801    ) -> Result<WriteReceipt, EngineError> {
1802        self.ensure_open()?;
1803        let embedder =
1804            self.runtime_embedder.as_ref().cloned().ok_or(EngineError::EmbedderNotConfigured)?;
1805
1806        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1807        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
1808        if !kind_is_vector_indexed(connection, kind)? {
1809            return Err(EngineError::KindNotVectorIndexed);
1810        }
1811
1812        let expected = default_profile_dimension(connection)?;
1813        ensure_vector_partition(connection, expected).map_err(|_| EngineError::Storage)?;
1814        let vector = embedder.embed(text).map_err(map_runtime_embedder_error)?;
1815        let actual = u32::try_from(vector.len()).unwrap_or(u32::MAX);
1816        if actual != expected {
1817            return Err(EngineError::EmbedderDimensionMismatch { expected, actual });
1818        }
1819
1820        let cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
1821        let blob = encode_vector_blob(&vector);
1822        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
1823        tx.execute(
1824            "INSERT INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
1825            params![cursor, kind, cursor],
1826        )
1827        .map_err(|_| EngineError::Storage)?;
1828        tx.execute(
1829            "INSERT INTO vector_default(rowid, embedding) VALUES(?1, ?2)",
1830            params![cursor, blob],
1831        )
1832        .map_err(|_| EngineError::Storage)?;
1833        tx.commit().map_err(|_| EngineError::Storage)?;
1834        self.next_cursor.store(cursor, Ordering::SeqCst);
1835        Ok(WriteReceipt { cursor })
1836    }
1837
1838    #[doc(hidden)]
1839    pub fn vector_row_count_for_test(&self) -> Result<u64, EngineError> {
1840        self.ensure_open()?;
1841        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1842        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1843        connection
1844            .query_row("SELECT COUNT(*) FROM vector_default", [], |row| row.get::<_, u64>(0))
1845            .map_err(|_| EngineError::Storage)
1846    }
1847
1848    #[doc(hidden)]
1849    pub fn read_vector_blob_for_test(&self, rowid: i64) -> Result<Vec<u8>, EngineError> {
1850        self.ensure_open()?;
1851        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1852        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1853        connection
1854            .query_row("SELECT embedding FROM vector_default WHERE rowid = ?1", [rowid], |row| {
1855                row.get::<_, Vec<u8>>(0)
1856            })
1857            .map_err(|_| EngineError::Storage)
1858    }
1859
1860    #[doc(hidden)]
1861    pub fn default_embedder_profile_for_test(&self) -> Result<EmbedderIdentity, EngineError> {
1862        self.ensure_open()?;
1863        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1864        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1865        load_default_profile(connection).map_err(|_| EngineError::Storage)
1866    }
1867
1868    /// Doctor read-only integrity report. Three-section output per
1869    /// AC-043a/b. `opts.full` adds `PRAGMA integrity_check`. `quick` and
1870    /// `round_trip` are accepted but treated as default for 0.6.0.
1871    pub fn check_integrity(
1872        &self,
1873        opts: CheckIntegrityOpts,
1874    ) -> Result<IntegrityReport, EngineError> {
1875        self.ensure_open()?;
1876        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1877        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1878        Ok(IntegrityReport {
1879            physical: physical_section(connection, opts.full),
1880            logical: logical_section(connection),
1881            semantic: semantic_section(connection),
1882        })
1883    }
1884
1885    /// Doctor bit-preserving export. Runs `VACUUM INTO` to produce a
1886    /// self-contained SQLite file at `out`, computes SHA-256 of the
1887    /// resulting bytes, and writes a JSON manifest at `manifest`. Per
1888    /// AC-039a/b.
1889    pub fn safe_export(
1890        &self,
1891        out: &Path,
1892        manifest: &Path,
1893    ) -> Result<SafeExportArtifact, EngineError> {
1894        self.ensure_open()?;
1895        {
1896            let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1897            let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1898            let target = out.to_string_lossy().to_string();
1899            connection
1900                .execute("VACUUM INTO ?1", params![target])
1901                .map_err(|_| EngineError::Storage)?;
1902        }
1903        let bytes = std::fs::read(out).map_err(|_| EngineError::Storage)?;
1904        let digest = sha2::Sha256::digest(&bytes);
1905        let sha256_hex = hex_encode(digest.as_slice());
1906        let export_abs = out.canonicalize().unwrap_or_else(|_| out.to_path_buf());
1907        let manifest_json = serde_json::json!({
1908            "export_path": export_abs.to_string_lossy(),
1909            "sha256": sha256_hex,
1910            "byte_count": bytes.len() as u64,
1911        });
1912        let manifest_bytes =
1913            serde_json::to_vec_pretty(&manifest_json).map_err(|_| EngineError::Storage)?;
1914        std::fs::write(manifest, &manifest_bytes).map_err(|_| EngineError::Storage)?;
1915        Ok(SafeExportArtifact {
1916            export_path: out.to_path_buf(),
1917            manifest_path: manifest.to_path_buf(),
1918            manifest_sha256: sha256_hex,
1919        })
1920    }
1921
1922    /// Operator regenerate workflow per `dev/design/projections.md`
1923    /// § Regenerate workflow. Drains in-flight projection work, then
1924    /// truncates FTS5 + vec0 shadow rows, resets the projection cursor,
1925    /// and lets the scheduler re-enqueue every canonical row. Durable
1926    /// `projection_failures` audit rows are preserved per design. AC-044
1927    /// + AC-063c.
1928    pub fn rebuild_projections(&self) -> Result<RebuildReport, EngineError> {
1929        self.ensure_open()?;
1930        self.run_rebuild(true, RebuildKind::Projections)
1931    }
1932
1933    /// Vec0-only variant of [`Engine::rebuild_projections`]. Leaves
1934    /// FTS5 shadow content untouched; per recovery design,
1935    /// `recover --rebuild-vec0` is the surface for vec0-only repair.
1936    pub fn rebuild_vec0(&self) -> Result<RebuildReport, EngineError> {
1937        self.ensure_open()?;
1938        self.run_rebuild(false, RebuildKind::Vec0)
1939    }
1940
1941    /// Phase 9 Pack B / AC-042 source trace. Returns the canonical-row
1942    /// id set produced by `source_id`, ordered by `write_cursor`. Empty
1943    /// string is not a valid `source_id`; rows with NULL `source_id`
1944    /// are excluded from every result.
1945    pub fn trace_source_ref(&self, source_id: &str) -> Result<TraceReport, EngineError> {
1946        self.ensure_open()?;
1947        if source_id.is_empty() {
1948            return Err(EngineError::WriteValidation);
1949        }
1950        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
1951        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
1952
1953        let mut events: Vec<TraceEvent> = Vec::new();
1954        let mut nodes = connection
1955            .prepare(
1956                "SELECT write_cursor, kind FROM canonical_nodes WHERE source_id = ?1
1957                 ORDER BY write_cursor",
1958            )
1959            .map_err(|_| EngineError::Storage)?;
1960        let node_rows = nodes
1961            .query_map([source_id], |row| {
1962                Ok(TraceEvent {
1963                    write_cursor: row.get::<_, i64>(0)? as u64,
1964                    kind: row.get::<_, String>(1)?,
1965                    table: "canonical_nodes",
1966                })
1967            })
1968            .map_err(|_| EngineError::Storage)?;
1969        for row in node_rows {
1970            events.push(row.map_err(|_| EngineError::Storage)?);
1971        }
1972
1973        let mut edges = connection
1974            .prepare(
1975                "SELECT write_cursor, kind FROM canonical_edges WHERE source_id = ?1
1976                 ORDER BY write_cursor",
1977            )
1978            .map_err(|_| EngineError::Storage)?;
1979        let edge_rows = edges
1980            .query_map([source_id], |row| {
1981                Ok(TraceEvent {
1982                    write_cursor: row.get::<_, i64>(0)? as u64,
1983                    kind: row.get::<_, String>(1)?,
1984                    table: "canonical_edges",
1985                })
1986            })
1987            .map_err(|_| EngineError::Storage)?;
1988        for row in edge_rows {
1989            events.push(row.map_err(|_| EngineError::Storage)?);
1990        }
1991
1992        events.sort_by_key(|e| e.write_cursor);
1993        Ok(TraceReport { source_ref: source_id.to_string(), events })
1994    }
1995
1996    /// Phase 9 Pack B / AC-028a/b/c source excise. Drains in-flight
1997    /// projection work, then deletes every canonical row attributable
1998    /// to `source_id` plus the FTS5 + vec0 shadow rows that referenced
1999    /// those cursors, and appends an audit row to the
2000    /// `excise_source_audit` operational collection.
2001    ///
2002    /// Non-perturbation: rows from other sources (and rows with NULL
2003    /// `source_id`) are untouched; the projection cursor is NOT reset
2004    /// and no blanket projection rebuild is issued.
2005    pub fn excise_source(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
2006        self.ensure_open()?;
2007        if source_id.is_empty() {
2008            return Err(EngineError::WriteValidation);
2009        }
2010
2011        // Drain MUST succeed before the excise transaction. SQLite-WAL
2012        // would otherwise allow a worker that already dequeued a job
2013        // for an excised cursor to commit its INSERT into vec0 /
2014        // _fathomdb_vector_rows after our DELETE releases the writer
2015        // lock, leaving residue and breaking AC-028b. Surface the
2016        // timeout instead of swallowing it (Pack A pattern).
2017        self.projection_runtime.set_frozen(true);
2018        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
2019        let outcome = drain_result.and_then(|()| self.excise_source_inner(source_id));
2020        self.projection_runtime.set_frozen(false);
2021        outcome
2022    }
2023
2024    /// Doctor `verify-embedder` seam (AC-040a). Compares the
2025    /// `_fathomdb_embedder_profiles` row to the operator-supplied
2026    /// `name:revision` identity + dimension; never raises on mismatch.
2027    pub fn verify_embedder(
2028        &self,
2029        supplied_identity: &str,
2030        supplied_dimension: u32,
2031    ) -> Result<VerifyEmbedderReport, EngineError> {
2032        self.ensure_open()?;
2033        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2034        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2035        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
2036        let stored_identity = format!("{}:{}", stored.name, stored.revision);
2037        let identity_match = stored_identity == supplied_identity;
2038        let dimension_match = stored.dimension == supplied_dimension;
2039        let status = match (identity_match, dimension_match) {
2040            (true, true) => VerifyEmbedderStatus::Match,
2041            (false, true) => VerifyEmbedderStatus::IdentityMismatch,
2042            (true, false) => VerifyEmbedderStatus::DimensionMismatch,
2043            (false, false) => VerifyEmbedderStatus::BothMismatch,
2044        };
2045        Ok(VerifyEmbedderReport {
2046            stored_identity,
2047            stored_dimension: stored.dimension,
2048            supplied_identity: supplied_identity.to_string(),
2049            supplied_dimension,
2050            status,
2051        })
2052    }
2053
2054    /// Doctor `dump-schema` seam (AC-040a). Returns the
2055    /// `PRAGMA user_version` sentinel plus the table + index inventory
2056    /// from `sqlite_schema`, excluding `sqlite_*` internal rows.
2057    /// Canonical tables appear first per [`CANONICAL_TABLES`].
2058    pub fn dump_schema(&self) -> Result<DumpSchemaReport, EngineError> {
2059        self.ensure_open()?;
2060        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2061        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2062        let user_version: u32 = connection
2063            .query_row("PRAGMA user_version", [], |row| row.get(0))
2064            .map_err(|_| EngineError::Storage)?;
2065        let tables = read_schema_objects(connection, "table")?;
2066        let indexes = read_schema_objects(connection, "index")?;
2067        Ok(DumpSchemaReport { user_version, tables: order_canonical_first(tables), indexes })
2068    }
2069
2070    /// Doctor `dump-row-counts` seam (AC-040a). Emits canonical-table
2071    /// counts only; projection / FTS / vec0 shadow tables are excluded.
2072    pub fn dump_row_counts(&self) -> Result<DumpRowCountsReport, EngineError> {
2073        self.ensure_open()?;
2074        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2075        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2076        let mut counts = Vec::with_capacity(CANONICAL_TABLES.len());
2077        for name in CANONICAL_TABLES {
2078            let rows: u64 = connection
2079                .query_row(&format!("SELECT COUNT(*) FROM {name}"), [], |row| row.get(0))
2080                .map_err(|_| EngineError::Storage)?;
2081            counts.push(TableRowCount { name: (*name).to_string(), rows });
2082        }
2083        Ok(DumpRowCountsReport { counts })
2084    }
2085
2086    /// Doctor `dump-profile` seam (AC-040a). Returns the stored
2087    /// embedder identity + dimension plus the registered vectorized
2088    /// kinds from `_fathomdb_vector_kinds`.
2089    pub fn dump_profile(&self) -> Result<DumpProfileReport, EngineError> {
2090        self.ensure_open()?;
2091        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2092        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2093        let stored = load_default_profile(connection).map_err(|_| EngineError::Storage)?;
2094        let mut stmt = connection
2095            .prepare("SELECT kind FROM _fathomdb_vector_kinds ORDER BY kind")
2096            .map_err(|_| EngineError::Storage)?;
2097        let rows =
2098            stmt.query_map([], |row| row.get::<_, String>(0)).map_err(|_| EngineError::Storage)?;
2099        let mut vectorized_kinds = Vec::new();
2100        for row in rows {
2101            vectorized_kinds.push(row.map_err(|_| EngineError::Storage)?);
2102        }
2103        Ok(DumpProfileReport {
2104            embedder_identity: format!("{}:{}", stored.name, stored.revision),
2105            embedder_dimension: stored.dimension,
2106            vectorized_kinds,
2107        })
2108    }
2109
2110    /// Recover `--truncate-wal` seam. Runs
2111    /// `PRAGMA wal_checkpoint(TRUNCATE)` and returns the three counters
2112    /// SQLite reports. `status = Busy` when SQLite signalled a blocked
2113    /// checkpoint (`busy != 0`); the WAL may still be partially
2114    /// checkpointed in that case.
2115    pub fn truncate_wal(&self) -> Result<TruncateWalReport, EngineError> {
2116        self.ensure_open()?;
2117        let connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2118        let connection = connection.as_ref().ok_or(EngineError::Closing)?;
2119        let (busy, log_frames, checkpointed_frames): (i64, i64, i64) = connection
2120            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
2121                Ok((row.get(0)?, row.get(1)?, row.get(2)?))
2122            })
2123            .map_err(|_| EngineError::Storage)?;
2124        let status = if busy == 0 { TruncateWalStatus::Done } else { TruncateWalStatus::Busy };
2125        Ok(TruncateWalReport {
2126            status,
2127            busy: busy.max(0) as u32,
2128            log_frames: log_frames.max(0) as u32,
2129            checkpointed_frames: checkpointed_frames.max(0) as u32,
2130        })
2131    }
2132
2133    fn excise_source_inner(&self, source_id: &str) -> Result<ExciseReport, EngineError> {
2134        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2135        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2136        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
2137
2138        // Collect the cursor sets up-front so we can targeted-delete
2139        // shadow rows AND emit an accurate audit row in one txn.
2140        let node_cursors: Vec<i64> = {
2141            let mut stmt = tx
2142                .prepare("SELECT write_cursor FROM canonical_nodes WHERE source_id = ?1")
2143                .map_err(|_| EngineError::Storage)?;
2144            let rows = stmt
2145                .query_map([source_id], |row| row.get::<_, i64>(0))
2146                .map_err(|_| EngineError::Storage)?;
2147            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
2148        };
2149        let edge_cursors: Vec<i64> = {
2150            let mut stmt = tx
2151                .prepare("SELECT write_cursor FROM canonical_edges WHERE source_id = ?1")
2152                .map_err(|_| EngineError::Storage)?;
2153            let rows = stmt
2154                .query_map([source_id], |row| row.get::<_, i64>(0))
2155                .map_err(|_| EngineError::Storage)?;
2156            rows.collect::<rusqlite::Result<Vec<_>>>().map_err(|_| EngineError::Storage)?
2157        };
2158
2159        let mut shadow_invalidated: u64 = 0;
2160        for cursor in node_cursors.iter().chain(edge_cursors.iter()) {
2161            shadow_invalidated = shadow_invalidated.saturating_add(
2162                tx.execute("DELETE FROM search_index WHERE write_cursor = ?1", [cursor])
2163                    .map_err(|_| EngineError::Storage)? as u64,
2164            );
2165            // vec0 rowid is the canonical row's write_cursor (see
2166            // `_fathomdb_vector_rows.write_cursor UNIQUE`).
2167            shadow_invalidated = shadow_invalidated.saturating_add(
2168                tx.execute("DELETE FROM vector_default WHERE rowid = ?1", [cursor])
2169                    .map_err(|_| EngineError::Storage)? as u64,
2170            );
2171            shadow_invalidated = shadow_invalidated.saturating_add(
2172                tx.execute("DELETE FROM _fathomdb_vector_rows WHERE write_cursor = ?1", [cursor])
2173                    .map_err(|_| EngineError::Storage)? as u64,
2174            );
2175            shadow_invalidated = shadow_invalidated.saturating_add(
2176                tx.execute(
2177                    "DELETE FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
2178                    [cursor],
2179                )
2180                .map_err(|_| EngineError::Storage)? as u64,
2181            );
2182        }
2183
2184        let nodes_excised = tx
2185            .execute("DELETE FROM canonical_nodes WHERE source_id = ?1", [source_id])
2186            .map_err(|_| EngineError::Storage)? as u64;
2187        let edges_excised = tx
2188            .execute("DELETE FROM canonical_edges WHERE source_id = ?1", [source_id])
2189            .map_err(|_| EngineError::Storage)? as u64;
2190
2191        // AC-028a audit row: a single append on the
2192        // `excise_source_audit` collection naming the excised source.
2193        // `next_cursor` after a prior write holds the LAST committed cursor;
2194        // mirror the vec writer pattern (load + 1, then store post-commit)
2195        // so the audit row's `write_cursor` is strictly greater than every
2196        // canonical row that preceded it.
2197        let excised_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
2198        let payload = serde_json::json!({
2199            "source_id": source_id,
2200            "excised_at": excised_at,
2201            "nodes_excised": nodes_excised,
2202            "edges_excised": edges_excised,
2203            "projections_invalidated": shadow_invalidated,
2204        })
2205        .to_string();
2206        let audit_cursor = self.next_cursor.load(Ordering::SeqCst).saturating_add(1);
2207        tx.execute(
2208            "INSERT INTO operational_mutations(
2209                collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
2210             ) VALUES('excise_source_audit', ?1, 'append', ?2, NULL, ?3)",
2211            params![source_id, payload, audit_cursor],
2212        )
2213        .map_err(|_| EngineError::Storage)?;
2214
2215        tx.commit().map_err(|_| EngineError::Storage)?;
2216        self.next_cursor.store(audit_cursor, Ordering::SeqCst);
2217        Ok(ExciseReport {
2218            source_ref: source_id.to_string(),
2219            nodes_excised,
2220            edges_excised,
2221            projections_invalidated: shadow_invalidated,
2222        })
2223    }
2224
2225    fn run_rebuild(
2226        &self,
2227        include_fts: bool,
2228        kind: RebuildKind,
2229    ) -> Result<RebuildReport, EngineError> {
2230        self.projection_runtime.set_frozen(true);
2231        // Drain MUST succeed: rebuild_shadow_state truncates shadow rows,
2232        // and SQLite-WAL allows a worker that already dequeued a job to
2233        // commit its `INSERT OR IGNORE INTO _fathomdb_vector_rows / vec0`
2234        // after our truncate releases the writer lock, leaving stale
2235        // rows. Surfacing the timeout (instead of swallowing it) lets the
2236        // operator retry rather than silently corrupt the rebuild.
2237        let drain_result = self.drain(REBUILD_DRAIN_TIMEOUT_MS);
2238        let result = drain_result.and_then(|()| self.rebuild_shadow_state(include_fts, kind));
2239        self.projection_runtime.set_frozen(false);
2240        result
2241    }
2242
2243    fn rebuild_shadow_state(
2244        &self,
2245        include_fts: bool,
2246        kind: RebuildKind,
2247    ) -> Result<RebuildReport, EngineError> {
2248        let mut connection = self.connection.lock().map_err(|_| EngineError::Storage)?;
2249        let connection = connection.as_mut().ok_or(EngineError::Closing)?;
2250        let tx = connection.transaction().map_err(|_| EngineError::Storage)?;
2251        let mut rows_invalidated: u64 = 0;
2252        if include_fts {
2253            let n = tx.execute("DELETE FROM search_index", []).map_err(|_| EngineError::Storage)?;
2254            rows_invalidated = rows_invalidated.saturating_add(n as u64);
2255        }
2256        let n = tx.execute("DELETE FROM vector_default", []).map_err(|_| EngineError::Storage)?;
2257        rows_invalidated = rows_invalidated.saturating_add(n as u64);
2258        let n = tx
2259            .execute("DELETE FROM _fathomdb_vector_rows", [])
2260            .map_err(|_| EngineError::Storage)?;
2261        rows_invalidated = rows_invalidated.saturating_add(n as u64);
2262        let n = tx
2263            .execute("DELETE FROM _fathomdb_projection_terminal", [])
2264            .map_err(|_| EngineError::Storage)?;
2265        rows_invalidated = rows_invalidated.saturating_add(n as u64);
2266        store_projection_cursor(&tx, 0).map_err(|_| EngineError::Storage)?;
2267        let mut rows_rebuilt: u64 = 0;
2268        if include_fts {
2269            for row in canonical_node_rows(&tx).map_err(|_| EngineError::Storage)? {
2270                tx.execute(
2271                    "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
2272                    params![row.body, row.kind, row.cursor],
2273                )
2274                .map_err(|_| EngineError::Storage)?;
2275                rows_rebuilt = rows_rebuilt.saturating_add(1);
2276            }
2277        }
2278        let projection_cursor_after =
2279            load_projection_cursor(&tx).map_err(|_| EngineError::Storage)?;
2280        tx.commit().map_err(|_| EngineError::Storage)?;
2281        Ok(RebuildReport { kind, rows_invalidated, rows_rebuilt, projection_cursor_after })
2282    }
2283
2284    fn ensure_open(&self) -> Result<(), EngineError> {
2285        if self.closed.load(Ordering::SeqCst) {
2286            return Err(EngineError::Closing);
2287        }
2288
2289        Ok(())
2290    }
2291}
2292
2293fn batch_is_admin(batch: &[PreparedWrite]) -> bool {
2294    !batch.is_empty() && batch.iter().all(|w| matches!(w, PreparedWrite::AdminSchema { .. }))
2295}
2296
2297/// Read projection cursor and matching body rows inside one read tx.
2298fn read_search_in_tx(
2299    reader: &mut Connection,
2300    compiled: &fathomdb_query::CompiledQuery,
2301    query_vector: Option<&str>,
2302) -> rusqlite::Result<(u64, Option<SoftFallback>, Vec<String>)> {
2303    let tx = reader.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
2304    let cursor = load_projection_cursor(&tx)?;
2305    let vector_results = if let Some(query_vector) = query_vector {
2306        let mut rowids = Vec::new();
2307        {
2308            let mut statement = tx.prepare(
2309                "SELECT rowid
2310                 FROM vector_default
2311                 WHERE embedding MATCH vec_f32(?1)
2312                 ORDER BY distance
2313                 LIMIT 10",
2314            )?;
2315            let rows = statement.query_map([query_vector], |row| row.get::<_, i64>(0))?;
2316            for row in rows.flatten() {
2317                rowids.push(row);
2318            }
2319        }
2320        let mut results = Vec::new();
2321        let mut statement =
2322            tx.prepare("SELECT body FROM canonical_nodes WHERE write_cursor = ?1 LIMIT 1")?;
2323        for rowid in rowids {
2324            if let Ok(body) = statement.query_row([rowid], |row| row.get::<_, String>(0)) {
2325                results.push(body);
2326            }
2327        }
2328        results
2329    } else {
2330        Vec::new()
2331    };
2332    let vector_rows_visible = !vector_results.is_empty();
2333    let soft_fallback = if query_vector.is_some() && !vector_rows_visible {
2334        tx.query_row(
2335            "SELECT 1
2336             FROM search_index
2337             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = search_index.kind
2338             LEFT JOIN _fathomdb_projection_terminal
2339               ON _fathomdb_projection_terminal.write_cursor = search_index.write_cursor
2340             WHERE search_index MATCH ?1
2341              AND _fathomdb_projection_terminal.write_cursor IS NULL
2342             LIMIT 1",
2343            [compiled.match_expression.as_str()],
2344            |_row| Ok(SoftFallback { branch: SoftFallbackBranch::Vector }),
2345        )
2346        .ok()
2347    } else {
2348        None
2349    };
2350    let mut seen = BTreeSet::new();
2351    let mut results = Vec::new();
2352    for row in vector_results {
2353        if seen.insert(row.clone()) {
2354            results.push(row);
2355        }
2356    }
2357    {
2358        let mut statement = tx.prepare(
2359            "SELECT body FROM search_index WHERE search_index MATCH ?1 ORDER BY write_cursor",
2360        )?;
2361        let rows = statement
2362            .query_map([compiled.match_expression.as_str()], |row| row.get::<_, String>(0))?;
2363        for row in rows.flatten() {
2364            if seen.insert(row.clone()) {
2365                results.push(row);
2366            }
2367        }
2368    }
2369    tx.commit()?;
2370    Ok((cursor, soft_fallback, results))
2371}
2372
2373fn projection_dispatcher_loop(shared: Arc<ProjectionRuntimeShared>) {
2374    let connection = match open_runtime_connection(&shared.path) {
2375        Ok(connection) => connection,
2376        Err(_) => return,
2377    };
2378    loop {
2379        let in_flight = {
2380            let mut state = match shared.state.lock() {
2381                Ok(state) => state,
2382                Err(_) => return,
2383            };
2384            while !state.stopping
2385                && (!state.pending_scan
2386                    || state.frozen
2387                    || state.active_jobs + state.queued_jobs >= PROJECTION_INFLIGHT_LIMIT)
2388            {
2389                state = match shared.state_cvar.wait(state) {
2390                    Ok(state) => state,
2391                    Err(_) => return,
2392                };
2393            }
2394            if state.stopping {
2395                return;
2396            }
2397            state.pending_scan = false;
2398            state.in_flight.clone()
2399        };
2400
2401        match next_pending_projection_job(&connection, &in_flight) {
2402            Ok(Some(job)) => {
2403                if let Ok(mut state) = shared.state.lock() {
2404                    state.queued_jobs = state.queued_jobs.saturating_add(1);
2405                    state.in_flight.insert(job.cursor);
2406                    state.pending_scan = true;
2407                    shared.state_cvar.notify_all();
2408                }
2409                if let Ok(mut queue) = shared.queue.lock() {
2410                    queue.push_back(job);
2411                    shared.queue_cvar.notify_one();
2412                }
2413            }
2414            Ok(None) => {}
2415            Err(_) => {
2416                if let Ok(mut state) = shared.state.lock() {
2417                    state.pending_scan = false;
2418                    shared.state_cvar.notify_all();
2419                }
2420            }
2421        }
2422    }
2423}
2424
2425fn projection_worker_loop(shared: Arc<ProjectionRuntimeShared>) {
2426    let mut connection = match open_runtime_connection(&shared.path) {
2427        Ok(connection) => connection,
2428        Err(_) => return,
2429    };
2430    if ensure_vector_partition(&connection, shared.embedder_identity.dimension).is_err() {
2431        return;
2432    }
2433    loop {
2434        let jobs = {
2435            let mut queue = match shared.queue.lock() {
2436                Ok(queue) => queue,
2437                Err(_) => return,
2438            };
2439            loop {
2440                let stopping = shared.state.lock().map(|state| state.stopping).unwrap_or(true);
2441                if stopping && queue.is_empty() {
2442                    return;
2443                }
2444                if let Some(job) = queue.pop_front() {
2445                    let mut jobs = vec![job];
2446                    while jobs.len() < PROJECTION_COMMIT_BATCH {
2447                        let Some(job) = queue.pop_front() else {
2448                            break;
2449                        };
2450                        jobs.push(job);
2451                    }
2452                    if let Ok(mut state) = shared.state.lock() {
2453                        state.queued_jobs = state.queued_jobs.saturating_sub(jobs.len());
2454                        state.active_jobs = state.active_jobs.saturating_add(jobs.len());
2455                        shared.state_cvar.notify_all();
2456                    }
2457                    break jobs;
2458                }
2459                queue = match shared.queue_cvar.wait(queue) {
2460                    Ok(queue) => queue,
2461                    Err(_) => return,
2462                };
2463            }
2464        };
2465
2466        run_projection_jobs(&shared, &mut connection, &jobs);
2467
2468        if let Ok(mut state) = shared.state.lock() {
2469            state.active_jobs = state.active_jobs.saturating_sub(jobs.len());
2470            for job in &jobs {
2471                state.in_flight.remove(&job.cursor);
2472            }
2473            if !state.stopping {
2474                state.pending_scan = true;
2475            }
2476            shared.state_cvar.notify_all();
2477        }
2478    }
2479}
2480
2481enum ProjectionOutcome {
2482    Success { cursor: u64, kind: String, blob: Vec<u8> },
2483    Failure { cursor: u64, failure_code: &'static str },
2484}
2485
2486fn run_projection_jobs(
2487    shared: &ProjectionRuntimeShared,
2488    connection: &mut Connection,
2489    jobs: &[ProjectionJob],
2490) {
2491    let mut outcomes = Vec::with_capacity(jobs.len());
2492    for job in jobs {
2493        outcomes.push(run_projection_job(shared, job));
2494    }
2495    let _ = commit_projection_outcomes(connection, &outcomes);
2496}
2497
2498fn run_projection_job(shared: &ProjectionRuntimeShared, job: &ProjectionJob) -> ProjectionOutcome {
2499    let delays = shared.retry_delays_ms.lock().map(|delays| delays.clone()).unwrap_or_default();
2500    let mut last_code = "EmbedderError";
2501    for (attempt, delay_ms) in std::iter::once(0_u64).chain(delays.iter().copied()).enumerate() {
2502        if attempt > 0 {
2503            if shared.state.lock().map(|state| state.stopping).unwrap_or(true) {
2504                return ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code };
2505            }
2506            thread::sleep(Duration::from_millis(delay_ms));
2507        }
2508        let vector = match shared.embedder.as_ref() {
2509            Some(embedder) => match embedder.embed(&job.body) {
2510                Ok(vector) => vector,
2511                Err(RuntimeEmbedderError::Timeout) => {
2512                    last_code = "EmbedderError";
2513                    continue;
2514                }
2515                Err(RuntimeEmbedderError::Failed { .. }) => {
2516                    last_code = "EmbedderError";
2517                    continue;
2518                }
2519            },
2520            None => {
2521                last_code = "EmbedderNotConfiguredError";
2522                continue;
2523            }
2524        };
2525
2526        if u32::try_from(vector.len()).unwrap_or(u32::MAX) != shared.embedder_identity.dimension {
2527            last_code = "EmbedderDimensionMismatchError";
2528            continue;
2529        }
2530
2531        let blob = encode_vector_blob(&vector);
2532        return ProjectionOutcome::Success { cursor: job.cursor, kind: job.kind.clone(), blob };
2533    }
2534
2535    ProjectionOutcome::Failure { cursor: job.cursor, failure_code: last_code }
2536}
2537
2538fn next_pending_projection_job(
2539    connection: &Connection,
2540    in_flight: &BTreeSet<u64>,
2541) -> rusqlite::Result<Option<ProjectionJob>> {
2542    let cursor = load_projection_cursor(connection)?;
2543    let mut statement = connection.prepare_cached(
2544        "SELECT canonical_nodes.write_cursor, canonical_nodes.kind, canonical_nodes.body
2545         FROM canonical_nodes
2546         JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
2547         LEFT JOIN _fathomdb_projection_terminal
2548           ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
2549         WHERE canonical_nodes.write_cursor > ?1
2550           AND _fathomdb_projection_terminal.write_cursor IS NULL
2551         ORDER BY canonical_nodes.write_cursor
2552         LIMIT 32",
2553    )?;
2554    let rows = statement.query_map([cursor], |row| {
2555        Ok(ProjectionJob { cursor: row.get(0)?, kind: row.get(1)?, body: row.get(2)? })
2556    })?;
2557    for row in rows {
2558        let job = row?;
2559        if !in_flight.contains(&job.cursor) {
2560            return Ok(Some(job));
2561        }
2562    }
2563    Ok(None)
2564}
2565
2566fn database_has_pending_projection_work(path: &Path) -> rusqlite::Result<bool> {
2567    let connection = open_runtime_connection(path)?;
2568    let cursor = load_projection_cursor(&connection)?;
2569    connection
2570        .query_row(
2571            "SELECT 1
2572             FROM canonical_nodes
2573             JOIN _fathomdb_vector_kinds ON _fathomdb_vector_kinds.kind = canonical_nodes.kind
2574             LEFT JOIN _fathomdb_projection_terminal
2575               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
2576             WHERE canonical_nodes.write_cursor > ?1
2577               AND _fathomdb_projection_terminal.write_cursor IS NULL
2578             LIMIT 1",
2579            [cursor],
2580            |_row| Ok(true),
2581        )
2582        .or_else(|err| match err {
2583            rusqlite::Error::QueryReturnedNoRows => Ok(false),
2584            _ => Err(err),
2585        })
2586}
2587
2588struct CanonicalNodeRow {
2589    cursor: u64,
2590    kind: String,
2591    body: String,
2592}
2593
2594fn canonical_node_rows(connection: &Connection) -> rusqlite::Result<Vec<CanonicalNodeRow>> {
2595    let mut statement = connection
2596        .prepare("SELECT write_cursor, kind, body FROM canonical_nodes ORDER BY write_cursor")?;
2597    let rows = statement.query_map([], |row| {
2598        Ok(CanonicalNodeRow {
2599            cursor: row.get::<_, u64>(0)?,
2600            kind: row.get::<_, String>(1)?,
2601            body: row.get::<_, String>(2)?,
2602        })
2603    })?;
2604    rows.collect()
2605}
2606
2607fn hex_encode(bytes: &[u8]) -> String {
2608    let mut out = String::with_capacity(bytes.len() * 2);
2609    for byte in bytes {
2610        out.push(hex_nibble(byte >> 4));
2611        out.push(hex_nibble(byte & 0x0f));
2612    }
2613    out
2614}
2615
2616fn hex_nibble(value: u8) -> char {
2617    match value {
2618        0..=9 => (b'0' + value) as char,
2619        10..=15 => (b'a' + value - 10) as char,
2620        _ => unreachable!(),
2621    }
2622}
2623
2624fn physical_section(connection: &Connection, full: bool) -> Section {
2625    let mut findings = Vec::new();
2626    if let Err(err) = connection.query_row("PRAGMA page_count", [], |row| row.get::<_, i64>(0)) {
2627        findings.push(Finding {
2628            code: "E_CORRUPT_HEADER",
2629            stage: "PhysicalProbe",
2630            locator: locator_from_rusqlite_error(&err),
2631            doc_anchor: "design/recovery.md#header-malformed",
2632            detail: format!("page_count probe failed: {err}"),
2633        });
2634    }
2635    if full {
2636        match collect_integrity_check_findings(connection) {
2637            Ok(rows) => findings.extend(rows),
2638            Err(err) => findings.push(Finding {
2639                code: "E_CORRUPT_INTEGRITY_CHECK",
2640                stage: "IntegrityCheck",
2641                locator: locator_from_rusqlite_error(&err),
2642                doc_anchor: "design/recovery.md#integrity-check-full-findings",
2643                detail: format!("PRAGMA integrity_check failed: {err}"),
2644            }),
2645        }
2646    }
2647    if findings.is_empty() {
2648        Section::Clean
2649    } else {
2650        Section::Findings(findings)
2651    }
2652}
2653
2654fn logical_section(connection: &Connection) -> Section {
2655    let mut findings = Vec::new();
2656    if let Err(err) = connection.query_row("PRAGMA schema_version", [], |row| row.get::<_, i64>(0))
2657    {
2658        findings.push(Finding {
2659            code: "E_CORRUPT_SCHEMA",
2660            stage: "SchemaProbe",
2661            locator: locator_from_rusqlite_error(&err),
2662            doc_anchor: "design/recovery.md#schema-inconsistent",
2663            detail: format!("schema_version probe failed: {err}"),
2664        });
2665    }
2666    match connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)) {
2667        Ok(0) => findings.push(Finding {
2668            code: "E_CORRUPT_SCHEMA",
2669            stage: "SchemaProbe",
2670            locator: CorruptionLocator::MigrationStep { from: 0, to: 0 },
2671            doc_anchor: "design/recovery.md#schema-inconsistent",
2672            detail: "user_version is zero".to_string(),
2673        }),
2674        Ok(_) => {}
2675        Err(err) => findings.push(Finding {
2676            code: "E_CORRUPT_SCHEMA",
2677            stage: "SchemaProbe",
2678            locator: locator_from_rusqlite_error(&err),
2679            doc_anchor: "design/recovery.md#schema-inconsistent",
2680            detail: format!("user_version probe failed: {err}"),
2681        }),
2682    }
2683    if findings.is_empty() {
2684        Section::Clean
2685    } else {
2686        Section::Findings(findings)
2687    }
2688}
2689
2690fn semantic_section(connection: &Connection) -> Section {
2691    match load_default_profile(connection) {
2692        Ok(_) => Section::Clean,
2693        Err(rusqlite::Error::QueryReturnedNoRows) => Section::Findings(vec![Finding {
2694            code: "E_CORRUPT_EMBEDDER_IDENTITY",
2695            stage: "EmbedderIdentity",
2696            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
2697            doc_anchor: "design/recovery.md#embedder-identity-drift",
2698            detail: "default embedder profile row is missing".to_string(),
2699        }]),
2700        Err(err) => Section::Findings(vec![Finding {
2701            code: "E_CORRUPT_EMBEDDER_IDENTITY",
2702            stage: "EmbedderIdentity",
2703            locator: locator_from_rusqlite_error(&err),
2704            doc_anchor: "design/recovery.md#embedder-identity-drift",
2705            detail: format!("default embedder profile probe failed: {err}"),
2706        }]),
2707    }
2708}
2709
2710fn collect_integrity_check_findings(connection: &Connection) -> rusqlite::Result<Vec<Finding>> {
2711    let mut statement = connection.prepare("PRAGMA integrity_check")?;
2712    let rows = statement.query_map([], |row| row.get::<_, String>(0))?;
2713    let mut findings = Vec::new();
2714    for row in rows {
2715        let message = row?;
2716        if message == "ok" {
2717            continue;
2718        }
2719        findings.push(Finding {
2720            code: "E_CORRUPT_INTEGRITY_CHECK",
2721            stage: "IntegrityCheck",
2722            locator: CorruptionLocator::OpaqueSqliteError {
2723                sqlite_extended_code: rusqlite::ffi::SQLITE_CORRUPT,
2724            },
2725            doc_anchor: "design/recovery.md#integrity-check-full-findings",
2726            detail: message,
2727        });
2728    }
2729    Ok(findings)
2730}
2731
2732fn locator_from_rusqlite_error(err: &rusqlite::Error) -> CorruptionLocator {
2733    let extended = err.sqlite_error().map(|inner| inner.extended_code).unwrap_or(0);
2734    CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: extended }
2735}
2736
2737fn open_runtime_connection(path: &Path) -> rusqlite::Result<Connection> {
2738    let connection = Connection::open(path)?;
2739    connection.pragma_update(None, "journal_mode", "WAL")?;
2740    Ok(connection)
2741}
2742
2743fn load_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
2744    connection
2745        .query_row(
2746            "SELECT value FROM _fathomdb_open_state WHERE key = ?1",
2747            [PROJECTION_CURSOR_KEY],
2748            |row| row.get::<_, String>(0),
2749        )
2750        .map(|value| value.parse::<u64>().unwrap_or(0))
2751        .or_else(|err| match err {
2752            rusqlite::Error::QueryReturnedNoRows => Ok(0),
2753            _ => Err(err),
2754        })
2755}
2756
2757fn store_projection_cursor(connection: &Connection, cursor: u64) -> rusqlite::Result<()> {
2758    connection.execute(
2759        "INSERT INTO _fathomdb_open_state(key, value) VALUES(?1, ?2)
2760         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2761        params![PROJECTION_CURSOR_KEY, cursor.to_string()],
2762    )?;
2763    Ok(())
2764}
2765
2766fn record_projection_terminal(
2767    connection: &Connection,
2768    cursor: u64,
2769    state: &str,
2770) -> rusqlite::Result<()> {
2771    connection.execute(
2772        "INSERT OR IGNORE INTO _fathomdb_projection_terminal(write_cursor, state) VALUES(?1, ?2)",
2773        params![cursor, state],
2774    )?;
2775    Ok(())
2776}
2777
2778fn terminal_state_for_cursor(
2779    connection: &Connection,
2780    cursor: u64,
2781) -> rusqlite::Result<Option<String>> {
2782    connection
2783        .query_row(
2784            "SELECT state FROM _fathomdb_projection_terminal WHERE write_cursor = ?1",
2785            [cursor],
2786            |row| row.get::<_, String>(0),
2787        )
2788        .map(Some)
2789        .or_else(|err| match err {
2790            rusqlite::Error::QueryReturnedNoRows => Ok(None),
2791            _ => Err(err),
2792        })
2793}
2794
2795fn advance_projection_cursor(connection: &Connection) -> rusqlite::Result<u64> {
2796    let mut cursor = load_projection_cursor(connection)?;
2797    loop {
2798        let next = cursor.saturating_add(1);
2799        if terminal_state_for_cursor(connection, next)?.is_some() {
2800            cursor = next;
2801        } else {
2802            break;
2803        }
2804    }
2805    store_projection_cursor(connection, cursor)?;
2806    Ok(cursor)
2807}
2808
2809fn commit_projection_outcomes(
2810    connection: &mut Connection,
2811    outcomes: &[ProjectionOutcome],
2812) -> rusqlite::Result<()> {
2813    let tx = connection.transaction()?;
2814    for outcome in outcomes {
2815        match outcome {
2816            ProjectionOutcome::Success { cursor, kind, blob } => {
2817                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
2818                    continue;
2819                }
2820                tx.execute(
2821                    "INSERT OR IGNORE INTO _fathomdb_vector_rows(rowid, kind, write_cursor) VALUES(?1, ?2, ?3)",
2822                    params![cursor, kind, cursor],
2823                )?;
2824                tx.execute(
2825                    "INSERT OR IGNORE INTO vector_default(rowid, embedding) VALUES(?1, ?2)",
2826                    params![cursor, blob],
2827                )?;
2828                record_projection_terminal(&tx, *cursor, "up_to_date")?;
2829            }
2830            ProjectionOutcome::Failure { cursor, failure_code } => {
2831                if terminal_state_for_cursor(&tx, *cursor)?.is_some() {
2832                    continue;
2833                }
2834                let existing: u64 = tx.query_row(
2835                    "SELECT COUNT(*) FROM operational_mutations
2836                     WHERE collection_name = 'projection_failures'
2837                       AND json_extract(payload_json, '$.write_cursor') = ?1",
2838                    [cursor],
2839                    |row| row.get(0),
2840                )?;
2841                if existing == 0 {
2842                    let payload = format!(
2843                        r#"{{"write_cursor":{cursor},"failure_code":"{failure_code}","recorded_at":0}}"#
2844                    );
2845                    tx.execute(
2846                        "INSERT INTO operational_mutations(
2847                            collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
2848                         ) VALUES('projection_failures', ?1, 'append', ?2, NULL, ?3)",
2849                        params![cursor.to_string(), payload, cursor],
2850                    )?;
2851                }
2852                record_projection_terminal(&tx, *cursor, "failed")?;
2853            }
2854        }
2855    }
2856    advance_projection_cursor(&tx)?;
2857    tx.commit()
2858}
2859
2860fn enforce_provenance_retention(connection: &Connection, cap: u64) -> rusqlite::Result<()> {
2861    if cap == 0 {
2862        return Ok(());
2863    }
2864    let slack = cap.max(20) / 20;
2865    let upper = cap.saturating_add(slack.max(1));
2866    let count: u64 =
2867        connection.query_row("SELECT COUNT(*) FROM operational_mutations", [], |row| row.get(0))?;
2868    if count <= upper {
2869        return Ok(());
2870    }
2871    let to_delete = count.saturating_sub(cap);
2872    connection.execute(
2873        "DELETE FROM operational_mutations
2874         WHERE id IN (
2875             SELECT id FROM operational_mutations
2876             ORDER BY id
2877             LIMIT ?1
2878         )",
2879        [to_delete],
2880    )?;
2881    Ok(())
2882}
2883
2884fn projection_status(
2885    connection: &Connection,
2886    kind: &str,
2887) -> Result<lifecycle::ProjectionStatus, EngineError> {
2888    let latest = connection
2889        .query_row(
2890            "SELECT COALESCE(MAX(write_cursor), 0) FROM canonical_nodes WHERE kind = ?1",
2891            [kind],
2892            |row| row.get::<_, u64>(0),
2893        )
2894        .map_err(|_| EngineError::Storage)?;
2895    if latest == 0 {
2896        return Ok(lifecycle::ProjectionStatus::UpToDate);
2897    }
2898    let pending: u64 = connection
2899        .query_row(
2900            "SELECT COUNT(*)
2901             FROM canonical_nodes
2902             LEFT JOIN _fathomdb_projection_terminal
2903               ON _fathomdb_projection_terminal.write_cursor = canonical_nodes.write_cursor
2904             WHERE canonical_nodes.kind = ?1
2905               AND _fathomdb_projection_terminal.write_cursor IS NULL",
2906            [kind],
2907            |row| row.get(0),
2908        )
2909        .map_err(|_| EngineError::Storage)?;
2910    if pending > 0 {
2911        return Ok(lifecycle::ProjectionStatus::Pending);
2912    }
2913    match terminal_state_for_cursor(connection, latest).map_err(|_| EngineError::Storage)? {
2914        Some(state) if state == "failed" => Ok(lifecycle::ProjectionStatus::Failed),
2915        _ => Ok(lifecycle::ProjectionStatus::UpToDate),
2916    }
2917}
2918
2919fn canonical_database_path(path: &Path) -> Result<PathBuf, EngineOpenError> {
2920    let parent = path
2921        .parent()
2922        .filter(|parent| !parent.as_os_str().is_empty())
2923        .unwrap_or_else(|| Path::new("."));
2924    let canonical_parent = parent.canonicalize().map_err(|_| EngineOpenError::Io {
2925        message: "database parent directory is not accessible".to_string(),
2926    })?;
2927    let file_name = path.file_name().ok_or_else(|| EngineOpenError::Io {
2928        message: "database path has no file name".to_string(),
2929    })?;
2930
2931    Ok(canonical_parent.join(file_name))
2932}
2933
2934fn acquire_lock(path: &Path) -> Result<File, EngineOpenError> {
2935    let lock_path = lock_path(path);
2936    let mut options = OpenOptions::new();
2937    options.read(true).write(true).create(true);
2938    #[cfg(unix)]
2939    options.mode(0o600);
2940
2941    let mut file = options.open(&lock_path).map_err(|_| EngineOpenError::Io {
2942        message: "could not open database lock file".to_string(),
2943    })?;
2944
2945    match file.try_lock() {
2946        Ok(()) => {
2947            let pid = std::process::id().to_string();
2948            let _ = file.set_len(0);
2949            let _ = file.seek(SeekFrom::Start(0));
2950            let _ = file.write_all(pid.as_bytes());
2951            Ok(file)
2952        }
2953        Err(std::fs::TryLockError::WouldBlock) => {
2954            Err(EngineOpenError::DatabaseLocked { holder_pid: read_holder_pid(&lock_path) })
2955        }
2956        Err(_) => {
2957            Err(EngineOpenError::Io { message: "could not acquire database lock".to_string() })
2958        }
2959    }
2960}
2961
2962fn lock_path(path: &Path) -> PathBuf {
2963    let mut lock_path = path.as_os_str().to_os_string();
2964    lock_path.push(LOCK_SUFFIX);
2965    PathBuf::from(lock_path)
2966}
2967
2968fn read_holder_pid(path: &Path) -> Option<u32> {
2969    std::fs::read_to_string(path).ok()?.trim().parse().ok()
2970}
2971
2972fn map_migration_error(err: SchemaMigrationError) -> EngineOpenError {
2973    match err {
2974        SchemaMigrationError::IncompatibleSchemaVersion { seen, supported } => {
2975            EngineOpenError::IncompatibleSchemaVersion { seen, supported }
2976        }
2977        SchemaMigrationError::MigrationError(report) => EngineOpenError::MigrationError {
2978            schema_version_before: report.schema_version_before,
2979            schema_version_current: report.schema_version_current,
2980            step_id: report.migration_steps.last().map_or(0, |step| step.step_id),
2981        },
2982        SchemaMigrationError::Storage { message } => {
2983            EngineOpenError::Io { message: message.to_string() }
2984        }
2985    }
2986}
2987
2988fn register_sqlite_vec_extension() {
2989    static REGISTER: Once = Once::new();
2990    REGISTER.call_once(|| unsafe {
2991        let entrypoint: unsafe extern "C" fn(
2992            *mut rusqlite::ffi::sqlite3,
2993            *mut *const std::os::raw::c_char,
2994            *const rusqlite::ffi::sqlite3_api_routines,
2995        ) -> std::os::raw::c_int = std::mem::transmute(sqlite3_vec_init as *const ());
2996        rusqlite::ffi::sqlite3_auto_extension(Some(entrypoint));
2997    });
2998}
2999
3000fn probe_open_integrity(connection: &Connection) -> Result<(), EngineOpenError> {
3001    // `SELECT COUNT(*) FROM sqlite_schema` forces a full traversal of the
3002    // sqlite_schema b-tree; this surfaces page-1 b-tree corruption that a
3003    // bare `PRAGMA schema_version` (which only reads the schema cookie
3004    // out of the file header) would miss.
3005    connection
3006        .query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get::<_, i64>(0))
3007        .map(|_| ())
3008        .map_err(|err| map_open_sqlite_error(err, OpenStage::SchemaProbe))
3009}
3010
3011fn probe_database_header(connection: &Connection) -> Result<(), EngineOpenError> {
3012    connection
3013        .query_row("PRAGMA application_id", [], |row| row.get::<_, i64>(0))
3014        .map(|_| ())
3015        .map_err(|err| map_open_sqlite_error(err, OpenStage::HeaderProbe))
3016}
3017
3018/// Pre-`pragma WAL` sidecar validation. SQLite silently discards a WAL
3019/// file whose header magic is wrong or whose advertised page size is
3020/// outside `[512, SQLITE_MAX_PAGE_SIZE]`, which would cause us to lose
3021/// committed frames at open time. AC-035a requires that we instead
3022/// refuse to open with `Corruption(WalReplayFailure)` rather than
3023/// silently rebuild from a truncated WAL.
3024fn probe_wal_sidecar(db_path: &Path) -> Result<(), EngineOpenError> {
3025    let mut wal_path = db_path.as_os_str().to_owned();
3026    wal_path.push("-wal");
3027    let wal_path = PathBuf::from(wal_path);
3028    // Bounded read: the WAL header is fixed-layout in the first 32
3029    // bytes (magic + format + page-size + checkpoint-seq + salts +
3030    // checksums); frame data starts at offset 32 and is irrelevant to
3031    // the magic + page-size pre-check. A `std::fs::read` of the whole
3032    // sidecar would force an unclean-shutdown open path to allocate
3033    // and copy the entire WAL into memory before SQLite touches
3034    // recovery — a real latency + RSS regression on AC-035.
3035    use std::io::Read;
3036    let mut file = match std::fs::File::open(&wal_path) {
3037        Ok(file) => file,
3038        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3039        Err(_) => return Ok(()),
3040    };
3041    let mut bytes = [0u8; 32];
3042    if file.read_exact(&mut bytes).is_err() {
3043        // A short (< 32-byte) sidecar carries no committed frames;
3044        // SQLite treats it as empty and re-initializes WAL state.
3045        return Ok(());
3046    }
3047    let magic = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
3048    let page_size = u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]);
3049    // WAL_MAGIC mask per SQLite `walIndexRecover`: low bit distinguishes
3050    // big-endian vs little-endian checksum encoding; the rest of the
3051    // magic is fixed.
3052    const WAL_MAGIC_MASK: u32 = 0xFFFF_FFFE;
3053    const WAL_MAGIC: u32 = 0x377F_0682;
3054    const SQLITE_MAX_PAGE_SIZE: u32 = 65536;
3055    let magic_ok = (magic & WAL_MAGIC_MASK) == WAL_MAGIC;
3056    let page_size_ok =
3057        page_size.is_power_of_two() && (512..=SQLITE_MAX_PAGE_SIZE).contains(&page_size);
3058    if magic_ok && page_size_ok {
3059        return Ok(());
3060    }
3061    Err(EngineOpenError::Corruption(CorruptionDetail {
3062        kind: CorruptionKind::WalReplayFailure,
3063        stage: OpenStage::WalReplay,
3064        locator: CorruptionLocator::FileOffset { offset: if !magic_ok { 0 } else { 8 } },
3065        recovery_hint: RecoveryHint {
3066            code: "E_CORRUPT_WAL_REPLAY",
3067            doc_anchor: "design/recovery.md#wal-replay-failures",
3068        },
3069    }))
3070}
3071
3072fn reject_legacy_shape(connection: &Connection) -> Result<(), EngineOpenError> {
3073    let has_legacy_table = table_exists(connection, "fathom_nodes")
3074        || table_exists(connection, "fathom_edges")
3075        || table_exists(connection, "fathom_chunks");
3076    if !has_legacy_table {
3077        return Ok(());
3078    }
3079
3080    let seen =
3081        connection.query_row("PRAGMA user_version", [], |row| row.get::<_, u32>(0)).unwrap_or(0);
3082    Err(EngineOpenError::IncompatibleSchemaVersion { seen, supported: SCHEMA_VERSION })
3083}
3084
3085fn table_exists(connection: &Connection, table: &str) -> bool {
3086    connection
3087        .query_row(
3088            "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?1",
3089            [table],
3090            |_row| Ok(()),
3091        )
3092        .is_ok()
3093}
3094
3095fn read_schema_objects(
3096    connection: &Connection,
3097    obj_type: &str,
3098) -> Result<Vec<SchemaObject>, EngineError> {
3099    let mut stmt = connection
3100        .prepare(
3101            "SELECT name, sql FROM sqlite_schema
3102             WHERE type = ?1 AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL
3103             ORDER BY name",
3104        )
3105        .map_err(|_| EngineError::Storage)?;
3106    let rows = stmt
3107        .query_map([obj_type], |row| {
3108            Ok(SchemaObject { name: row.get::<_, String>(0)?, sql: row.get::<_, String>(1)? })
3109        })
3110        .map_err(|_| EngineError::Storage)?;
3111    let mut out = Vec::new();
3112    for row in rows {
3113        out.push(row.map_err(|_| EngineError::Storage)?);
3114    }
3115    Ok(out)
3116}
3117
3118fn order_canonical_first(mut objects: Vec<SchemaObject>) -> Vec<SchemaObject> {
3119    let mut canonical: Vec<SchemaObject> = Vec::new();
3120    for name in CANONICAL_TABLES {
3121        if let Some(pos) = objects.iter().position(|o| o.name == *name) {
3122            canonical.push(objects.remove(pos));
3123        }
3124    }
3125    canonical.extend(objects);
3126    canonical
3127}
3128
3129fn load_default_profile(connection: &Connection) -> rusqlite::Result<EmbedderIdentity> {
3130    connection.query_row(
3131        "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = ?1",
3132        [DEFAULT_VECTOR_PROFILE],
3133        |row| {
3134            Ok(EmbedderIdentity::new(
3135                row.get::<_, String>(0)?,
3136                row.get::<_, String>(1)?,
3137                row.get::<_, u32>(2)?,
3138            ))
3139        },
3140    )
3141}
3142
3143fn default_profile_dimension(connection: &Connection) -> Result<u32, EngineError> {
3144    load_default_profile(connection)
3145        .map(|identity| identity.dimension)
3146        .map_err(|_| EngineError::Storage)
3147}
3148
3149fn kind_is_vector_indexed(connection: &Connection, kind: &str) -> Result<bool, EngineError> {
3150    connection
3151        .query_row("SELECT 1 FROM _fathomdb_vector_kinds WHERE kind = ?1", [kind], |_row| Ok(()))
3152        .map(|_| true)
3153        .or_else(|err| match err {
3154            rusqlite::Error::QueryReturnedNoRows => Ok(false),
3155            _ => Err(EngineError::Storage),
3156        })
3157}
3158
3159fn ensure_vector_partition(connection: &Connection, dimension: u32) -> rusqlite::Result<()> {
3160    let sql = format!(
3161        "CREATE VIRTUAL TABLE IF NOT EXISTS {DEFAULT_VECTOR_PARTITION} USING vec0(embedding float[{dimension}])"
3162    );
3163    connection.execute_batch(&sql)
3164}
3165
3166fn encode_vector_blob(vector: &[f32]) -> Vec<u8> {
3167    vector.iter().flat_map(|value| value.to_le_bytes()).collect()
3168}
3169
3170fn map_runtime_embedder_error(err: RuntimeEmbedderError) -> EngineError {
3171    match err {
3172        RuntimeEmbedderError::Failed { .. } | RuntimeEmbedderError::Timeout => {
3173            EngineError::Embedder
3174        }
3175    }
3176}
3177
3178fn default_embedder_identity() -> EmbedderIdentity {
3179    EmbedderIdentity::new(
3180        DEFAULT_EMBEDDER_NAME,
3181        DEFAULT_EMBEDDER_REVISION,
3182        DEFAULT_EMBEDDER_DIMENSION,
3183    )
3184}
3185
3186fn check_embedder_profile(
3187    connection: &Connection,
3188    supplied: &EmbedderIdentity,
3189) -> Result<(), EngineOpenError> {
3190    let mut statement = match connection.prepare(
3191        "SELECT name, revision, dimension FROM _fathomdb_embedder_profiles WHERE profile = 'default'",
3192    ) {
3193        Ok(statement) => statement,
3194        Err(_) => return Ok(()),
3195    };
3196    let mut rows = statement.query([]).map_err(|_| {
3197        EngineOpenError::Corruption(CorruptionDetail {
3198            kind: CorruptionKind::EmbedderIdentityDrift,
3199            stage: OpenStage::EmbedderIdentity,
3200            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
3201            recovery_hint: RecoveryHint {
3202                code: "E_CORRUPT_EMBEDDER_IDENTITY",
3203                doc_anchor: "design/recovery.md#embedder-identity-drift",
3204            },
3205        })
3206    })?;
3207
3208    let Some(row) = rows.next().map_err(|_| {
3209        EngineOpenError::Corruption(CorruptionDetail {
3210            kind: CorruptionKind::EmbedderIdentityDrift,
3211            stage: OpenStage::EmbedderIdentity,
3212            locator: CorruptionLocator::OpaqueSqliteError { sqlite_extended_code: 0 },
3213            recovery_hint: RecoveryHint {
3214                code: "E_CORRUPT_EMBEDDER_IDENTITY",
3215                doc_anchor: "design/recovery.md#embedder-identity-drift",
3216            },
3217        })
3218    })?
3219    else {
3220        connection
3221            .execute(
3222                "INSERT INTO _fathomdb_embedder_profiles(profile, name, revision, dimension)
3223                 VALUES(?1, ?2, ?3, ?4)",
3224                params![
3225                    DEFAULT_VECTOR_PROFILE,
3226                    supplied.name,
3227                    supplied.revision,
3228                    supplied.dimension
3229                ],
3230            )
3231            .map_err(|_| EngineOpenError::Io {
3232                message: "could not persist embedder profile".to_string(),
3233            })?;
3234        return Ok(());
3235    };
3236
3237    let stored_name = row.get::<_, String>(0).map_err(|_| {
3238        EngineOpenError::Corruption(CorruptionDetail {
3239            kind: CorruptionKind::EmbedderIdentityDrift,
3240            stage: OpenStage::EmbedderIdentity,
3241            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
3242            recovery_hint: RecoveryHint {
3243                code: "E_CORRUPT_EMBEDDER_IDENTITY",
3244                doc_anchor: "design/recovery.md#embedder-identity-drift",
3245            },
3246        })
3247    })?;
3248    let stored_revision = row.get::<_, String>(1).map_err(|_| {
3249        EngineOpenError::Corruption(CorruptionDetail {
3250            kind: CorruptionKind::EmbedderIdentityDrift,
3251            stage: OpenStage::EmbedderIdentity,
3252            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
3253            recovery_hint: RecoveryHint {
3254                code: "E_CORRUPT_EMBEDDER_IDENTITY",
3255                doc_anchor: "design/recovery.md#embedder-identity-drift",
3256            },
3257        })
3258    })?;
3259    let dimension = row.get::<_, u32>(2).map_err(|_| {
3260        EngineOpenError::Corruption(CorruptionDetail {
3261            kind: CorruptionKind::EmbedderIdentityDrift,
3262            stage: OpenStage::EmbedderIdentity,
3263            locator: CorruptionLocator::TableRow { table: "_fathomdb_embedder_profiles", rowid: 0 },
3264            recovery_hint: RecoveryHint {
3265                code: "E_CORRUPT_EMBEDDER_IDENTITY",
3266                doc_anchor: "design/recovery.md#embedder-identity-drift",
3267            },
3268        })
3269    })?;
3270
3271    let stored = EmbedderIdentity::new(stored_name, stored_revision, dimension);
3272
3273    if stored.name != supplied.name || stored.revision != supplied.revision {
3274        return Err(EngineOpenError::EmbedderIdentityMismatch {
3275            stored,
3276            supplied: supplied.clone(),
3277        });
3278    }
3279    if dimension != supplied.dimension {
3280        return Err(EngineOpenError::EmbedderDimensionMismatch {
3281            stored: dimension,
3282            supplied: supplied.dimension,
3283        });
3284    }
3285
3286    Ok(())
3287}
3288
3289#[derive(Clone, Debug, Eq, PartialEq)]
3290enum WritePlan {
3291    Node,
3292    Edge,
3293    AppendOnlyLog,
3294    LatestState,
3295    AdminSchema,
3296}
3297
3298fn validate_batch(
3299    connection: &Connection,
3300    batch: &[PreparedWrite],
3301) -> Result<Vec<WritePlan>, EngineError> {
3302    batch.iter().map(|write| validate_write(connection, write)).collect()
3303}
3304
3305fn collect_projection_jobs(
3306    connection: &Connection,
3307    batch: &[PreparedWrite],
3308) -> Result<Vec<ProjectionJob>, EngineError> {
3309    let mut jobs = Vec::new();
3310    for write in batch {
3311        if let PreparedWrite::Node { kind, body, .. } = write {
3312            if kind_is_vector_indexed(connection, kind)? {
3313                jobs.push(ProjectionJob { cursor: 0, kind: kind.clone(), body: body.clone() });
3314            }
3315        }
3316    }
3317    Ok(jobs)
3318}
3319
3320fn validate_write(
3321    connection: &Connection,
3322    write: &PreparedWrite,
3323) -> Result<WritePlan, EngineError> {
3324    match write {
3325        PreparedWrite::Node { kind, body, source_id } => {
3326            if kind.trim().is_empty() || body.trim().is_empty() {
3327                return Err(EngineError::WriteValidation);
3328            }
3329            if let Some(source_id) = source_id {
3330                if source_id.is_empty() {
3331                    return Err(EngineError::WriteValidation);
3332                }
3333            }
3334            Ok(WritePlan::Node)
3335        }
3336        PreparedWrite::Edge { kind, from, to, source_id } => {
3337            if kind.trim().is_empty() || from.trim().is_empty() || to.trim().is_empty() {
3338                return Err(EngineError::WriteValidation);
3339            }
3340            if let Some(source_id) = source_id {
3341                if source_id.is_empty() {
3342                    return Err(EngineError::WriteValidation);
3343                }
3344            }
3345            Ok(WritePlan::Edge)
3346        }
3347        PreparedWrite::AdminSchema { name, kind, schema_json, retention_json } => {
3348            if name.trim().is_empty()
3349                || !matches!(kind.as_str(), "append_only_log" | "latest_state")
3350                || serde_json::from_str::<Value>(schema_json).is_err()
3351                || serde_json::from_str::<Value>(retention_json).is_err()
3352                || contains_external_ref(schema_json)
3353            {
3354                return Err(EngineError::SchemaValidation);
3355            }
3356            Ok(WritePlan::AdminSchema)
3357        }
3358        PreparedWrite::OpStore { collection, record_key, schema_id, body } => {
3359            if collection.trim().is_empty() || record_key.trim().is_empty() {
3360                return Err(EngineError::WriteValidation);
3361            }
3362            let (kind, schema_json) = collection_metadata(connection, collection)?;
3363            if let Some(schema_id) = schema_id {
3364                if schema_id != collection {
3365                    return Err(EngineError::SchemaValidation);
3366                }
3367                validate_payload(&schema_json, body)?;
3368            } else if serde_json::from_str::<Value>(body).is_err() {
3369                return Err(EngineError::SchemaValidation);
3370            }
3371
3372            match kind.as_str() {
3373                "append_only_log" => Ok(WritePlan::AppendOnlyLog),
3374                "latest_state" => Ok(WritePlan::LatestState),
3375                _ => Err(EngineError::OpStore),
3376            }
3377        }
3378    }
3379}
3380
3381fn collection_metadata(
3382    connection: &Connection,
3383    collection: &str,
3384) -> Result<(String, String), EngineError> {
3385    connection
3386        .query_row(
3387            "SELECT kind, schema_json FROM operational_collections WHERE name = ?1",
3388            [collection],
3389            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
3390        )
3391        .map_err(|_| EngineError::OpStore)
3392}
3393
3394fn validate_payload(schema_json: &str, body: &str) -> Result<(), EngineError> {
3395    let schema =
3396        serde_json::from_str::<Value>(schema_json).map_err(|_| EngineError::SchemaValidation)?;
3397    let payload = serde_json::from_str::<Value>(body).map_err(|_| EngineError::SchemaValidation)?;
3398
3399    let compiled = JSONSchema::compile(&schema).map_err(|_| EngineError::SchemaValidation)?;
3400    compiled.validate(&payload).map_err(|_| EngineError::SchemaValidation)?;
3401
3402    Ok(())
3403}
3404
3405fn contains_external_ref(schema_json: &str) -> bool {
3406    let Ok(value) = serde_json::from_str::<Value>(schema_json) else {
3407        return false;
3408    };
3409    value_contains_external_ref(&value)
3410}
3411
3412fn value_contains_external_ref(value: &Value) -> bool {
3413    match value {
3414        Value::Object(object) => object.iter().any(|(key, value)| {
3415            if key == "$ref" {
3416                return value.as_str().is_some_and(|uri| !uri.starts_with('#'));
3417            }
3418            value_contains_external_ref(value)
3419        }),
3420        Value::Array(values) => values.iter().any(value_contains_external_ref),
3421        _ => false,
3422    }
3423}
3424
3425fn commit_batch(
3426    connection: &mut Connection,
3427    batch: &[PreparedWrite],
3428    plans: &[WritePlan],
3429    cursor: u64,
3430    pending_projection: bool,
3431    provenance_row_cap: u64,
3432) -> rusqlite::Result<()> {
3433    let tx = connection.transaction()?;
3434
3435    for (write, plan) in batch.iter().zip(plans) {
3436        match (write, plan) {
3437            (PreparedWrite::Node { kind, body, source_id }, WritePlan::Node) => {
3438                tx.execute(
3439                    "INSERT INTO canonical_nodes(write_cursor, kind, body, source_id)
3440                     VALUES(?1, ?2, ?3, ?4)",
3441                    params![cursor, kind, body, source_id],
3442                )?;
3443                tx.execute(
3444                    "INSERT INTO search_index(body, kind, write_cursor) VALUES(?1, ?2, ?3)",
3445                    params![body, kind, cursor],
3446                )?;
3447                if kind_is_vector_indexed(&tx, kind).unwrap_or(false) {
3448                    tx.execute(
3449                        "INSERT INTO _fathomdb_projection_state(kind, last_enqueued_cursor, updated_at)
3450                         VALUES(?1, ?2, 0)
3451                         ON CONFLICT(kind) DO UPDATE SET last_enqueued_cursor = excluded.last_enqueued_cursor",
3452                        params![kind, cursor],
3453                    )?;
3454                }
3455            }
3456            (PreparedWrite::Edge { kind, from, to, source_id }, WritePlan::Edge) => {
3457                tx.execute(
3458                    "INSERT INTO canonical_edges(write_cursor, kind, from_id, to_id, source_id)
3459                     VALUES(?1, ?2, ?3, ?4, ?5)",
3460                    params![cursor, kind, from, to, source_id],
3461                )?;
3462            }
3463            (
3464                PreparedWrite::AdminSchema { name, kind, schema_json, retention_json },
3465                WritePlan::AdminSchema,
3466            ) => {
3467                tx.execute(
3468                    "INSERT INTO operational_collections(
3469                        name, kind, schema_json, retention_json, format_version, created_at
3470                     ) VALUES(?1, ?2, ?3, ?4, 1, 0)
3471                     ON CONFLICT(name) DO UPDATE SET
3472                        schema_json = excluded.schema_json,
3473                        retention_json = excluded.retention_json",
3474                    params![name, kind, schema_json, retention_json],
3475                )?;
3476            }
3477            (
3478                PreparedWrite::OpStore { collection, record_key, schema_id, body },
3479                WritePlan::AppendOnlyLog,
3480            ) => {
3481                tx.execute(
3482                    "INSERT INTO operational_mutations(
3483                        collection_name, record_key, op_kind, payload_json, schema_id, write_cursor
3484                     ) VALUES(?1, ?2, 'append', ?3, ?4, ?5)",
3485                    params![collection, record_key, body, schema_id, cursor],
3486                )?;
3487            }
3488            (
3489                PreparedWrite::OpStore { collection, record_key, schema_id, body },
3490                WritePlan::LatestState,
3491            ) => {
3492                tx.execute(
3493                    "INSERT INTO operational_state(
3494                        collection_name, record_key, payload_json, schema_id, write_cursor
3495                     ) VALUES(?1, ?2, ?3, ?4, ?5)
3496                     ON CONFLICT(collection_name, record_key) DO UPDATE SET
3497                        payload_json = excluded.payload_json,
3498                        schema_id = excluded.schema_id,
3499                        write_cursor = excluded.write_cursor",
3500                    params![collection, record_key, body, schema_id, cursor],
3501                )?;
3502            }
3503            _ => return Err(rusqlite::Error::InvalidQuery),
3504        }
3505    }
3506
3507    if !pending_projection {
3508        record_projection_terminal(&tx, cursor, "up_to_date")?;
3509    }
3510    enforce_provenance_retention(&tx, provenance_row_cap)?;
3511    advance_projection_cursor(&tx)?;
3512
3513    tx.commit()
3514}
3515
3516fn load_next_cursor(connection: &Connection) -> u64 {
3517    let nodes = max_cursor(connection, "canonical_nodes").unwrap_or(0);
3518    let edges = max_cursor(connection, "canonical_edges").unwrap_or(0);
3519    let mutations = max_cursor(connection, "operational_mutations").unwrap_or(0);
3520    let state = max_cursor(connection, "operational_state").unwrap_or(0);
3521    nodes.max(edges).max(mutations).max(state)
3522}
3523
3524fn max_cursor(connection: &Connection, table: &str) -> rusqlite::Result<u64> {
3525    let sql = format!("SELECT COALESCE(MAX(write_cursor), 0) FROM {table}");
3526    connection.query_row(&sql, [], |row| row.get::<_, u64>(0))
3527}
3528
3529/// Map a rusqlite error to its stable SQLite extended-code name.
3530///
3531/// Returns `None` for non-`SqliteFailure` variants (e.g. JSON conversion
3532/// failures, type mismatches at the rusqlite layer) — those are not
3533/// SQLite-internal events and should not be surfaced under
3534/// `EventSource::SqliteInternal`. The names returned here are the
3535/// canonical `SQLITE_*` symbol names from `sqlite3.h` and are stable
3536/// dispatch keys for AC-021 / AC-006 binding adapters.
3537///
3538/// Only the subset of codes the engine can reach in 0.6.0 is enumerated
3539/// — bare-extended-code matching covers the rest with a stable
3540/// `"SQLITE_UNKNOWN"` fallback so subscribers always see a typed code.
3541///
3542/// Diagnostic completeness for unmapped codes: when this helper returns
3543/// `"SQLITE_UNKNOWN"`, the numeric extended code is not lost — it
3544/// remains on the underlying `rusqlite::Error::SqliteFailure` carried
3545/// in the engine error chain that subscribers can inspect via
3546/// `EngineError`'s `source()`. Expanding the enumerated subset (or
3547/// surfacing the numeric code as a typed payload field) is a 0.7+
3548/// improvement.
3549fn sqlite_extended_code_name(err: &rusqlite::Error) -> Option<&'static str> {
3550    let sqlite_error = err.sqlite_error()?;
3551    let extended = sqlite_error.extended_code;
3552    Some(match extended {
3553        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
3554        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
3555        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
3556        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
3557        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
3558        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
3559        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
3560        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
3561        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
3562        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
3563        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
3564        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
3565        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
3566        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
3567        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
3568        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
3569        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
3570        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
3571        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
3572        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
3573        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
3574        _ => "SQLITE_UNKNOWN",
3575    })
3576}
3577
3578fn sqlite_extended_code_name_from_int(extended: i32) -> &'static str {
3579    match extended {
3580        rusqlite::ffi::SQLITE_SCHEMA => "SQLITE_SCHEMA",
3581        rusqlite::ffi::SQLITE_BUSY => "SQLITE_BUSY",
3582        rusqlite::ffi::SQLITE_LOCKED => "SQLITE_LOCKED",
3583        rusqlite::ffi::SQLITE_CORRUPT => "SQLITE_CORRUPT",
3584        rusqlite::ffi::SQLITE_NOTADB => "SQLITE_NOTADB",
3585        rusqlite::ffi::SQLITE_IOERR => "SQLITE_IOERR",
3586        rusqlite::ffi::SQLITE_FULL => "SQLITE_FULL",
3587        rusqlite::ffi::SQLITE_READONLY => "SQLITE_READONLY",
3588        rusqlite::ffi::SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
3589        rusqlite::ffi::SQLITE_MISUSE => "SQLITE_MISUSE",
3590        rusqlite::ffi::SQLITE_INTERRUPT => "SQLITE_INTERRUPT",
3591        rusqlite::ffi::SQLITE_NOMEM => "SQLITE_NOMEM",
3592        rusqlite::ffi::SQLITE_PERM => "SQLITE_PERM",
3593        rusqlite::ffi::SQLITE_ABORT => "SQLITE_ABORT",
3594        rusqlite::ffi::SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
3595        rusqlite::ffi::SQLITE_RANGE => "SQLITE_RANGE",
3596        rusqlite::ffi::SQLITE_TOOBIG => "SQLITE_TOOBIG",
3597        rusqlite::ffi::SQLITE_MISMATCH => "SQLITE_MISMATCH",
3598        rusqlite::ffi::SQLITE_AUTH => "SQLITE_AUTH",
3599        rusqlite::ffi::SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
3600        rusqlite::ffi::SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
3601        _ => "SQLITE_UNKNOWN",
3602    }
3603}
3604
3605fn map_open_sqlite_error(err: rusqlite::Error, stage: OpenStage) -> EngineOpenError {
3606    let Some(sqlite_error) = err.sqlite_error() else {
3607        return EngineOpenError::Io { message: "could not open database".to_string() };
3608    };
3609    match sqlite_error.extended_code {
3610        rusqlite::ffi::SQLITE_CORRUPT | rusqlite::ffi::SQLITE_NOTADB => {
3611            EngineOpenError::Corruption(CorruptionDetail {
3612                kind: match stage {
3613                    OpenStage::WalReplay => CorruptionKind::WalReplayFailure,
3614                    OpenStage::HeaderProbe => CorruptionKind::HeaderMalformed,
3615                    OpenStage::SchemaProbe => CorruptionKind::SchemaInconsistent,
3616                    OpenStage::EmbedderIdentity => CorruptionKind::EmbedderIdentityDrift,
3617                },
3618                stage,
3619                locator: CorruptionLocator::OpaqueSqliteError {
3620                    sqlite_extended_code: sqlite_error.extended_code,
3621                },
3622                recovery_hint: RecoveryHint {
3623                    code: match stage {
3624                        OpenStage::WalReplay => "E_CORRUPT_WAL_REPLAY",
3625                        OpenStage::HeaderProbe => "E_CORRUPT_HEADER",
3626                        OpenStage::SchemaProbe => "E_CORRUPT_SCHEMA",
3627                        OpenStage::EmbedderIdentity => "E_CORRUPT_EMBEDDER_IDENTITY",
3628                    },
3629                    doc_anchor: match stage {
3630                        OpenStage::WalReplay => "design/recovery.md#wal-replay-failures",
3631                        OpenStage::HeaderProbe => "design/recovery.md#header-malformed",
3632                        OpenStage::SchemaProbe => "design/recovery.md#schema-inconsistent",
3633                        OpenStage::EmbedderIdentity => "design/recovery.md#embedder-identity-drift",
3634                    },
3635                },
3636            })
3637        }
3638        _ => EngineOpenError::Io { message: "could not open database".to_string() },
3639    }
3640}
3641
3642fn emit_open_error_event(subscriber: &Arc<dyn lifecycle::Subscriber>, err: &EngineOpenError) {
3643    if let EngineOpenError::Corruption(detail) = err {
3644        let code = match detail.locator {
3645            CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
3646                Some(sqlite_extended_code_name_from_int(sqlite_extended_code))
3647            }
3648            _ => None,
3649        };
3650        let event = lifecycle::Event {
3651            phase: lifecycle::Phase::Failed,
3652            source: lifecycle::EventSource::SqliteInternal,
3653            category: lifecycle::EventCategory::Corruption,
3654            code,
3655        };
3656        subscriber.on_event(&event);
3657    }
3658}
3659
3660/// Install a `sqlite3_profile` callback on `connection` that dispatches
3661/// per-statement profile records and slow-statement signals to the
3662/// engine's subscriber registry.
3663///
3664/// Why FFI rather than `rusqlite::Connection::profile`: the safe API
3665/// (rusqlite 0.31) accepts only a `fn(&str, Duration)` with no
3666/// environment, so it cannot carry a per-engine subscriber-registry
3667/// pointer. We use `sqlite3_profile` directly with a leaked-into-`Box`
3668/// context whose pointer is tied to the engine's lifetime via
3669/// `Engine::profile_contexts`.
3670///
3671/// `sqlite3_profile` is documented as deprecated in favor of
3672/// `sqlite3_trace_v2`, but it remains supported and is sufficient for
3673/// the wall-clock + SQL-text payload required by AC-005a/b.
3674#[allow(clippy::vec_box)]
3675fn install_profile_callback(
3676    connection: &Connection,
3677    subscribers: &Arc<lifecycle::SubscriberRegistry>,
3678    profiling_enabled: &Arc<AtomicBool>,
3679    slow_threshold_ms: &Arc<AtomicU64>,
3680    contexts: &mut Vec<Box<ProfileContext>>,
3681) {
3682    let mut ctx = Box::new(ProfileContext {
3683        subscribers: Arc::clone(subscribers),
3684        profiling_enabled: Arc::clone(profiling_enabled),
3685        slow_threshold_ms: Arc::clone(slow_threshold_ms),
3686    });
3687    let ctx_ptr: *mut ProfileContext = &mut *ctx;
3688
3689    // SAFETY: the Box outlives the connection. Rust drops struct fields
3690    // in declaration order. `connection` and `reader_pool` are declared
3691    // before `profile_contexts`. `ReaderWorkerPool::Drop` joins every
3692    // reader worker, and each worker uninstalls and drops its owned
3693    // connection inside `reader_worker_loop` before the worker thread
3694    // returns. Therefore all connections — and SQLite's internal
3695    // profile-callback state with them — are torn down before the
3696    // `Box<ProfileContext>` allocations are freed. `Engine::close`
3697    // additionally clears the callback via
3698    // `sqlite3_profile(handle, None, NULL)` before connection close to
3699    // drain any in-flight callback dispatch.
3700    unsafe {
3701        rusqlite::ffi::sqlite3_profile(
3702            connection.handle(),
3703            Some(profile_callback_trampoline),
3704            ctx_ptr.cast::<std::ffi::c_void>(),
3705        );
3706    }
3707    contexts.push(ctx);
3708}
3709
3710/// Uninstall the profile callback so SQLite stops calling into our
3711/// freed `Box<ProfileContext>` pointer once a connection is being torn
3712/// down. Call before dropping `profile_contexts`.
3713fn uninstall_profile_callback(connection: &Connection) {
3714    // SAFETY: passing `None` as the callback unregisters the previous
3715    // callback; SQLite documents this as legal and idempotent.
3716    unsafe {
3717        rusqlite::ffi::sqlite3_profile(connection.handle(), None, std::ptr::null_mut());
3718    }
3719}
3720
3721/// Pack 6.G G.1 — configure SQLite per-connection lookaside on a reader
3722/// worker connection. Must be called BEFORE any statement is prepared
3723/// or any PRAGMA is run on `connection`; per the SQLite docs
3724/// (https://www.sqlite.org/malloc.html §3) lookaside is silently
3725/// ignored if reconfigured after the first allocation on the
3726/// connection. Passing `NULL` for the buffer pointer lets SQLite
3727/// allocate the lookaside backing memory itself.
3728///
3729/// rusqlite 0.31's `set_db_config` only handles the boolean
3730/// `DbConfig::*` variants; `SQLITE_DBCONFIG_LOOKASIDE` is not surfaced
3731/// (it is commented out in `rusqlite/src/config.rs`), so we call the
3732/// raw FFI directly.
3733///
3734/// Returns the rc of `sqlite3_db_config` so callers can debug-assert
3735/// `SQLITE_OK` and surface configuration failure under
3736/// `debug_assertions` test builds without expanding the public surface.
3737fn configure_reader_lookaside(connection: &Connection) -> std::os::raw::c_int {
3738    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
3739    // the lifetime of `connection`. The variadic
3740    // `sqlite3_db_config(LOOKASIDE)` call expects three trailing
3741    // arguments of types `void*`, `int`, `int` — the prototype shape
3742    // documented in `sqlite3.h`. We pass a null buffer so SQLite owns
3743    // the lookaside backing allocation, and the slot size / count from
3744    // the G.1 constants. No allocations happen on the connection
3745    // before this call (reader open path is `Connection::open` ->
3746    // `configure_reader_lookaside` -> first PRAGMA).
3747    unsafe {
3748        rusqlite::ffi::sqlite3_db_config(
3749            connection.handle(),
3750            rusqlite::ffi::SQLITE_DBCONFIG_LOOKASIDE,
3751            std::ptr::null_mut::<std::ffi::c_void>(),
3752            READER_LOOKASIDE_SLOT_SIZE,
3753            READER_LOOKASIDE_SLOT_COUNT,
3754        )
3755    }
3756}
3757
3758/// Read the high-water-mark for `SQLITE_DBSTATUS_LOOKASIDE_USED` on
3759/// `connection`. The `current` out-param is the live checked-out slot
3760/// count and decays as transactions finalize, so it is unreliable as
3761/// post-warmup evidence. The `hiwtr` out-param latches the largest
3762/// observed `current` value since the last reset and is the right
3763/// signal that lookaside was honored at any point on this connection.
3764/// Reset flag is `0` so reading does not clear the high-water mark.
3765#[cfg(debug_assertions)]
3766fn read_lookaside_used_hiwtr(connection: &Connection) -> std::os::raw::c_int {
3767    let mut current: std::os::raw::c_int = 0;
3768    let mut hiwtr: std::os::raw::c_int = 0;
3769    // SAFETY: handle is valid; both out pointers are to local stack
3770    // ints; reset flag 0 is documented as legal.
3771    unsafe {
3772        rusqlite::ffi::sqlite3_db_status(
3773            connection.handle(),
3774            rusqlite::ffi::SQLITE_DBSTATUS_LOOKASIDE_USED,
3775            &mut current,
3776            &mut hiwtr,
3777            0,
3778        );
3779    }
3780    hiwtr
3781}
3782
3783/// Pack 6.G G.3.5 — read the three page-cache pressure counters on
3784/// `connection`: `SQLITE_DBSTATUS_CACHE_HIT`, `_CACHE_MISS`, and
3785/// `_CACHE_USED`. Returns `(hit, miss, used_bytes)`. Hit/miss are
3786/// monotonic counters (reset flag = 0 here); used_bytes is the live
3787/// page-cache memory footprint at call time. The caller is expected to
3788/// take pre/post snapshots and do delta arithmetic explicitly.
3789#[cfg(debug_assertions)]
3790fn read_cache_status(
3791    connection: &Connection,
3792) -> (std::os::raw::c_int, std::os::raw::c_int, std::os::raw::c_int) {
3793    let mut hit_current: std::os::raw::c_int = 0;
3794    let mut hit_hiwtr: std::os::raw::c_int = 0;
3795    let mut miss_current: std::os::raw::c_int = 0;
3796    let mut miss_hiwtr: std::os::raw::c_int = 0;
3797    let mut used_current: std::os::raw::c_int = 0;
3798    let mut used_hiwtr: std::os::raw::c_int = 0;
3799    // SAFETY: `connection.handle()` returns a valid `*mut sqlite3` for
3800    // the lifetime of `connection`. All out-pointers are to local stack
3801    // ints. Reset flag 0 is documented as legal (no counter is reset).
3802    unsafe {
3803        rusqlite::ffi::sqlite3_db_status(
3804            connection.handle(),
3805            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_HIT,
3806            &mut hit_current,
3807            &mut hit_hiwtr,
3808            0,
3809        );
3810        rusqlite::ffi::sqlite3_db_status(
3811            connection.handle(),
3812            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_MISS,
3813            &mut miss_current,
3814            &mut miss_hiwtr,
3815            0,
3816        );
3817        rusqlite::ffi::sqlite3_db_status(
3818            connection.handle(),
3819            rusqlite::ffi::SQLITE_DBSTATUS_CACHE_USED,
3820            &mut used_current,
3821            &mut used_hiwtr,
3822            0,
3823        );
3824    }
3825    // CACHE_HIT / CACHE_MISS are monotonic counters reported in the
3826    // `current` out-param; CACHE_USED is the live byte count, also in
3827    // `current`. The hiwtr values are unused for this telemetry.
3828    (hit_current, miss_current, used_current)
3829}
3830
3831/// FFI trampoline for `sqlite3_profile`.
3832///
3833/// Invoked by SQLite at statement-finish with the SQL text and the
3834/// statement's wall-clock cost in nanoseconds. We dispatch a
3835/// `ProfileRecord` (when profiling is enabled) and a `SlowStatement`
3836/// signal (when `wall_clock_ms` exceeds the configured slow threshold).
3837///
3838/// Per `dev/design/lifecycle.md` § Public record shape, the public
3839/// payload exposes `wall_clock_ms`, `step_count`, and `cache_delta`.
3840/// `sqlite3_profile` does not surface per-statement step counts or
3841/// cache-hit deltas in its callback; we emit `0` for those fields and
3842/// document the hazard. AC-005b requires the fields be typed numeric,
3843/// not that they carry non-zero values for every backend.
3844unsafe extern "C" fn profile_callback_trampoline(
3845    user_data: *mut std::ffi::c_void,
3846    sql: *const std::os::raw::c_char,
3847    nanoseconds: u64,
3848) {
3849    if user_data.is_null() || sql.is_null() {
3850        return;
3851    }
3852    let ctx = unsafe { &*(user_data.cast::<ProfileContext>()) };
3853    let sql_text = match unsafe { std::ffi::CStr::from_ptr(sql) }.to_str() {
3854        Ok(s) => s,
3855        Err(_) => return,
3856    };
3857
3858    let wall_clock_ms = nanoseconds / 1_000_000;
3859
3860    if ctx.profiling_enabled.load(Ordering::Relaxed) {
3861        let record = lifecycle::ProfileRecord {
3862            wall_clock_ms,
3863            // step_count / cache_delta are not surfaced by
3864            // sqlite3_profile; placeholder 0 satisfies AC-005b's
3865            // "typed numeric" contract. A future profiling refactor
3866            // around sqlite3_stmt_status + sqlite3_db_status would
3867            // populate them with non-zero deltas.
3868            step_count: 0,
3869            cache_delta: 0,
3870        };
3871        ctx.subscribers.dispatch_profile(&record);
3872    }
3873
3874    let threshold = ctx.slow_threshold_ms.load(Ordering::Relaxed);
3875    if wall_clock_ms > threshold {
3876        let signal = lifecycle::SlowStatement { statement: sql_text.to_string(), wall_clock_ms };
3877        ctx.subscribers.dispatch_slow_statement(&signal);
3878    }
3879}
3880
3881#[cfg(test)]
3882mod tests {
3883    use super::{Engine, PreparedWrite};
3884    use tempfile::TempDir;
3885
3886    #[test]
3887    fn write_advances_cursor() {
3888        let dir = TempDir::new().unwrap();
3889        let opened = Engine::open(dir.path().join("rewrite.sqlite")).expect("engine should open");
3890        let receipt = opened
3891            .engine
3892            .write(&[PreparedWrite::Node {
3893                kind: "doc".to_string(),
3894                body: "hello".to_string(),
3895                source_id: None,
3896            }])
3897            .expect("write should succeed");
3898
3899        assert_eq!(receipt.cursor, 1);
3900    }
3901}