Skip to main content

fathomdb_cli/
lib.rs

1//! **FathomDB operator CLI** — the `fathomdb` binary: parser plus verb runtime.
2//!
3//! Installing this crate gives you the `fathomdb` command. It is
4//! **operator-only** and deliberately ships no application query surface: there
5//! is no `fathomdb search` / `get` / `list`. Use one of the SDKs
6//! (the `fathomdb` facade crate for Rust, or the Python / TypeScript packages)
7//! to read and write data.
8//!
9//! Two roots:
10//!
11//! - `fathomdb doctor <verb>` — read-only or artifact-producing diagnostics:
12//!   `check-integrity`, `safe-export`, `verify-embedder`, `trace`,
13//!   `dump-schema`, `dump-row-counts`, `dump-profile`, `dump-mutations`,
14//!   `orphan-provenance`, `warm-cache`, `recompute-mean`.
15//! - `fathomdb recover --accept-data-loss <flag>` — the only lossy,
16//!   non-bit-preserving root: `--truncate-wal`, `--rebuild-vec0`,
17//!   `--rebuild-projections`, `--excise-source`, and the
18//!   `--excise-collection` / `--excise-record-key` pair.
19//!
20//! `--json` is the normative machine-readable contract on every verb; exit
21//! codes are a stable class set (`0` clean, `64` data-loss acknowledged, `65`
22//! findings, `66` artifact failure, `70` unrecoverable, `71` lock-held).
23//!
24//! **The CLI is not required for deletion on request.** Since 0.8.20 the SDKs
25//! ship `purge` and `erase_source` themselves. `recover --excise-source`
26//! remains the only route into the engine's reserved `_`-prefixed provenance
27//! namespace, and `--excise-collection` / `--excise-record-key` has no SDK
28//! peer.
29//!
30//! This crate enables the `operator` cargo feature on the `fathomdb` facade,
31//! which is what un-gates the recovery seam it drives.
32//!
33//! Surface owned by `dev/interfaces/cli.md`; verb semantics by
34//! `dev/design/recovery.md`. Each verb invokes the corresponding
35//! [`fathomdb::Engine`] method and serializes the typed report under a per-verb
36//! JSON discriminator.
37
38use std::path::PathBuf;
39
40use clap::{Args, Parser, Subcommand};
41use fathomdb::{
42    CheckIntegrityOpts, CorruptionLocator, DumpProfileReport, DumpRowCountsReport,
43    DumpSchemaReport, Engine, EngineError, EngineOpenError, ExciseRecordReport, ExciseReport,
44    Finding, IntegrityReport, MeanRecomputeReport, OrphanProvenanceReport, RebuildKind,
45    RebuildReport, SafeExportArtifact, SchemaObject, Section, TraceReport, TruncateWalReport,
46    TruncateWalStatus, VerifyEmbedderReport, VerifyEmbedderStatus,
47};
48use serde_json::{json, Value};
49
50/// Stable exit-code classes for the operator CLI.
51///
52/// Sourced from `dev/interfaces/cli.md` § Exit-code classes; meanings remain
53/// load-bearing across `recover` + `doctor` outcomes.
54pub mod exit_code {
55    /// Successful completion with no findings that require a non-zero exit.
56    pub const OK: i32 = 0;
57
58    /// `recover` completed only because lossy action was explicitly accepted.
59    pub const RECOVERY_ACCEPTED_LOSS: i32 = 64;
60
61    /// Doctor / verification surface found actionable non-clean state.
62    pub const DOCTOR_FOUND_ISSUES: i32 = 65;
63
64    /// Export / materialization failure on an artifact-producing doctor verb.
65    pub const EXPORT_FAILURE: i32 = 66;
66
67    /// Unrecoverable command failure.
68    pub const UNRECOVERABLE: i32 = 70;
69
70    /// Lock-held or equivalent precondition-blocked outcome.
71    pub const LOCK_HELD: i32 = 71;
72}
73
74/// Top-level CLI invocation.
75#[derive(Debug, Parser)]
76#[command(name = "fathomdb", version, about = "FathomDB operator CLI", long_about = None)]
77pub struct Cli {
78    #[command(subcommand)]
79    pub command: Command,
80}
81
82/// Root command verbs.
83///
84/// Exactly two roots per `dev/interfaces/cli.md` § Roots: `recover` for lossy
85/// operator workflows and `doctor` for diagnostics.
86#[derive(Debug, Subcommand)]
87pub enum Command {
88    /// Run a lossy / non-bit-preserving recovery workflow.
89    Recover(RecoverArgs),
90    /// Run an operator diagnostic verb.
91    Doctor(DoctorArgs),
92}
93
94/// Wrapper carrying the doctor verb table beneath the `doctor` root.
95#[derive(Debug, Args)]
96pub struct DoctorArgs {
97    #[command(subcommand)]
98    pub command: DoctorCommand,
99}
100
101/// Argument set for the `recover` root command.
102///
103/// `--accept-data-loss` is declared on this parser only; doctor verbs reject
104/// it as unknown.
105#[derive(Debug, Args)]
106pub struct RecoverArgs {
107    /// Required acknowledgement that the workflow may discard data.
108    #[arg(long)]
109    pub accept_data_loss: bool,
110
111    /// Truncate the SQLite WAL after replay.
112    #[arg(long)]
113    pub truncate_wal: bool,
114
115    /// Rebuild the `vec0` shadow tables from canonical state.
116    #[arg(long)]
117    pub rebuild_vec0: bool,
118
119    /// Rebuild projection materializations.
120    #[arg(long)]
121    pub rebuild_projections: bool,
122
123    /// Excise the named source row from the canonical store.
124    #[arg(long)]
125    pub excise_source: Option<String>,
126
127    /// 0.8.20 Slice 5b (R-20-E7) — op-store collection holding the record to
128    /// excise. Paired with `--excise-record-key`; both are required together.
129    #[arg(long, requires = "excise_record_key")]
130    pub excise_collection: Option<String>,
131
132    /// 0.8.20 Slice 5b (R-20-E7) — record key to excise from
133    /// `--excise-collection`. Erases every append-only-log version of the key
134    /// plus its latest-state row.
135    #[arg(long, requires = "excise_collection")]
136    pub excise_record_key: Option<String>,
137
138    /// Emit machine-readable JSON output.
139    #[arg(long)]
140    pub json: bool,
141
142    /// Path to the database file to recover.
143    pub db_path: PathBuf,
144}
145
146/// Doctor verb table per `dev/interfaces/cli.md` § Doctor verbs.
147#[derive(Debug, Subcommand)]
148pub enum DoctorCommand {
149    /// Run a structural integrity check against the database.
150    CheckIntegrity(CheckIntegrityArgs),
151    /// Materialize a safe export of the database.
152    SafeExport(SafeExportArgs),
153    /// Verify the embedder identity recorded in the database.
154    VerifyEmbedder(VerifyEmbedderArgs),
155    /// Trace the resolution chain for a given source reference.
156    Trace(TraceArgs),
157    /// Dump the canonical schema definition.
158    DumpSchema(SimpleDoctorArgs),
159    /// Dump per-table row counts.
160    DumpRowCounts(SimpleDoctorArgs),
161    /// Dump the response-cycle profile recorded by the engine.
162    DumpProfile(SimpleDoctorArgs),
163    /// EU-5b — fetch and verify the pinned default embedder weights so the
164    /// next `Engine::open` with `EmbedderChoice::Default` runs against a
165    /// warm cache without touching the network.
166    WarmCache(WarmCacheArgs),
167    /// 0.7.2 PR-2b — re-derive and re-pin the corpus mean from the current
168    /// vectors, re-quantizing every row in one transaction. Always allowed
169    /// (exempt from the automatic-path 200k cap).
170    RecomputeMean(SimpleDoctorArgs),
171    /// Slice 34 (F4-READ / reserved-gap-34) — read back op-store
172    /// (`operational_mutations`) rows for one `append_only_log` collection
173    /// over the existing `Engine::read_mutations` seam. A read-only operator
174    /// diagnostic over the mutation log (the `dump-*` family, per
175    /// `ADR-0.6.0-cli-scope`), NOT the rejected `search`/`get`/`list`
176    /// application query surface. CLI-only; no SDK parity.
177    DumpMutations(DumpMutationsArgs),
178    /// 0.8.20 Slice 5d (R-20-E8) — read-only per-`source_id` census over the
179    /// canonical tables. Reports which provenance buckets exist and, load-
180    /// bearingly, how many rows are reachable by NO erasure verb (neither a
181    /// `source_id` for `erase_source` nor a `logical_id` for `purge`). A
182    /// non-zero un-erasable count exits `DOCTOR_FOUND_ISSUES` (65).
183    /// CLI-only; no SDK parity.
184    OrphanProvenance(SimpleDoctorArgs),
185}
186
187/// EU-5b — `fathomdb doctor warm-cache` argument set.
188#[derive(Debug, Args)]
189pub struct WarmCacheArgs {
190    /// Emit machine-readable JSON output.
191    #[arg(long)]
192    pub json: bool,
193}
194
195/// Shared args for doctor verbs whose only options are `--json` and a
196/// required `<db_path>` positional. `cli.md` § Output posture: `--json` is
197/// the normative machine-readable contract on every verb.
198#[derive(Debug, Args)]
199pub struct SimpleDoctorArgs {
200    /// Emit machine-readable JSON output.
201    #[arg(long)]
202    pub json: bool,
203
204    /// Path to the database file to inspect.
205    pub db_path: PathBuf,
206}
207
208/// Default `--limit` page size for `doctor dump-mutations` when the operator
209/// omits it. A sane page that bounds output; the engine still clamps the
210/// effective SQL `LIMIT` to `READ_COLLECTION_MAX_LIMIT` (~1M), so no read is
211/// ever unbounded. See `dev/design/slice-34-cli-op-store-readback-design.md`.
212const DUMP_MUTATIONS_DEFAULT_LIMIT: usize = 1000;
213
214/// CLI-side mirror of the engine's `read_collection`/`read_mutations` page cap
215/// (`READ_COLLECTION_MAX_LIMIT`, ~1M — private to `fathomdb-engine`). The CLI
216/// clamps `--limit` to the SAME value via [`effective_dump_limit`] so the
217/// `next_after_id` "full page ⇒ maybe more" decision compares `rows.len()`
218/// against the EFFECTIVE limit the engine actually honors. Without this mirror,
219/// a `--limit` above the engine cap would make a full capped page
220/// (`rows.len() == cap < requested`) look exhausted → `next_after_id: null` →
221/// pagination would silently stop while rows remain. Keep in lockstep with
222/// `fathomdb-engine`'s `READ_COLLECTION_MAX_LIMIT`.
223const DUMP_MUTATIONS_MAX_LIMIT: usize = 1_000_000;
224
225/// Resolve the effective `doctor dump-mutations` page limit: the operator's
226/// `--limit` (or the default when omitted), clamped to the engine page cap
227/// [`DUMP_MUTATIONS_MAX_LIMIT`]. Pure + total so the clamp is unit-pinned
228/// without seeding a >1M-row log (`tests/parser.rs`).
229#[must_use]
230pub fn effective_dump_limit(requested: Option<usize>) -> usize {
231    requested.unwrap_or(DUMP_MUTATIONS_DEFAULT_LIMIT).min(DUMP_MUTATIONS_MAX_LIMIT)
232}
233
234/// Slice 34 — argument set for `doctor dump-mutations <collection>
235/// [--after-id <n>] [--limit <n>] [--json] <db_path>`. A read-only operator
236/// diagnostic that pages the op-store mutation log over the existing
237/// `Engine::read_mutations` seam.
238#[derive(Debug, Args)]
239pub struct DumpMutationsArgs {
240    /// The `append_only_log` collection whose appended rows to read back.
241    pub collection: String,
242
243    /// Exclusive cursor: return only rows with `id` strictly greater than this
244    /// value. A negative value is normalized to the start of the log; a value
245    /// past the last id yields an empty page.
246    #[arg(long = "after-id")]
247    pub after_id: Option<i64>,
248
249    /// Maximum rows in this page (default 1000). The engine clamps the
250    /// effective SQL `LIMIT` to the ~1M cap, so the read is never unbounded.
251    #[arg(long)]
252    pub limit: Option<usize>,
253
254    /// Emit machine-readable JSON output.
255    #[arg(long)]
256    pub json: bool,
257
258    /// Path to the database file to inspect.
259    pub db_path: PathBuf,
260}
261
262/// Per-verb argument set for `doctor check-integrity`.
263#[derive(Debug, Args)]
264pub struct CheckIntegrityArgs {
265    /// Run only the fast integrity probes.
266    #[arg(long)]
267    pub quick: bool,
268
269    /// Run the full per-page integrity sweep.
270    #[arg(long)]
271    pub full: bool,
272
273    /// Confirm round-trip equivalence between canonical + projection state.
274    #[arg(long = "round-trip")]
275    pub round_trip: bool,
276
277    /// Format human output.
278    #[arg(long)]
279    pub pretty: bool,
280
281    /// Emit machine-readable JSON output.
282    #[arg(long)]
283    pub json: bool,
284
285    /// Path to the database file to inspect.
286    pub db_path: PathBuf,
287}
288
289/// Per-verb argument set for `doctor safe-export`.
290#[derive(Debug, Args)]
291pub struct SafeExportArgs {
292    /// Destination path for the exported artifact.
293    pub out: PathBuf,
294
295    /// Optional manifest sidecar describing the exported artifact.
296    #[arg(long)]
297    pub manifest: Option<PathBuf>,
298
299    /// Emit machine-readable JSON output.
300    #[arg(long)]
301    pub json: bool,
302
303    /// Path to the database file to export.
304    pub db_path: PathBuf,
305}
306
307/// Per-verb argument set for `doctor verify-embedder`. `cli.md`
308/// (amended 2026-05-15) locks the invocation as
309/// `verify-embedder --identity <s> --dimension <n> <db_path>`.
310#[derive(Debug, Args)]
311pub struct VerifyEmbedderArgs {
312    /// Stored-embedder identity string the operator expects (typically
313    /// `<name>:<revision>`).
314    #[arg(long)]
315    pub identity: String,
316
317    /// Stored-embedder dimension the operator expects.
318    #[arg(long)]
319    pub dimension: u32,
320
321    /// Emit machine-readable JSON output.
322    #[arg(long)]
323    pub json: bool,
324
325    /// Path to the database file to inspect.
326    pub db_path: PathBuf,
327}
328
329/// Per-verb argument set for `doctor trace`.
330#[derive(Debug, Args)]
331pub struct TraceArgs {
332    /// Source reference to trace.
333    #[arg(long = "source-ref")]
334    pub source_ref: String,
335
336    /// Emit machine-readable JSON output.
337    #[arg(long)]
338    pub json: bool,
339
340    /// Path to the database file to inspect.
341    pub db_path: PathBuf,
342}
343
344/// Outcome classes that map to the stable exit-code matrix in
345/// `dev/interfaces/cli.md` § Exit-code classes.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum CliOutcome {
348    /// Verb completed successfully with no findings.
349    Clean,
350    /// Doctor / verification surface found actionable non-clean state.
351    Findings,
352    /// Export / materialization failure on an artifact-producing doctor verb.
353    ExportFailure,
354    /// `recover` completed only because lossy action was explicitly accepted.
355    RecoveryAcceptedLoss,
356    /// Lock-held or equivalent precondition-blocked outcome.
357    LockHeld,
358    /// Unrecoverable command failure.
359    Unrecoverable,
360}
361
362/// Map an outcome to the stable exit code defined in `cli.md`.
363#[must_use]
364pub fn outcome_to_exit_code(outcome: CliOutcome) -> i32 {
365    match outcome {
366        CliOutcome::Clean => exit_code::OK,
367        CliOutcome::Findings => exit_code::DOCTOR_FOUND_ISSUES,
368        CliOutcome::ExportFailure => exit_code::EXPORT_FAILURE,
369        CliOutcome::RecoveryAcceptedLoss => exit_code::RECOVERY_ACCEPTED_LOSS,
370        CliOutcome::LockHeld => exit_code::LOCK_HELD,
371        CliOutcome::Unrecoverable => exit_code::UNRECOVERABLE,
372    }
373}
374
375/// Map an [`EngineError`] to the [`CliOutcome`] class per
376/// `dev/interfaces/cli.md` § Error to exit-code mapping.
377#[must_use]
378pub fn engine_error_to_outcome(err: &EngineError) -> CliOutcome {
379    match err {
380        EngineError::Closing => CliOutcome::LockHeld,
381        // 0.8.20 Slice 5b (R-20-E5) — `LOCK_HELD` (71), NOT `UNRECOVERABLE` (70).
382        //
383        // `LOCK_HELD` is documented as "lock-held or equivalent
384        // precondition-blocked outcome", and that is exactly this case: the
385        // erasure verb's row deletions committed, and the only step that failed
386        // — `wal_checkpoint(TRUNCATE)` — failed because a CONCURRENT READER is
387        // pinning a WAL snapshot. It is precondition-blocked and retryable, so
388        // `UNRECOVERABLE` would tell an operator's script the opposite of the
389        // truth and invite a destructive escalation instead of a retry.
390        //
391        // Non-zero either way, so the "an erasure verb must never report success
392        // on an incomplete erasure" contract holds under both mappings; 71 is
393        // chosen for the ACTIONABILITY of the signal. Caveat (accepted): the
394        // rarer `stage = "telemetry_redaction"` failure is a sink I/O error
395        // rather than a lock, and it also lands on 71; the JSON envelope carries
396        // `code` + `detail` naming the real stage, so the precise cause is not
397        // lost.
398        EngineError::ErasureIncomplete { .. } => CliOutcome::LockHeld,
399        _ => CliOutcome::Unrecoverable,
400    }
401}
402
403/// Map an [`EngineOpenError`] to the [`CliOutcome`] class per
404/// `dev/interfaces/cli.md` § Error to exit-code mapping.
405#[must_use]
406pub fn engine_open_error_to_outcome(err: &EngineOpenError) -> CliOutcome {
407    match err {
408        EngineOpenError::DatabaseLocked { .. } => CliOutcome::LockHeld,
409        _ => CliOutcome::Unrecoverable,
410    }
411}
412
413/// Run a parsed CLI command.
414///
415/// Phase 10a wires the six landed engine seams. The CLI opens the engine
416/// at the verb's `<db_path>`, calls the seam, serializes the typed report
417/// under a `verb`-discriminated JSON envelope, and maps `EngineError` to
418/// the stable exit-code matrix.
419///
420/// `recover` invoked without `--accept-data-loss` is refused at the CLI
421/// layer (no engine call) per `dev/design/recovery.md`: recovery is the
422/// only lossy root and must not proceed without explicit acknowledgement.
423#[must_use]
424pub fn run(cli: Cli) -> i32 {
425    match cli.command {
426        Command::Recover(args) => run_recover(args),
427        Command::Doctor(d) => run_doctor(d.command),
428    }
429}
430
431fn run_recover(args: RecoverArgs) -> i32 {
432    if !args.accept_data_loss {
433        println!(
434            r#"{{"status":"refused","verb":"recover","code":"E_RECOVER_REQUIRES_ACCEPT_DATA_LOSS"}}"#
435        );
436        return exit_code::UNRECOVERABLE;
437    }
438
439    if args.rebuild_projections {
440        return wire_recover(&args.db_path, "rebuild-projections", |e| {
441            e.rebuild_projections().map(|r| rebuild_report_json("rebuild-projections", &r))
442        });
443    }
444    if args.rebuild_vec0 {
445        return wire_recover(&args.db_path, "rebuild-vec0", |e| {
446            e.rebuild_vec0().map(|r| rebuild_report_json("rebuild-vec0", &r))
447        });
448    }
449    if let Some(source_id) = args.excise_source.as_deref() {
450        return wire_recover(&args.db_path, "excise-source", |e| {
451            e.excise_source(source_id).map(|r| excise_report_json(&r))
452        });
453    }
454    // 0.8.20 Slice 5b (R-20-E7) — op-store record erasure. Clap's `requires`
455    // pairing guarantees both flags arrive together.
456    if let (Some(collection), Some(record_key)) =
457        (args.excise_collection.as_deref(), args.excise_record_key.as_deref())
458    {
459        return wire_recover(&args.db_path, "excise-record", |e| {
460            e.excise_collection_record(collection, record_key)
461                .map(|r| excise_record_report_json(&r))
462        });
463    }
464    if args.truncate_wal {
465        return wire_recover(&args.db_path, "truncate-wal", |e| {
466            e.truncate_wal().map(|r| truncate_wal_report_json(&r))
467        });
468    }
469
470    // No bound sub-action selected → stub.
471    println!(r#"{{"status":"not_implemented","verb":"recover"}}"#);
472    exit_code::UNRECOVERABLE
473}
474
475fn run_doctor(cmd: DoctorCommand) -> i32 {
476    match cmd {
477        DoctorCommand::CheckIntegrity(args) => {
478            let opts = CheckIntegrityOpts {
479                quick: args.quick,
480                full: args.full,
481                round_trip: args.round_trip,
482            };
483            run_doctor_verb(&args.db_path, "check-integrity", |e| {
484                e.check_integrity(opts).map(|r| integrity_report_outcome(&r))
485            })
486        }
487        DoctorCommand::SafeExport(args) => {
488            let manifest = args.manifest.clone().unwrap_or_else(|| {
489                let mut p = args.out.clone();
490                let name = p
491                    .file_name()
492                    .map(|s| s.to_string_lossy().into_owned())
493                    .unwrap_or_else(|| "export".to_string());
494                p.set_file_name(format!("{name}.manifest.json"));
495                p
496            });
497            run_doctor_verb_with_error_outcome(
498                &args.db_path,
499                "safe-export",
500                CliOutcome::ExportFailure,
501                |e| {
502                    e.safe_export(&args.out, &manifest)
503                        .map(|r| (safe_export_json(&r), CliOutcome::Clean))
504                },
505            )
506        }
507        DoctorCommand::Trace(args) => run_doctor_verb(&args.db_path, "trace", |e| {
508            e.trace_source_ref(&args.source_ref).map(|r| (trace_report_json(&r), CliOutcome::Clean))
509        }),
510        DoctorCommand::VerifyEmbedder(args) => {
511            let identity = args.identity.clone();
512            let dimension = args.dimension;
513            run_doctor_verb(&args.db_path, "verify-embedder", |e| {
514                e.verify_embedder(&identity, dimension)
515                    .map(|r| (verify_embedder_report_json(&r), CliOutcome::Clean))
516            })
517        }
518        DoctorCommand::DumpSchema(args) => run_doctor_verb(&args.db_path, "dump-schema", |e| {
519            e.dump_schema().map(|r| (dump_schema_report_json(&r), CliOutcome::Clean))
520        }),
521        DoctorCommand::DumpRowCounts(args) => {
522            run_doctor_verb(&args.db_path, "dump-row-counts", |e| {
523                e.dump_row_counts().map(|r| (dump_row_counts_report_json(&r), CliOutcome::Clean))
524            })
525        }
526        DoctorCommand::DumpProfile(args) => run_doctor_verb(&args.db_path, "dump-profile", |e| {
527            e.dump_profile().map(|r| (dump_profile_report_json(&r), CliOutcome::Clean))
528        }),
529        DoctorCommand::OrphanProvenance(args) => {
530            run_doctor_verb(&args.db_path, "orphan-provenance", |e| {
531                e.orphan_provenance().map(|r| {
532                    // An un-erasable row is actionable non-clean state: the
533                    // database holds content no erasure request can reach.
534                    // Everything else (including `_legacy:` rows, which ARE
535                    // erasable through the operator seam) is merely reported.
536                    let outcome = if r.unerasable_rows > 0 {
537                        CliOutcome::Findings
538                    } else {
539                        CliOutcome::Clean
540                    };
541                    (orphan_provenance_report_json(&r), outcome)
542                })
543            })
544        }
545        DoctorCommand::WarmCache(args) => run_doctor_warm_cache(args),
546        DoctorCommand::RecomputeMean(args) => {
547            run_doctor_verb(&args.db_path, "recompute-mean", |e| {
548                e.recompute_mean().map(|r| (recompute_mean_report_json(&r), CliOutcome::Clean))
549            })
550        }
551        DoctorCommand::DumpMutations(args) => {
552            let limit = effective_dump_limit(args.limit);
553            run_doctor_verb(&args.db_path, "dump-mutations", |e| {
554                // Read over the EXISTING Slice-30 seam (Slice-33 index-driven).
555                // The rows are serialized INLINE below so `OpStoreRow` is never
556                // named / re-exported — the facade public-type set is untouched.
557                e.read_mutations(&args.collection, args.after_id, limit).map(|rows| {
558                    let row_values = rows
559                        .iter()
560                        .map(|r| {
561                            json!({
562                                "id": r.id,
563                                "collection": r.collection,
564                                "record_key": r.record_key,
565                                "op_kind": r.op_kind,
566                                "payload": r.payload,
567                                "schema_id": r.schema_id,
568                                "write_cursor": r.write_cursor,
569                            })
570                        })
571                        .collect::<Vec<_>>();
572                    // `next_after_id` = the last row's id iff a full page was
573                    // returned (more rows may follow); else null (the log is
574                    // exhausted at this cursor). The engine cursor is exclusive,
575                    // so resuming with `--after-id <next_after_id>` never overlaps.
576                    let next_after_id =
577                        if rows.len() == limit { rows.last().map(|r| r.id) } else { None };
578                    let body = json!({
579                        "verb": "dump-mutations",
580                        "collection": args.collection,
581                        "after_id": args.after_id,
582                        "limit": limit,
583                        "count": row_values.len(),
584                        "rows": row_values,
585                        "next_after_id": next_after_id,
586                    });
587                    (body, CliOutcome::Clean)
588                })
589            })
590        }
591    }
592}
593
594/// EU-5b — invoke the default-embedder loader directly (no engine open)
595/// so users + CI can warm the on-disk cache before the first
596/// `Engine::open` triggers a download.
597fn run_doctor_warm_cache(args: WarmCacheArgs) -> i32 {
598    #[cfg(feature = "default-embedder")]
599    {
600        match fathomdb_embedder::loader::load_pinned_default_embedder() {
601            Ok(weights) => {
602                if args.json {
603                    let payload = json!({
604                        "verb": "warm-cache",
605                        "status": "ok",
606                        "config_json": weights.config_json_path.to_string_lossy(),
607                        "tokenizer_json": weights.tokenizer_json_path.to_string_lossy(),
608                        "model_safetensors": weights.model_safetensors_path.to_string_lossy(),
609                        "bytes_downloaded": weights.bytes_downloaded,
610                        "events": weights
611                            .events
612                            .iter()
613                            .map(warm_cache_event_json)
614                            .collect::<Vec<_>>(),
615                    });
616                    println!("{payload}");
617                } else {
618                    let kind = if weights.bytes_downloaded > 0 { "cold" } else { "warm" };
619                    println!("warm-cache: ok ({kind})");
620                    println!("  config.json:       {}", weights.config_json_path.display());
621                    println!("  tokenizer.json:    {}", weights.tokenizer_json_path.display());
622                    println!("  model.safetensors: {}", weights.model_safetensors_path.display());
623                    println!("  bytes downloaded:  {}", weights.bytes_downloaded);
624                    println!("  events:            {}", weights.events.len());
625                }
626                exit_code::OK
627            }
628            Err(err) => {
629                if args.json {
630                    let payload = json!({
631                        "verb": "warm-cache",
632                        "status": "error",
633                        "code": "EmbedderLoadError",
634                        "detail": err.to_string(),
635                    });
636                    println!("{payload}");
637                } else {
638                    eprintln!("warm-cache: error: {err}");
639                }
640                exit_code::UNRECOVERABLE
641            }
642        }
643    }
644    #[cfg(not(feature = "default-embedder"))]
645    {
646        let detail = "fathomdb CLI was built without the `default-embedder` feature; rebuild with --features default-embedder";
647        if args.json {
648            let payload = json!({
649                "verb": "warm-cache",
650                "status": "error",
651                "code": "DefaultEmbedderFeatureDisabled",
652                "detail": detail,
653            });
654            println!("{payload}");
655        } else {
656            eprintln!("warm-cache: error: {detail}");
657        }
658        exit_code::UNRECOVERABLE
659    }
660}
661
662#[cfg(feature = "default-embedder")]
663fn warm_cache_event_json(ev: &fathomdb_embedder::EmbedderEvent) -> Value {
664    use fathomdb_embedder::EmbedderEvent;
665    match ev {
666        EmbedderEvent::DefaultEmbedderDownload {
667            file,
668            url,
669            bytes,
670            sha256,
671            cache_path,
672            duration_ms,
673        } => json!({
674            "kind": "download",
675            "file": file,
676            "url": url,
677            "bytes": bytes,
678            "sha256": sha256,
679            "cache_path": cache_path.to_string_lossy(),
680            "duration_ms": duration_ms,
681        }),
682        EmbedderEvent::DefaultEmbedderCacheHit { file, sha256, cache_path } => json!({
683            "kind": "cache_hit",
684            "file": file,
685            "sha256": sha256,
686            "cache_path": cache_path.to_string_lossy(),
687        }),
688        EmbedderEvent::MeanVecPinned { dim, doc_count } => json!({
689            "kind": "mean_vec_pinned",
690            "dim": dim,
691            "doc_count": doc_count,
692        }),
693        EmbedderEvent::MeanVecRecomputed { dim, doc_count, trigger } => json!({
694            "kind": "mean_vec_recomputed",
695            "dim": dim,
696            "doc_count": doc_count,
697            "trigger": trigger.as_str(),
698        }),
699    }
700}
701
702/// Open the engine, invoke `f`, print the resulting JSON value, and map
703/// the outcome to an exit code. The closure returns `(json, outcome)`.
704fn run_doctor_verb<F>(db_path: &std::path::Path, verb: &str, f: F) -> i32
705where
706    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
707{
708    run_doctor_verb_inner(db_path, verb, None, f)
709}
710
711/// Variant that overrides the `EngineError` → outcome mapping for verbs
712/// with a dedicated failure class (per `cli.md § Error → exit-code
713/// mapping`). Example: `doctor safe-export` maps engine errors to
714/// `ExportFailure` (66), not the default `Unrecoverable` (70).
715fn run_doctor_verb_with_error_outcome<F>(
716    db_path: &std::path::Path,
717    verb: &str,
718    error_outcome: CliOutcome,
719    f: F,
720) -> i32
721where
722    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
723{
724    run_doctor_verb_inner(db_path, verb, Some(error_outcome), f)
725}
726
727fn run_doctor_verb_inner<F>(
728    db_path: &std::path::Path,
729    verb: &str,
730    error_outcome: Option<CliOutcome>,
731    f: F,
732) -> i32
733where
734    F: FnOnce(&Engine) -> Result<(Value, CliOutcome), EngineError>,
735{
736    let opened = match Engine::open(db_path.to_path_buf()) {
737        Ok(o) => o,
738        Err(err) => return emit_engine_open_error(verb, &err),
739    };
740    match f(&opened.engine) {
741        Ok((value, outcome)) => {
742            println!("{value}");
743            outcome_to_exit_code(outcome)
744        }
745        Err(err) => match error_outcome {
746            Some(outcome) => emit_engine_error_with_outcome(verb, &err, outcome),
747            None => emit_engine_error(verb, &err),
748        },
749    }
750}
751
752/// Open the engine for `recover`, invoke `f`, print the JSON, and map the
753/// outcome to `RECOVERY_ACCEPTED_LOSS` (64) on success.
754fn wire_recover<F>(db_path: &std::path::Path, sub_verb: &str, f: F) -> i32
755where
756    F: FnOnce(&Engine) -> Result<Value, EngineError>,
757{
758    let opened = match Engine::open(db_path.to_path_buf()) {
759        Ok(o) => o,
760        Err(err) => return emit_engine_open_error(sub_verb, &err),
761    };
762    match f(&opened.engine) {
763        Ok(value) => {
764            println!("{value}");
765            outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss)
766        }
767        Err(err) => emit_engine_error(sub_verb, &err),
768    }
769}
770
771fn emit_engine_error(verb: &str, err: &EngineError) -> i32 {
772    emit_engine_error_with_outcome(verb, err, engine_error_to_outcome(err))
773}
774
775fn emit_engine_error_with_outcome(verb: &str, err: &EngineError, outcome: CliOutcome) -> i32 {
776    let payload = json!({
777        "status": "error",
778        "verb": verb,
779        "code": engine_error_code(err),
780        "detail": err.to_string(),
781    });
782    println!("{payload}");
783    outcome_to_exit_code(outcome)
784}
785
786fn emit_engine_open_error(verb: &str, err: &EngineOpenError) -> i32 {
787    let outcome = engine_open_error_to_outcome(err);
788    let payload = json!({
789        "status": "error",
790        "verb": verb,
791        "code": engine_open_error_code(err),
792        "detail": err.to_string(),
793    });
794    println!("{payload}");
795    outcome_to_exit_code(outcome)
796}
797
798fn engine_error_code(err: &EngineError) -> &'static str {
799    match err {
800        EngineError::Storage => "StorageError",
801        EngineError::Projection => "ProjectionError",
802        EngineError::Vector => "VectorError",
803        EngineError::Embedder => "EmbedderError",
804        EngineError::EmbedderNotConfigured => "EmbedderNotConfiguredError",
805        EngineError::KindNotVectorIndexed => "KindNotVectorIndexedError",
806        EngineError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
807        EngineError::Scheduler => "SchedulerError",
808        EngineError::OpStore => "OpStoreError",
809        EngineError::WriteValidation => "WriteValidationError",
810        EngineError::SchemaValidation => "SchemaValidationError",
811        EngineError::Overloaded => "OverloadedError",
812        EngineError::Closing => "ClosingError",
813        EngineError::Extractor => "ExtractorError",
814        EngineError::Consolidator => "ConsolidatorError",
815        // G4 (Slice 35) — filter predicate construction error.
816        EngineError::InvalidFilter { .. } => "InvalidFilterError",
817        EngineError::InvalidArgument { .. } => "InvalidArgumentError",
818        // 0.8.18 Slice 5 (#5 vector-equivalence probe) — query-time dense refusal.
819        EngineError::VectorEquivalenceMismatch { .. } => "VectorEquivalenceMismatchError",
820        // OPP-12 Phase-1 (0.8.19 Slice 10) — lifecycle-verb typed errors.
821        EngineError::IllegalTransition { .. } => "IllegalTransitionError",
822        EngineError::NotLifecycleAddressable { .. } => "NotLifecycleAddressableError",
823        // 0.8.20 Slice 5b (R-20-E5) — erasure verb could not finish at rest.
824        EngineError::ErasureIncomplete { .. } => "ErasureIncompleteError",
825        // 0.8.20 Slice 15d (R-20-PR) — destructive projection change refused.
826        EngineError::ProjectionDestructive { .. } => "ProjectionDestructiveError",
827    }
828}
829
830fn engine_open_error_code(err: &EngineOpenError) -> &'static str {
831    match err {
832        EngineOpenError::DatabaseLocked { .. } => "DatabaseLockedError",
833        EngineOpenError::Corruption(_) => "CorruptionError",
834        EngineOpenError::IncompatibleSchemaVersion { .. } => "IncompatibleSchemaVersionError",
835        EngineOpenError::MigrationError { .. } => "MigrationError",
836        EngineOpenError::EmbedderIdentityMismatch { .. } => "EmbedderIdentityMismatchError",
837        EngineOpenError::EmbedderDimensionMismatch { .. } => "EmbedderDimensionMismatchError",
838        EngineOpenError::Embedder(_) => "EmbedderError",
839        EngineOpenError::Io { .. } => "IoError",
840    }
841}
842
843// ---- JSON serializers for engine report types ----
844
845fn integrity_report_outcome(report: &IntegrityReport) -> (Value, CliOutcome) {
846    let any_findings = matches!(report.physical, Section::Findings(_))
847        || matches!(report.logical, Section::Findings(_))
848        || matches!(report.semantic, Section::Findings(_));
849    let body = json!({
850        "verb": "check-integrity",
851        "physical": section_json(&report.physical),
852        "logical": section_json(&report.logical),
853        "semantic": section_json(&report.semantic),
854    });
855    let outcome = if any_findings { CliOutcome::Findings } else { CliOutcome::Clean };
856    (body, outcome)
857}
858
859fn section_json(section: &Section) -> Value {
860    match section {
861        Section::Clean => json!({ "status": "clean", "findings": [] }),
862        Section::Findings(findings) => json!({
863            "status": "findings",
864            "findings": findings.iter().map(finding_json).collect::<Vec<_>>(),
865        }),
866    }
867}
868
869fn finding_json(f: &Finding) -> Value {
870    json!({
871        "code": f.code,
872        "stage": f.stage,
873        "locator": locator_json(&f.locator),
874        "doc_anchor": f.doc_anchor,
875        "detail": f.detail,
876    })
877}
878
879fn locator_json(loc: &CorruptionLocator) -> Value {
880    match loc {
881        CorruptionLocator::FileOffset { offset } => {
882            json!({ "kind": "file_offset", "offset": offset })
883        }
884        CorruptionLocator::PageId { page } => json!({ "kind": "page_id", "page": page }),
885        CorruptionLocator::TableRow { table, rowid } => {
886            json!({ "kind": "table_row", "table": table, "rowid": rowid })
887        }
888        CorruptionLocator::Vec0ShadowRow { partition, rowid } => {
889            json!({ "kind": "vec0_shadow_row", "partition": partition, "rowid": rowid })
890        }
891        CorruptionLocator::MigrationStep { from, to } => {
892            json!({ "kind": "migration_step", "from": from, "to": to })
893        }
894        CorruptionLocator::OpaqueSqliteError { sqlite_extended_code } => {
895            json!({
896                "kind": "opaque_sqlite_error",
897                "sqlite_extended_code": sqlite_extended_code,
898            })
899        }
900    }
901}
902
903fn safe_export_json(a: &SafeExportArtifact) -> Value {
904    json!({
905        "verb": "safe-export",
906        "export_path": a.export_path.to_string_lossy(),
907        "manifest_path": a.manifest_path.to_string_lossy(),
908        "manifest_sha256": a.manifest_sha256,
909    })
910}
911
912fn trace_report_json(t: &TraceReport) -> Value {
913    json!({
914        "verb": "trace",
915        "source_ref": t.source_ref,
916        "events": t.events.iter().map(|e| json!({
917            "write_cursor": e.write_cursor,
918            "kind": e.kind,
919            "table": e.table,
920        })).collect::<Vec<_>>(),
921    })
922}
923
924fn rebuild_report_json(verb: &'static str, r: &RebuildReport) -> Value {
925    let kind = match r.kind {
926        RebuildKind::Projections => "projections",
927        RebuildKind::Vec0 => "vec0",
928    };
929    json!({
930        "verb": verb,
931        "kind": kind,
932        "rows_invalidated": r.rows_invalidated,
933        "rows_rebuilt": r.rows_rebuilt,
934        "projection_cursor_after": r.projection_cursor_after,
935    })
936}
937
938fn excise_report_json(r: &ExciseReport) -> Value {
939    json!({
940        "verb": "excise-source",
941        "source_ref": r.source_ref,
942        "nodes_excised": r.nodes_excised,
943        "edges_excised": r.edges_excised,
944        "projections_invalidated": r.projections_invalidated,
945    })
946}
947
948/// 0.8.20 Slice 5b (R-20-E7). Emits the audit DIGEST, never the erased
949/// `record_key` — a record key is arbitrary caller-supplied text and may itself
950/// be the identifier being erased, so echoing it into CLI output (and thence
951/// into an operator's shell history or log pipeline) would defeat the erasure.
952fn excise_record_report_json(r: &ExciseRecordReport) -> Value {
953    json!({
954        "verb": "excise-record",
955        "collection": r.collection,
956        "record_digest": r.record_digest,
957        "records_excised": r.records_excised,
958        "state_rows_excised": r.state_rows_excised,
959    })
960}
961
962fn verify_embedder_report_json(r: &VerifyEmbedderReport) -> Value {
963    let status = match r.status {
964        VerifyEmbedderStatus::Match => "match",
965        VerifyEmbedderStatus::IdentityMismatch => "identity_mismatch",
966        VerifyEmbedderStatus::DimensionMismatch => "dimension_mismatch",
967        VerifyEmbedderStatus::BothMismatch => "both_mismatch",
968    };
969    json!({
970        "verb": "verify-embedder",
971        "stored_identity": r.stored_identity,
972        "stored_dimension": r.stored_dimension,
973        "supplied_identity": r.supplied_identity,
974        "supplied_dimension": r.supplied_dimension,
975        "status": status,
976    })
977}
978
979fn schema_object_json(o: &SchemaObject) -> Value {
980    json!({ "name": o.name, "sql": o.sql })
981}
982
983fn dump_schema_report_json(r: &DumpSchemaReport) -> Value {
984    json!({
985        "verb": "dump-schema",
986        "user_version": r.user_version,
987        "tables": r.tables.iter().map(schema_object_json).collect::<Vec<_>>(),
988        "indexes": r.indexes.iter().map(schema_object_json).collect::<Vec<_>>(),
989    })
990}
991
992/// 0.7.2 PR-2b — `doctor recompute-mean` `--json` normative contract.
993fn recompute_mean_report_json(r: &MeanRecomputeReport) -> Value {
994    json!({
995        "verb": "recompute-mean",
996        "status": "ok",
997        "dim": r.dim,
998        "old_doc_count": r.old_doc_count,
999        "doc_count_requantized": r.doc_count_requantized,
1000        "drift_cos_before": r.drift_cos_before,
1001        "mean_was_pinned": r.mean_was_pinned,
1002        "elapsed_ms": r.elapsed_ms,
1003    })
1004}
1005
1006fn dump_row_counts_report_json(r: &DumpRowCountsReport) -> Value {
1007    json!({
1008        "verb": "dump-row-counts",
1009        "counts": r.counts.iter().map(|c| json!({
1010            "name": c.name,
1011            "rows": c.rows,
1012        })).collect::<Vec<_>>(),
1013    })
1014}
1015
1016fn orphan_provenance_report_json(r: &OrphanProvenanceReport) -> Value {
1017    json!({
1018        "verb": "orphan-provenance",
1019        "sources": r.sources.iter().map(|s| json!({
1020            "source_id": s.source_id,
1021            "rows": s.rows,
1022            "governed_rows": s.governed_rows,
1023            "reserved": s.reserved,
1024        })).collect::<Vec<_>>(),
1025        "total_rows": r.total_rows,
1026        "unerasable_rows": r.unerasable_rows,
1027    })
1028}
1029
1030fn dump_profile_report_json(r: &DumpProfileReport) -> Value {
1031    json!({
1032        "verb": "dump-profile",
1033        "embedder_identity": r.embedder_identity,
1034        "embedder_dimension": r.embedder_dimension,
1035        "vectorized_kinds": r.vectorized_kinds,
1036    })
1037}
1038
1039fn truncate_wal_report_json(r: &TruncateWalReport) -> Value {
1040    let status = match r.status {
1041        TruncateWalStatus::Done => "done",
1042        TruncateWalStatus::Busy => "busy",
1043    };
1044    json!({
1045        "verb": "truncate-wal",
1046        "status": status,
1047        "busy": r.busy,
1048        "log_frames": r.log_frames,
1049        "checkpointed_frames": r.checkpointed_frames,
1050    })
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056
1057    #[test]
1058    fn outcome_mapping_covers_cli_md_exit_classes() {
1059        assert_eq!(outcome_to_exit_code(CliOutcome::Clean), 0);
1060        assert_eq!(outcome_to_exit_code(CliOutcome::RecoveryAcceptedLoss), 64);
1061        assert_eq!(outcome_to_exit_code(CliOutcome::Findings), 65);
1062        assert_eq!(outcome_to_exit_code(CliOutcome::ExportFailure), 66);
1063        assert_eq!(outcome_to_exit_code(CliOutcome::Unrecoverable), 70);
1064        assert_eq!(outcome_to_exit_code(CliOutcome::LockHeld), 71);
1065    }
1066
1067    #[test]
1068    fn engine_error_storage_maps_to_unrecoverable() {
1069        assert_eq!(engine_error_to_outcome(&EngineError::Storage), CliOutcome::Unrecoverable);
1070        assert_eq!(engine_error_to_outcome(&EngineError::Closing), CliOutcome::LockHeld);
1071    }
1072
1073    #[test]
1074    fn engine_open_database_locked_maps_to_lock_held() {
1075        let err = EngineOpenError::DatabaseLocked { holder_pid: Some(1234) };
1076        assert_eq!(engine_open_error_to_outcome(&err), CliOutcome::LockHeld);
1077    }
1078}