Skip to main content

rivet/pipeline/
repair_cmd.rs

1//! `rivet repair` — targeted re-export of reconcile mismatches (Epic H).
2//!
3//! ## Flow
4//!
5//! 1. Build a `ReconcileReport` (either freshly, or load a previous JSON via
6//!    `--report`).
7//! 2. Derive a [`RepairPlan`](crate::plan::RepairPlan) — the set of chunk
8//!    ranges that need re-export.
9//! 3. Without `--execute` (default): emit the plan and exit.
10//! 4. With `--execute`: re-run just those chunks via
11//!    [`chunked::run_chunked_sequential`] using a `Precomputed` chunk source.
12//!    Output files are written with collision-proof naming — they are new
13//!    files alongside the originals; Rivet does not delete or overwrite the
14//!    old files.
15//!
16//! ## Closing the trust loop
17//!
18//! A re-export that lands a fresh file but leaves the recorded state stale
19//! breaks the operator's trust loop: `reconcile` still recounts the source
20//! against the *old* `chunk_task.rows_written` and reports the same mismatch
21//! (the loop never converges), and `rivet validate` lists the prefix and flags
22//! the un-recorded repair file as an `untracked_object`. So after a successful
23//! per-chunk re-export this path:
24//!
25//! 1. updates that chunk's `chunk_task` row — `rows_written` is set to the
26//!    freshly-exported count and the task is re-marked `completed` — so the
27//!    next `reconcile` compares the live source count against the repaired
28//!    count and converges to a match; and
29//! 2. appends the repair-written part(s) to the destination `manifest.json`
30//!    (read → append → rewrite) so the new file is tracked and `validate` no
31//!    longer reports it as untracked. The originals are left in place and in
32//!    the manifest (repair is additive, ADR-0009 RR5 / ADR-0012).
33//!
34//! Progression semantics (ADR-0008): repair does **not** advance
35//! `last_committed_*` — the committed boundary already covers the chunk index.
36//! The reconcile the operator runs (or that the repaired state now passes)
37//! advances `last_verified_*`.
38
39use std::collections::HashMap;
40use std::path::Path;
41
42use crate::config::Config;
43use crate::error::Result;
44use crate::manifest::{MANIFEST_FILENAME, ManifestPart, PartStatus, RunManifest};
45use crate::plan::{
46    ExtractionStrategy, ReconcileReport, RepairAction, RepairOutcome, RepairPlan, RepairReport,
47    ResolvedRunPlan, build_plan,
48};
49use crate::source;
50use crate::state::StateStore;
51
52use super::RunSummary;
53use super::chunked::{ChunkSource, run_chunked_sequential};
54use super::reconcile_cmd;
55
56/// Output format for the repair plan / report.
57pub enum RepairOutputFormat {
58    /// Human-readable summary to stdout.
59    Pretty,
60    /// Pretty-printed JSON to the given path (or stdout if `None`).
61    Json(Option<String>),
62}
63
64/// Source of the reconcile report used to derive the repair plan.
65pub enum RepairReportSource {
66    /// Read a reconcile report JSON from disk.
67    File(String),
68    /// Run reconcile in-process against the latest chunk run.
69    Auto,
70}
71
72pub fn run_repair_command(
73    config_path: &str,
74    export_name: &str,
75    params: Option<&HashMap<String, String>>,
76    report_source: RepairReportSource,
77    execute: bool,
78    format: RepairOutputFormat,
79) -> Result<()> {
80    let config = Config::load_with_params(config_path, params)?;
81    let config_dir = Path::new(config_path)
82        .parent()
83        .unwrap_or_else(|| Path::new("."));
84
85    let export = config
86        .exports
87        .iter()
88        .find(|e| e.name == export_name)
89        .ok_or_else(|| anyhow::anyhow!("export '{}' not found in config", export_name))?;
90
91    let mut plan = build_plan(&config, export, config_dir, false, false, false, params)?;
92    if !matches!(plan.strategy, ExtractionStrategy::Chunked(_)) {
93        anyhow::bail!(
94            "repair: '{}' mode — only chunked exports are supported in v1 (Epic H)",
95            plan.strategy.mode_label()
96        );
97    }
98
99    let state_path = config_dir.join(".rivet_state.db");
100    let state = StateStore::open(state_path.to_str().unwrap_or(".rivet_state.db"))?;
101
102    let reconcile_report = load_or_build_reconcile(&plan, &state, report_source)?;
103    let repair_plan = RepairPlan::from_reconcile(&reconcile_report);
104
105    if !execute {
106        emit_plan(&repair_plan, &format)?;
107        return Ok(());
108    }
109
110    if repair_plan.is_empty() {
111        println!(
112            "repair: nothing to repair for '{}' (reconcile report is clean)",
113            export_name
114        );
115        return Ok(());
116    }
117
118    let report = execute_repair(&mut plan, &state, repair_plan)?;
119    emit_report(&report, &format)?;
120    Ok(())
121}
122
123fn load_or_build_reconcile(
124    plan: &ResolvedRunPlan,
125    state: &StateStore,
126    source: RepairReportSource,
127) -> Result<ReconcileReport> {
128    match source {
129        RepairReportSource::File(path) => {
130            let raw = std::fs::read_to_string(&path)
131                .map_err(|e| anyhow::anyhow!("cannot read reconcile report '{}': {}", path, e))?;
132            let r: ReconcileReport = serde_json::from_str(&raw)
133                .map_err(|e| anyhow::anyhow!("invalid reconcile report '{}': {}", path, e))?;
134            if r.export_name != plan.export_name {
135                anyhow::bail!(
136                    "repair: reconcile report is for export '{}' but config targets '{}'",
137                    r.export_name,
138                    plan.export_name
139                );
140            }
141            Ok(r)
142        }
143        RepairReportSource::Auto => reconcile_cmd::reconcile_chunked_fresh(plan, state),
144    }
145}
146
147fn execute_repair(
148    plan: &mut ResolvedRunPlan,
149    state: &StateStore,
150    repair_plan: RepairPlan,
151) -> Result<RepairReport> {
152    let mut results: Vec<(RepairAction, RepairOutcome)> =
153        Vec::with_capacity(repair_plan.actions.len());
154
155    // The chunk run whose `chunk_task` rows reconcile reads. Repair re-exports
156    // against the latest run for this export — the same run reconcile counted.
157    // Without it we can re-export the data but cannot point the recorded state
158    // at the fresh count, so `reconcile → repair → reconcile` could never
159    // converge (audit finding #7).
160    let run_id = state
161        .get_latest_chunk_run(&plan.export_name)?
162        .map(|(rid, _, _, _)| rid);
163
164    // One summary across the whole repair (matches the original single
165    // `RunSummary::new`): `record_part` appends every freshly-written part to
166    // `summary.manifest_parts`. We snapshot its length and `total_rows` around
167    // each single-chunk re-export to attribute the exact rows and the exact
168    // new file(s) to that chunk — no even-split lie.
169    let mut src = source::create_source(&plan.source)?;
170    let mut summary = RunSummary::new(plan);
171
172    // One destination handle for the whole repair: used to rename each
173    // repair-written part so its filename carries the ORIGINAL chunk index
174    // (audit L15). The single-chunk `Precomputed` source restarts enumeration
175    // at 0, so the writer always names the file `..._chunk0_...`; without this
176    // the file repairing chunk 2 would land as `chunk0`, no longer reflecting
177    // the logical chunk it repairs. Built once here (re-`create_destination`d
178    // again only in the manifest-rewrite step, which runs at most once).
179    let dest = crate::destination::create_destination(&plan.destination)?;
180
181    // Repair-written parts to record in the destination manifest, in order.
182    let mut new_parts: Vec<ManifestPart> = Vec::new();
183
184    for a in &repair_plan.actions {
185        let (start, end) = match (a.start_key.parse::<i64>(), a.end_key.parse::<i64>()) {
186            (Ok(s), Ok(e)) => (s, e),
187            _ => {
188                results.push((
189                    a.clone(),
190                    RepairOutcome::Skipped {
191                        reason: format!("unparseable chunk keys [{}..{}]", a.start_key, a.end_key),
192                    },
193                ));
194                continue;
195            }
196        };
197
198        let rows_before = summary.total_rows;
199        let parts_before = summary.manifest_parts.len();
200        let outcome = run_chunked_sequential(
201            &mut *src,
202            plan,
203            &mut summary,
204            Some(state),
205            ChunkSource::Precomputed(vec![(start, end)]),
206        );
207        match outcome {
208            Ok(()) => {
209                let rows = summary.total_rows - rows_before;
210                // Every part `record_part` appended for this single chunk — its
211                // path, rows, bytes, fingerprint, md5. One chunk yields one part
212                // unless max_file_size rotated it; either way these are exactly
213                // the new files the manifest must learn about.
214                let mut chunk_parts: Vec<ManifestPart> =
215                    summary.manifest_parts[parts_before..].to_vec();
216
217                // L15: the writer named each part `..._chunk0_...` (the
218                // single-chunk `Precomputed` source enumerates from 0), but this
219                // part repairs the logical chunk `a.chunk_index`. Rename the file
220                // and rewrite the recorded `path` so the name carries the real
221                // index — both `complete_chunk_task` (file_name) and the manifest
222                // append below then reference the corrected name. Best-effort
223                // (ADR-0012 M9 `move` semantics): the bytes are already durable,
224                // so a failed rename keeps the original name and warns rather than
225                // failing the repair. A no-op when the chunk index is already 0.
226                for p in &mut chunk_parts {
227                    if let Some(renamed) = relabel_repair_chunk_index(&p.path, a.chunk_index) {
228                        match dest.r#move(&p.path, &renamed) {
229                            Ok(()) => p.path = renamed,
230                            Err(e) => log::warn!(
231                                "repair: chunk {} re-exported but could not rename \
232                                 '{}' → '{}' to carry the original chunk index \
233                                 (the file is durable under its chunk0 name): {:#}",
234                                a.chunk_index,
235                                p.path,
236                                renamed,
237                                e
238                            ),
239                        }
240                    }
241                }
242
243                // (1) Close finding #7: point `chunk_task.rows_written` at the
244                //     freshly-exported count (and re-mark the task completed,
245                //     clearing any stale error) so the next reconcile compares
246                //     the live source count against the repaired count. The
247                //     `file_name` records the newest part for this chunk; if the
248                //     chunk rotated into several parts the latest is recorded
249                //     (reconcile keys on rows_written, not file_name).
250                if let Some(rid) = &run_id {
251                    let file_name = chunk_parts.last().map(|p| p.path.as_str());
252                    if let Err(e) = state.complete_chunk_task(rid, a.chunk_index, rows, file_name) {
253                        // Non-fatal to the data (the file is durable) but fatal
254                        // to trust — surface it loudly rather than report a
255                        // false "executed" that leaves reconcile stuck.
256                        log::warn!(
257                            "repair: chunk {} re-exported but chunk_task update failed — \
258                             reconcile will still report the old mismatch: {:#}",
259                            a.chunk_index,
260                            e
261                        );
262                    }
263                } else {
264                    log::warn!(
265                        "repair: chunk {} re-exported but no chunk run is recorded for export \
266                         '{}' — chunk_task could not be updated; reconcile will not converge",
267                        a.chunk_index,
268                        plan.export_name
269                    );
270                }
271
272                new_parts.extend(chunk_parts);
273                results.push((a.clone(), RepairOutcome::Executed { rows_written: rows }));
274            }
275            Err(e) => {
276                let msg = crate::redact::redact_error(&e);
277                results.push((a.clone(), RepairOutcome::Failed { error: msg }));
278            }
279        }
280    }
281
282    // (2) Close finding #8: record the repair-written parts in the destination
283    //     manifest so `rivet validate` no longer flags them as untracked. Best
284    //     effort and warn-on-fail (ADR-0001 I7 / ADR-0012): the parts are
285    //     already durable at the destination, so a manifest-rewrite failure
286    //     must not change the repair's exit code — but it is logged loudly so
287    //     the operator knows validate may still flag the files.
288    if !new_parts.is_empty()
289        && let Err(e) = record_repair_parts_in_manifest(&plan.destination, &new_parts)
290    {
291        log::warn!(
292            "repair: re-exported parts were written but the destination manifest could not be \
293             updated (the files are durable; `rivet validate` may flag them as untracked): {:#}",
294            e
295        );
296    }
297
298    Ok(RepairReport::new(
299        repair_plan,
300        format!("repair-{}", chrono::Utc::now().format("%Y%m%dT%H%M%S")),
301        results,
302    ))
303}
304
305/// Read the destination `manifest.json`, append the repair-written parts as new
306/// committed entries (fresh unique `part_id`s, recomputed `row_count` /
307/// `part_count`), and rewrite it. The originals stay recorded — repair is
308/// additive — so this closes the "untracked repair file" gap (finding #8)
309/// without dropping the manifest's history of the prior parts.
310///
311/// Returns `Err` if no manifest exists at the prefix (a repair against a prefix
312/// that was never finalized has nothing to amend) or if the read/write fails;
313/// the caller logs and continues since the data itself is already durable.
314fn record_repair_parts_in_manifest(
315    destination: &crate::config::DestinationConfig,
316    new_parts: &[ManifestPart],
317) -> Result<()> {
318    let dest = crate::destination::create_destination(destination)?;
319
320    // Manifests live at the prefix root (manifest_dir == "" for the local/path
321    // and bucket-prefix destinations repair supports); parts are recorded with
322    // prefix-relative paths, which is exactly what `record_part` stored.
323    let raw = match dest.head(MANIFEST_FILENAME)? {
324        Some(_) => crate::pipeline::validate_manifest::read_capped(
325            &*dest,
326            MANIFEST_FILENAME,
327            crate::pipeline::validate_manifest::MANIFEST_MAX_BYTES,
328        )?,
329        None => anyhow::bail!(
330            "no manifest.json at the destination prefix — cannot record repair parts \
331             (was the original export finalized?)"
332        ),
333    };
334    let mut manifest: RunManifest = serde_json::from_slice(&raw)
335        .map_err(|e| anyhow::anyhow!("destination manifest.json is unparseable: {e}"))?;
336
337    // Unique, monotonic part_ids (ADR-0012 M4): max existing + 1, incrementing.
338    let mut next_id = manifest.parts.iter().map(|p| p.part_id).max().unwrap_or(0) + 1;
339    for p in new_parts {
340        manifest.parts.push(ManifestPart {
341            part_id: next_id,
342            path: p.path.clone(),
343            rows: p.rows,
344            size_bytes: p.size_bytes,
345            content_fingerprint: p.content_fingerprint.clone(),
346            content_md5: p.content_md5.clone(),
347            status: PartStatus::Committed,
348        });
349        next_id += 1;
350    }
351
352    // Keep the manifest self-consistent (validate's step 2 checks this): the
353    // declared aggregates must match the committed parts after the append.
354    manifest.row_count = manifest.committed_rows();
355    manifest.part_count = manifest.committed_part_count() as u32;
356    manifest.finished_at = chrono::Utc::now().to_rfc3339();
357
358    // Route through the shared writer so the canonical `manifest.json`, the
359    // immutable run-unique `manifest-<run_id>.json` copy, and the `_SUCCESS`
360    // fingerprint all update TOGETHER. The old hand-rolled write touched ONLY
361    // the canonical file, leaving the run-unique sidecar stale — and `rivet load`
362    // is manifest-authoritative and reads the run-unique copies preferentially
363    // (`list_manifest_keys`), so it saw the PRE-repair part list, resolved those
364    // parts as present (repair is additive, never deletes), never fell back to
365    // the full listing, and SILENTLY dropped the repaired parts (the exact rows
366    // the operator ran `repair` to recover) with every count/gate green. The
367    // canonical-only write also left `_SUCCESS` fingerprinting the old bytes
368    // (a false `SuccessMarkerStale` on `rivet validate`). `write_manifest`
369    // re-emits `_SUCCESS` only for a `Success` manifest, so a repaired clean run
370    // keeps its marker (re-fingerprinted to the new bytes) and a repaired failed
371    // run stays marker-less — the terminal status is preserved.
372    crate::pipeline::manifest_writer::write_manifest(&*dest, &manifest)?;
373    Ok(())
374}
375
376/// Rewrite a repair-written part filename so it carries the ORIGINAL chunk
377/// index (L15). The single-chunk `Precomputed` source the repair runner uses
378/// enumerates from 0, so the writer always emits `..._chunk0_<nonce>.<ext>`
379/// (or a rotated `..._chunk0_<nonce>_p<n>.<ext>`); this replaces that `_chunk0_`
380/// token with `_chunk{original_chunk_index}_` so the name reflects the logical
381/// chunk it repairs.
382///
383/// Returns `None` when there is nothing to do: the chunk index is already 0
384/// (the name is already correct), or the path carries no `_chunk0_` token
385/// (defensive — an unexpected name shape is left untouched rather than mangled).
386///
387/// Targets the **rightmost** `_chunk0_`: everything after the chunk token is a
388/// 16-hex nonce, an optional `_p<n>` rotation suffix, and the extension — none
389/// of which can contain `_chunk0_`, so the rightmost match is the chunk token.
390fn relabel_repair_chunk_index(path: &str, original_chunk_index: i64) -> Option<String> {
391    if original_chunk_index == 0 {
392        return None;
393    }
394    let token = "_chunk0_";
395    let at = path.rfind(token)?;
396    Some(format!(
397        "{}_chunk{}_{}",
398        &path[..at],
399        original_chunk_index,
400        &path[at + token.len()..],
401    ))
402}
403
404fn emit_plan(plan: &RepairPlan, format: &RepairOutputFormat) -> Result<()> {
405    match format {
406        RepairOutputFormat::Pretty => print_plan_pretty(plan),
407        RepairOutputFormat::Json(None) => println!("{}", plan.to_json_pretty()?),
408        RepairOutputFormat::Json(Some(path)) => {
409            std::fs::write(path, plan.to_json_pretty()?)
410                .map_err(|e| anyhow::anyhow!("cannot write repair plan '{}': {}", path, e))?;
411            println!("Repair plan written to: {}", path);
412        }
413    }
414    Ok(())
415}
416
417fn emit_report(report: &RepairReport, format: &RepairOutputFormat) -> Result<()> {
418    match format {
419        RepairOutputFormat::Pretty => print_report_pretty(report),
420        RepairOutputFormat::Json(None) => println!("{}", report.to_json_pretty()?),
421        RepairOutputFormat::Json(Some(path)) => {
422            std::fs::write(path, report.to_json_pretty()?)
423                .map_err(|e| anyhow::anyhow!("cannot write repair report '{}': {}", path, e))?;
424            println!("Repair report written to: {}", path);
425        }
426    }
427    Ok(())
428}
429
430fn print_plan_pretty(plan: &RepairPlan) {
431    println!();
432    println!("  Export            : {}", plan.export_name);
433    println!("  Reconcile run     : {}", plan.reconcile_run_id);
434    println!("  Actions           : {}", plan.actions.len());
435    for a in &plan.actions {
436        println!(
437            "    • chunk {} [{}..{}] — {}",
438            a.chunk_index, a.start_key, a.end_key, a.reason
439        );
440    }
441    if !plan.skipped.is_empty() {
442        println!("  Skipped           :");
443        for s in &plan.skipped {
444            println!("    • {s}");
445        }
446    }
447    if plan.is_empty() && plan.skipped.is_empty() {
448        println!("  (nothing to repair)");
449    }
450    println!();
451}
452
453fn print_report_pretty(report: &RepairReport) {
454    println!();
455    println!("  Export       : {}", report.plan.export_name);
456    println!("  Repair run   : {}", report.repair_run_id);
457    println!(
458        "  Summary      : planned {} · executed {} · skipped {} · failed {} · rows {}",
459        report.summary.planned,
460        report.summary.executed,
461        report.summary.skipped,
462        report.summary.failed,
463        report.summary.rows_written,
464    );
465    for (a, out) in &report.results {
466        let tag = match out {
467            RepairOutcome::Executed { rows_written } => format!("executed ({rows_written} rows)"),
468            RepairOutcome::Skipped { reason } => format!("skipped ({reason})"),
469            RepairOutcome::Failed { error } => format!("failed ({error})"),
470        };
471        println!(
472            "    • chunk {} [{}..{}] — {tag}",
473            a.chunk_index, a.start_key, a.end_key
474        );
475    }
476    println!();
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use crate::plan::{PartitionKind, PartitionResult, ReconcileReport};
483
484    #[test]
485    fn plan_from_auto_would_derive_actions_from_reconcile() {
486        // Smoke-test the public derivation path without hitting the DB.
487        let partitions = vec![
488            PartitionResult::classify(
489                PartitionKind::Chunk,
490                "chunk 0 [1..100]".into(),
491                Some(100),
492                Some(100),
493            ),
494            PartitionResult::classify(
495                PartitionKind::Chunk,
496                "chunk 1 [101..200]".into(),
497                Some(100),
498                Some(90),
499            ),
500        ];
501        let r = ReconcileReport::new(
502            "orders".into(),
503            "rec-1".into(),
504            "chunked".into(),
505            partitions,
506        );
507        let plan = RepairPlan::from_reconcile(&r);
508        assert_eq!(plan.actions.len(), 1);
509        assert_eq!(plan.actions[0].chunk_index, 1);
510    }
511
512    // ── L15: repair-written filename carries the ORIGINAL chunk index ─────────
513
514    #[test]
515    fn relabel_repair_chunk_index_rewrites_chunk0_to_original() {
516        // The writer always emits `_chunk0_` for a single-chunk Precomputed
517        // source; repairing logical chunk 2 must rename it to `_chunk2_`.
518        let written = "orders_20260611_120000_chunk0_a1b2c3d4e5f6a7b8.parquet";
519        let renamed = relabel_repair_chunk_index(written, 2)
520            .expect("a non-zero chunk index must produce a renamed path");
521        assert_eq!(
522            renamed,
523            "orders_20260611_120000_chunk2_a1b2c3d4e5f6a7b8.parquet"
524        );
525        assert!(!renamed.contains("_chunk0_"), "no chunk0 token survives");
526    }
527
528    #[test]
529    fn relabel_repair_chunk_index_handles_rotated_part_suffix() {
530        // A max_file_size rotation suffixes `_p<n>` after the nonce; the chunk
531        // token still rewrites and the rotation suffix is preserved.
532        let written = "orders_20260611_120000_chunk0_a1b2c3d4e5f6a7b8_p1.parquet";
533        let renamed = relabel_repair_chunk_index(written, 3).unwrap();
534        assert_eq!(
535            renamed,
536            "orders_20260611_120000_chunk3_a1b2c3d4e5f6a7b8_p1.parquet"
537        );
538    }
539
540    #[test]
541    fn relabel_repair_chunk_index_is_noop_for_chunk_zero() {
542        // Chunk 0's name is already correct — nothing to rename, so no move.
543        let written = "orders_20260611_120000_chunk0_a1b2c3d4e5f6a7b8.parquet";
544        assert!(relabel_repair_chunk_index(written, 0).is_none());
545    }
546
547    #[test]
548    fn relabel_repair_chunk_index_leaves_unexpected_shapes_untouched() {
549        // A name without the chunk0 token (e.g. an unexpected writer shape) is
550        // left alone rather than mangled.
551        assert!(relabel_repair_chunk_index("orders_no_chunk_token.parquet", 5).is_none());
552    }
553
554    // ── repair must update the run-unique manifest copy the loader reads ──────
555    //
556    // RED before the fix: repair wrote ONLY the canonical manifest.json, leaving
557    // the immutable `manifest-<run_id>.json` sidecar stale. `rivet load` is
558    // manifest-authoritative and prefers the run-unique copy, so the repaired
559    // parts were silently dropped at load while every count/gate passed. Assert
560    // on the manifest COPY (not a data re-read — a re-read can't see a sidecar
561    // clobber; CLAUDE.md sidecar rule).
562    #[test]
563    fn repair_updates_the_run_unique_manifest_copy_not_just_the_canonical() {
564        use crate::config::{DestinationConfig, DestinationType};
565        use crate::manifest::{
566            MANIFEST_VERSION, ManifestDestination, ManifestSource, ManifestStatus,
567            run_unique_manifest_name,
568        };
569
570        let dir = tempfile::tempdir().unwrap();
571        let dpath = dir.path().to_str().unwrap().to_string();
572        let run_id = "orders_20260722T120000.000";
573        let part = |id: u32, rows: i64| ManifestPart {
574            part_id: id,
575            path: format!("orders_{id}.parquet"),
576            rows,
577            size_bytes: 100,
578            content_fingerprint: "xxh3:0000000000000000".into(),
579            content_md5: String::new(),
580            status: PartStatus::Committed,
581        };
582        // A finalized run: 2 committed parts, 20 rows.
583        let manifest = RunManifest {
584            manifest_version: MANIFEST_VERSION,
585            run_id: run_id.into(),
586            export_name: "public.orders".into(),
587            mode: "chunked".into(),
588            started_at: "2026-07-22T12:00:00Z".into(),
589            finished_at: "2026-07-22T12:00:10Z".into(),
590            status: ManifestStatus::Success,
591            source: ManifestSource {
592                engine: "postgres".into(),
593                schema: Some("public".into()),
594                table: Some("orders".into()),
595                extraction: None,
596            },
597            destination: ManifestDestination {
598                kind: "local".into(),
599                uri: dpath.clone(),
600            },
601            format: "parquet".into(),
602            compression: "zstd".into(),
603            schema_fingerprint: "xxh3:0123456789abcdef".into(),
604            row_count: 20,
605            part_count: 2,
606            parts: vec![part(1, 10), part(2, 10)],
607            column_checksums: None,
608            checksum_key_column: None,
609        };
610        let bytes = serde_json::to_vec_pretty(&manifest).unwrap();
611        // Both files as a real finalize would leave them; the run-unique copy is
612        // the one the loader reads and the one that went stale after repair.
613        std::fs::write(dir.path().join(MANIFEST_FILENAME), &bytes).unwrap();
614        std::fs::write(dir.path().join(run_unique_manifest_name(run_id)), &bytes).unwrap();
615
616        let dest_cfg = DestinationConfig {
617            destination_type: DestinationType::Local,
618            path: Some(dpath),
619            ..Default::default()
620        };
621        // Repair recovers one more part: id 3, 7 rows.
622        record_repair_parts_in_manifest(&dest_cfg, &[part(3, 7)]).unwrap();
623
624        let read = |name: String| -> RunManifest {
625            serde_json::from_slice(&std::fs::read(dir.path().join(name)).unwrap()).unwrap()
626        };
627        let run_unique = read(run_unique_manifest_name(run_id));
628        assert_eq!(
629            run_unique.parts.len(),
630            3,
631            "the loader-authoritative run-unique copy must list the repair part"
632        );
633        assert_eq!(
634            run_unique.row_count, 27,
635            "run-unique row_count must reflect the recovered rows (20 + 7)"
636        );
637        // The canonical stays consistent with it.
638        assert_eq!(read(MANIFEST_FILENAME.to_string()).parts.len(), 3);
639    }
640}