Skip to main content

rivet/pipeline/
validate_cmd.rs

1//! **Layer: Coordinator** (config → destination → verification → render)
2//!
3//! `rivet validate` — re-run the manifest-aware `--validate` pass against
4//! an existing destination, without performing an extraction.
5//!
6//! ADR-0013 amendment: this is **not** a new trust noun.  It is a standalone
7//! driver for the same M5/M6 verification surface that `rivet run --validate`
8//! already performs at end-of-run (see [`crate::pipeline::validate_manifest`]).
9//! The verdict shape is identical; the only difference is no source query,
10//! no extraction, no state writes.
11//!
12//! Use cases:
13//! - "Is the output at this prefix still complete?" — Airflow / CI poller
14//!   between runs.
15//! - "Did someone delete a part by mistake?" — operator triage on a
16//!   suspected-broken dataset.
17//! - "Does this legacy prefix have a manifest yet?" — fast check for M6.
18//! - "Was yesterday's run complete?" — `--date YYYY-MM-DD` or `--run-id`
19//!   re-targets a prior day's prefix without re-running the export
20//!   (v0.7.2 historical-validation flags).
21//!
22//! Out of scope:
23//! - Source-side reconciliation (`COUNT(*)`).  That's `--reconcile` /
24//!   `rivet reconcile`, which already exists.
25//! - Per-byte re-fingerprint of every part (`--validate --deep`, future).
26//!
27//! Exit code: `0` if `passed` (or the legacy-run case where the verifier
28//! cannot certify but no failures were seen); non-zero on any explicit
29//! failure (`PartMissing`, `PartSizeMismatch`, `SuccessMarkerStale`, …).
30
31use std::path::Path;
32
33use chrono::NaiveDate;
34
35use crate::config::Config;
36use crate::destination::placeholder::PlaceholderContext;
37use crate::error::Result;
38use crate::pipeline::ManifestVerification;
39use crate::pipeline::validate_manifest::{ValidateDepth, verify_at_destination};
40
41/// Output format mirroring the `rivet reconcile` / `rivet repair` pattern.
42pub enum ValidateOutputFormat {
43    /// Human-readable summary printed to stdout.
44    Pretty,
45    /// JSON to the given path or stdout if `None`.
46    Json(Option<String>),
47}
48
49/// Re-targeting overrides for `rivet validate`.
50///
51/// Default (`ValidateTarget::default()`) reproduces the v0.7.1 behaviour:
52/// resolve `{date}` against today's UTC date, with no `{run_id}`
53/// substitution, and use the config's destination prefix/path unchanged.
54#[derive(Debug, Default, Clone)]
55pub struct ValidateTarget {
56    /// `--date YYYY-MM-DD` — override the date used for `{date}`.
57    pub date: Option<NaiveDate>,
58    /// `--run-id RID` — substitute `{run_id}` in the destination template.
59    pub run_id: Option<String>,
60    /// `--prefix STRING` — bypass placeholder resolution entirely and
61    /// verify exactly this prefix.  Replaces both `prefix` and `path`.
62    pub prefix_override: Option<String>,
63    /// `--depth light|sample|full` — the graded verify layer (see
64    /// [`ValidateDepth`]).  Defaults (`ValidateDepth::default()` →
65    /// [`ValidateDepth::Full`]) to the pre-graded behaviour: all five
66    /// sections **plus** the Form B value re-read, so existing callers
67    /// constructing `ValidateTarget::default()` are unchanged.
68    pub depth: ValidateDepth,
69}
70
71impl ValidateTarget {
72    fn placeholder_context(&self, export_name: &str) -> PlaceholderContext {
73        let mut ctx = match self.date {
74            Some(d) => PlaceholderContext::for_date(d, export_name),
75            None => PlaceholderContext::for_today(export_name),
76        };
77        if let Some(rid) = &self.run_id {
78            ctx = ctx.with_run_id(rid.clone());
79        }
80        ctx
81    }
82}
83
84/// Driver for `rivet validate <export>` (or every export when
85/// `export_name` is `None`).
86///
87/// Returns `Err` on the first explicit verification failure across the
88/// requested exports so an Airflow / CI step can branch on the exit code.
89/// Per-export verdicts are still printed to stdout / written to JSON for
90/// every export, including subsequent ones — the bail at the end is the
91/// last action.
92pub fn run_validate_command(
93    config_path: &str,
94    export_name: Option<&str>,
95    format: ValidateOutputFormat,
96    target: ValidateTarget,
97) -> Result<()> {
98    let config = Config::load_with_params(config_path, None)?;
99
100    let exports: Vec<&crate::config::ExportConfig> = match export_name {
101        Some(name) => match config.exports.iter().find(|e| e.name == name) {
102            Some(e) => vec![e],
103            None => anyhow::bail!("export '{}' not found in config", name),
104        },
105        None => config.exports.iter().collect(),
106    };
107
108    if exports.is_empty() {
109        anyhow::bail!("no exports defined in config — nothing to validate");
110    }
111
112    // `--prefix` only makes sense for a single export; with multiple
113    // exports it would silently re-point all of them at the same physical
114    // bytes.  Catch this at the boundary so we never head-check the wrong
115    // dataset under operator triage pressure.
116    if target.prefix_override.is_some() && exports.len() > 1 {
117        anyhow::bail!(
118            "--prefix requires --export <name>: cannot apply one override to {} exports",
119            exports.len()
120        );
121    }
122
123    let mut all_results: Vec<ExportVerdict> = Vec::with_capacity(exports.len());
124    let mut hard_failures: Vec<String> = Vec::new();
125
126    for export in &exports {
127        // Apply the operator-supplied re-targeting if any, else fall back
128        // to today's UTC date (same shape `rivet run` resolves at write
129        // time).
130        let ctx = target.placeholder_context(&export.name);
131        let mut expanded_dest =
132            crate::destination::placeholder::expand_destination(export.destination.clone(), &ctx);
133        if let Some(p) = &target.prefix_override {
134            // Bypass placeholder resolution: trust the operator's literal
135            // prefix.  Replace both `path` (local) and `prefix` (cloud)
136            // so whichever the backend reads picks up the override.
137            expanded_dest.path = Some(p.clone());
138            expanded_dest.prefix = Some(p.clone());
139        }
140        // A CDC export's output is one table prefix per captured table: a
141        // `tables:` stream fans each out under `<base>/<table>/` (via
142        // `cdc_job::dest_for_table`), a single-table export uses its prefix
143        // directly. Either way the change parts live at the table prefix with an
144        // optional nested `snapshot/` dataset, and a fanned-out base holds no
145        // manifest of its own — so descend per table (using the writer's builder)
146        // rather than verifying the base, which would read back "legacy_run" and
147        // fail the `__pos` check on a missing part.
148        let multiplex = if export.mode == crate::config::ExportMode::Cdc {
149            export.tables.as_deref().filter(|t| !t.is_empty())
150        } else {
151            None
152        };
153        let has_snapshot = export.mode == crate::config::ExportMode::Cdc
154            && export.cdc.as_ref().and_then(|c| c.initial)
155                == Some(crate::config::CdcInitialMode::Snapshot);
156        match multiplex {
157            Some(tables) => {
158                for table in tables {
159                    verify_cdc_table(
160                        crate::pipeline::cdc_job::dest_for_table(&expanded_dest, table),
161                        format!("{}/{}", export.name, table),
162                        export,
163                        &target,
164                        has_snapshot,
165                        &mut all_results,
166                        &mut hard_failures,
167                    );
168                }
169            }
170            None if export.mode == crate::config::ExportMode::Cdc => {
171                // Single-table CDC: the export prefix IS the one table prefix.
172                verify_cdc_table(
173                    expanded_dest,
174                    export.name.clone(),
175                    export,
176                    &target,
177                    has_snapshot,
178                    &mut all_results,
179                    &mut hard_failures,
180                );
181            }
182            None => {
183                // Batch export: verify the prefix directly — no CDC `__pos`
184                // continuity, no nested snapshot dataset.
185                verify_one_prefix(
186                    expanded_dest,
187                    export.name.clone(),
188                    export,
189                    &target,
190                    false,
191                    false,
192                    &mut all_results,
193                    &mut hard_failures,
194                );
195            }
196        }
197    }
198
199    match format {
200        ValidateOutputFormat::Pretty => render_pretty(&all_results, &hard_failures),
201        ValidateOutputFormat::Json(out_path) => {
202            render_json(&all_results, &hard_failures, out_path)?
203        }
204    }
205
206    // Exit-code policy: the standalone driver fails when an export's
207    // verdict surfaced an explicit failure it could not pass over
208    // (`verdict_fails_exit`) — an M5 verification failure on a found
209    // manifest (missing part, size mismatch, stale _SUCCESS,
210    // self-inconsistent manifest) or a manifest that could not even be
211    // read (`ManifestReadError`: `manifest_found` is false, but the
212    // verifier has a concrete reason to refuse, not a legacy prefix).
213    // Surplus untracked objects (`UntrackedObject`) are surfaced in
214    // `failures` for operator audit but do NOT flip `passed`, because
215    // their cleanup is M9's job (resume), not validate's.  An operator
216    // who wants strict "no surplus allowed" can grep the JSON report for
217    // `kind: untracked_object` themselves; a future
218    // `rivet validate --strict` flag may surface that exit-code mode if
219    // demand appears (out of scope for this PR).
220    //
221    // Legacy runs (M6) keep exit 0: `passed: false` with no failures
222    // means "verifier cannot certify", not "verifier found a problem".
223    // A failing verdict is either VERIFIED-WRONG (a check ran and the data is bad:
224    // missing part, size/checksum/value mismatch, stale _SUCCESS, __pos gap,
225    // self-inconsistent manifest) or purely COULD-NOT-VERIFY (its only failures are
226    // I/O read/list errors against the destination). Only verified-wrong is the
227    // data-integrity stop-the-line class (exit 3); could-not-verify is operational
228    // (exit 1), so a chmod-000 manifest or a transient list blip does not page a
229    // corruption incident (#7 bughunt).
230    let verified_wrong = all_results
231        .iter()
232        .filter(|r| {
233            verdict_fails_exit(&r.verification) && r.verification.has_verified_wrong_failure()
234        })
235        .count();
236    let could_not_verify_verdicts = all_results
237        .iter()
238        .filter(|r| {
239            verdict_fails_exit(&r.verification) && !r.verification.has_verified_wrong_failure()
240        })
241        .count();
242    if verified_wrong > 0 {
243        // Typed so a scheduler stops rather than blindly retries. Could-not-verify
244        // verdicts + hard_failures fold into the count (the run did not fully
245        // certify), but the CLASS is driven by the verified-wrong verdict.
246        return Err(crate::error::DataIntegrityError::new(format!(
247            "rivet validate: {} export(s) failed verification",
248            hard_failures.len() + verified_wrong + could_not_verify_verdicts
249        ))
250        .into());
251    }
252    let could_not_verify = hard_failures.len() + could_not_verify_verdicts;
253    if could_not_verify > 0 {
254        // Could-not-verify only (no verified-wrong verdict): operational, generic
255        // exit 1 — retry, don't stop-the-line.
256        anyhow::bail!("rivet validate: {could_not_verify} export(s) could not be verified");
257    }
258    Ok(())
259}
260
261/// Exit-code predicate for one export's verdict: non-zero iff the verifier
262/// surfaced an explicit failure (`has_failures` — "a reason an orchestrator
263/// should refuse the run") on a verdict that did not pass.  Both documented
264/// exit-0 cases survive: legacy runs (M6 — `passed: false` with no failures
265/// is "cannot certify", not "found a problem") and advisory-only verdicts
266/// (`UntrackedObject` never flips `passed`).
267fn verdict_fails_exit(v: &ManifestVerification) -> bool {
268    !v.passed && v.has_failures()
269}
270
271/// Per-export verdict plus the resolved physical prefix the verifier
272/// looked at — surfaced in both pretty and JSON output so an operator can
273/// confirm at a glance which bytes were checked.
274struct ExportVerdict {
275    name: String,
276    resolved_prefix: String,
277    verification: ManifestVerification,
278}
279
280/// Render the destination's resolved prefix for human/JSON output.
281///
282/// Cloud backends carry the data location in `prefix`; the local backend
283/// uses `path`.  Falling back to `<unresolved>` should never fire under
284/// normal config (clap + Config::load enforce one of the two) but keeps
285/// `validate` from panicking if a future config shape lands here.
286fn resolved_prefix_for_display(dest: &crate::config::DestinationConfig) -> String {
287    dest.prefix
288        .clone()
289        .or_else(|| dest.path.clone())
290        .unwrap_or_else(|| "<unresolved>".into())
291}
292
293/// Verify one CDC table's output at `table_dest`: its initial-snapshot dataset
294/// (when `has_snapshot`, a nested `snapshot/` prefix with its OWN manifest) plus
295/// its change parts. Shared by each table of a `tables:` stream AND a
296/// single-table CDC export — both write the same `<prefix>/{snapshot/, cdc-*}`
297/// shape, so both certify the snapshot and drop its (separately-verified) files
298/// from the change prefix's untracked-surplus advisory.
299#[allow(clippy::too_many_arguments)]
300fn verify_cdc_table(
301    table_dest: crate::config::DestinationConfig,
302    display_name: String,
303    export: &crate::config::ExportConfig,
304    target: &ValidateTarget,
305    has_snapshot: bool,
306    all_results: &mut Vec<ExportVerdict>,
307    hard_failures: &mut Vec<String>,
308) {
309    // The snapshot is a batch dataset with its own manifest — verify it, but with
310    // no `__pos` continuity check (snapshot rows carry no log position).
311    if has_snapshot {
312        let snap = crate::pipeline::cdc_job::dest_for_table(&table_dest, "snapshot");
313        verify_one_prefix(
314            snap,
315            format!("{display_name}/snapshot"),
316            export,
317            target,
318            false,
319            false,
320            all_results,
321            hard_failures,
322        );
323    }
324    verify_one_prefix(
325        table_dest,
326        display_name,
327        export,
328        target,
329        true,
330        has_snapshot,
331        all_results,
332        hard_failures,
333    );
334}
335
336/// Verify ONE resolved prefix (an export's whole destination, or one table's
337/// sub-prefix of a `tables:` CDC stream) and append its verdict / failures.
338/// `display_name` labels the verdict (`export` or `export/table[/snapshot]`);
339/// `run_cdc_pos_check` gates the CDC `__pos`-continuity re-read (off for a
340/// snapshot sub-prefix, whose batch rows carry no log position).
341#[allow(clippy::too_many_arguments)]
342fn verify_one_prefix(
343    expanded_dest: crate::config::DestinationConfig,
344    display_name: String,
345    export: &crate::config::ExportConfig,
346    target: &ValidateTarget,
347    run_cdc_pos_check: bool,
348    drop_snapshot_untracked: bool,
349    all_results: &mut Vec<ExportVerdict>,
350    hard_failures: &mut Vec<String>,
351) {
352    let resolved_prefix = resolved_prefix_for_display(&expanded_dest);
353    let dest = match crate::destination::create_destination(&expanded_dest) {
354        Ok(d) => d,
355        Err(e) => {
356            hard_failures.push(format!(
357                "export '{}' (prefix: {}): could not open destination: {:#}",
358                display_name, resolved_prefix, e
359            ));
360            return;
361        }
362    };
363    // Streaming destinations have no prefix to verify — note and skip.
364    if dest.capabilities().commit_protocol == crate::destination::WriteCommitProtocol::Streaming {
365        log::info!(
366            "export '{}': streaming destination, skipping (nothing to verify)",
367            display_name
368        );
369        return;
370    }
371    match verify_at_destination(&*dest, "", target.depth) {
372        Ok(mut v) => {
373            // Apply this export's `verify` policy: `content` fails the
374            // verdict when any part is only size-verified (review D).
375            v.enforce_content_policy(export.verify.requires_content());
376            // A `tables:` CDC table prefix holds its initial snapshot under a
377            // nested `snapshot/` dataset with its OWN manifest (verified as a
378            // separate prefix above). The listing here therefore sees those files
379            // as "untracked" surplus — but they are a known, separately-certified
380            // dataset, not orphans. Drop that advisory so the operator isn't told
381            // real data is stray. (Non-fatal already, so `passed` is unaffected.)
382            if drop_snapshot_untracked {
383                v.failures.retain(|f| {
384                    !matches!(
385                        f,
386                        crate::pipeline::validate_manifest::Failure::UntrackedObject { key, .. }
387                            if key.starts_with("snapshot/")
388                    )
389                });
390            }
391            // Finding #20: when the operator pinned a literal `--prefix`,
392            // they asserted a real dataset lives here. An absent manifest is
393            // then NOT the benign M6 legacy-run case (exit 0) — it almost
394            // always means the prefix was never written (a misconfigured CI
395            // gate `rivet validate && deploy` sailing past nothing). Escalate
396            // that exact shape (no manifest, no other failure) to a fatal
397            // `ManifestRequiredButAbsent` so the exit gate refuses it loudly
398            // instead of silently passing. No-op for every other shape (a
399            // real manifest, or an absent one already carrying a read error).
400            if target.prefix_override.is_some() {
401                v.require_manifest_present(&resolved_prefix);
402            }
403            // Capture the verdict before `v` is moved into the result: the
404            // deeper Form B checksum re-read below must run *only* on a
405            // manifest that was found and passed the standard checks.
406            let manifest_verified = v.manifest_found && v.passed;
407            all_results.push(ExportVerdict {
408                name: display_name.clone(),
409                resolved_prefix,
410                verification: v,
411            });
412            // CDC-specific: re-read the parts and confirm `__pos` stayed in
413            // source-log order (no reorder / no part-boundary overlap). The
414            // manifest check above already covered per-part MD5 / size / _SUCCESS.
415            // Full-depth only — like Form B below it downloads every part, so a
416            // light/sample run skips it (keeps the depth contract consistent).
417            if target.depth.runs_part_download()
418                && run_cdc_pos_check
419                && export.format == crate::config::FormatType::Parquet
420            {
421                match crate::source::cdc::validate::check_positions(&*dest, "") {
422                    Ok(pc) if pc.is_ok() => log::info!(
423                        "export '{}': cdc __pos continuity OK — {} changes across {} parts, range {:?}..{:?}",
424                        display_name,
425                        pc.rows,
426                        pc.parts,
427                        pc.first,
428                        pc.last
429                    ),
430                    // A __pos VIOLATION is verified-wrong (a gap/dup in the change
431                    // stream) — data-integrity (exit 3), same class as a value
432                    // mismatch. Route it into THIS export's verdict, not
433                    // hard_failures (bughunt MED: it was exit 1, inconsistent).
434                    Ok(pc) => {
435                        if let Some(ev) = all_results.last_mut() {
436                            ev.verification.passed = false;
437                            for viol in &pc.violations {
438                                ev.verification.failures.push(
439                                    crate::pipeline::validate_manifest::Failure::CdcPositionViolation {
440                                        detail: format!("export '{}': {}", display_name, viol),
441                                    },
442                                );
443                            }
444                        }
445                    }
446                    // A check-RUN failure (couldn't read the parts) is operational
447                    // could-not-verify → hard failure (exit 1), not corruption.
448                    Err(e) => hard_failures.push(format!(
449                        "export '{}': cdc __pos check could not complete: {:#}",
450                        display_name, e
451                    )),
452                }
453            }
454            // Form B: re-read the parts and verify the per-column value
455            // checksums recorded in the manifest (catches an Arrow→Parquet
456            // encode / post-write fault the in-process Form A cannot see).
457            // Gated on a found+passed manifest: an absent (legacy pass) or
458            // unreadable manifest is already accounted for above, so re-reading
459            // it here would either break the legacy pass or double-count.
460            //
461            // Graded depth: Form B is the **only** part-download step, so it
462            // runs at `--depth full` alone.  `light` and `sample` deliberately
463            // skip it — `sample` is "all structural checks, no part bodies".
464            if target.depth.runs_part_download()
465                && manifest_verified
466                && export.format == crate::config::FormatType::Parquet
467            {
468                match crate::source::value_checksum::validate_manifest_checksums(&*dest, "") {
469                    // A value-checksum MISMATCH is post-write corruption
470                    // (verified-wrong): fold it into THIS export's verdict so the
471                    // headline reads FAILED and the exit gate classifies it as
472                    // data-integrity (exit 3), not generic (#104). The verdict is
473                    // the just-pushed `all_results` entry (nothing pushes between).
474                    Ok(Some(detail)) => {
475                        if let Some(ev) = all_results.last_mut() {
476                            ev.verification.passed = false;
477                            ev.verification.failures.push(
478                                crate::pipeline::validate_manifest::Failure::ValueChecksumMismatch {
479                                    detail: format!("export '{}': {}", display_name, detail),
480                                },
481                            );
482                        }
483                    }
484                    // An OPERATIONAL failure (could not read the manifest / a part)
485                    // is could-not-verify, NOT corruption — a hard failure (exit 1),
486                    // never mislabelled data-integrity (bughunt MED).
487                    Err(e) => hard_failures.push(format!(
488                        "export '{}': value-checksum re-read could not complete: {:#}",
489                        display_name, e
490                    )),
491                    Ok(None) => {}
492                }
493            }
494        }
495        Err(e) => {
496            hard_failures.push(format!(
497                "export '{}' (prefix: {}): verify_at_destination failed: {:#}",
498                display_name, resolved_prefix, e
499            ));
500        }
501    }
502}
503
504fn render_pretty(results: &[ExportVerdict], hard_failures: &[String]) {
505    use std::io::Write;
506    let stdout = std::io::stdout();
507    let mut h = stdout.lock();
508
509    for r in results {
510        let _ = writeln!(h, "── {} ──", r.name);
511        let _ = writeln!(h, "  prefix:    {}", r.resolved_prefix);
512        let v = &r.verification;
513        // Graded verify layer: surface how deep this pass went so a reader
514        // knows whether a PASSED verdict reconciled parts (sample/full) or
515        // only the manifest + _SUCCESS (light).
516        let _ = writeln!(h, "  depth:     {}", v.depth_level);
517        if v.legacy_run {
518            let _ = writeln!(
519                h,
520                "  status:    legacy_run (no manifest at destination — pre-0.7.0 prefix)"
521            );
522            continue;
523        }
524        if !v.manifest_found {
525            let _ = writeln!(h, "  status:    NO MANIFEST");
526            // A read-error verdict lands here (manifest present but
527            // unreadable, or head failed): its `failures` are the
528            // operator's only signal, so print them before bailing out
529            // of this export's section.  Each line carries its stable
530            // `RIVET_VERIFY_*` code in brackets so CI can grep it.
531            for failure in &v.failures {
532                let _ = writeln!(h, "  failure:   [{}] {}", failure.error_code(), failure);
533            }
534            continue;
535        }
536        let _ = writeln!(
537            h,
538            "  status:    {}",
539            if v.passed { "PASSED" } else { "FAILED" }
540        );
541        let _ = writeln!(
542            h,
543            "  parts:     {} verified ({} md5, {} size-only), {} failed",
544            v.parts_verified,
545            v.parts_md5_verified,
546            v.parts_verified.saturating_sub(v.parts_md5_verified),
547            v.parts_failed
548        );
549        let _ = writeln!(
550            h,
551            "  _SUCCESS:  {}",
552            if v.success_marker_consistent {
553                "consistent"
554            } else if v.failures.iter().any(|f| matches!(
555                f,
556                crate::pipeline::ManifestVerificationFailure::SuccessMarkerStale { .. }
557                    | crate::pipeline::ManifestVerificationFailure::SuccessMarkerMalformed { .. }
558                    | crate::pipeline::ManifestVerificationFailure::SuccessMarkerReadError { .. }
559            )) {
560                "INCONSISTENT (see failures)"
561            } else {
562                "absent (no signal)"
563            }
564        );
565        let _ = writeln!(
566            h,
567            "  manifest:  {}",
568            if v.manifest_self_consistent {
569                "self-consistent"
570            } else {
571                "INCONSISTENT (see failures)"
572            }
573        );
574        for failure in &v.failures {
575            // `Failure: Display` is the single source of truth for the message;
576            // same string the run report uses.  L14: advisory (non-fatal)
577            // entries — `UntrackedObject` surplus — are labelled "warning:" not
578            // "failure:".  They never flip `passed` and never change the exit
579            // code (cleanup is `--resume`'s job, M9), so rendering them as
580            // "failure:" beside exit 0 was contradictory.  Fatal failures keep
581            // the "failure:" label.
582            let label = if failure.is_fatal() {
583                "failure:"
584            } else {
585                "warning:"
586            };
587            // Stable `RIVET_VERIFY_*` code in brackets ahead of the human
588            // message so an orchestrator can branch on the code without
589            // parsing the prose.
590            let _ = writeln!(h, "  {}   [{}] {}", label, failure.error_code(), failure);
591        }
592    }
593
594    if !hard_failures.is_empty() {
595        let _ = writeln!(h);
596        let _ = writeln!(h, "── errors ──");
597        for e in hard_failures {
598            let _ = writeln!(h, "  {}", e);
599        }
600    }
601    let _ = h.flush();
602}
603
604/// Serialize one [`ManifestVerificationFailure`] to JSON with its stable
605/// `RIVET_VERIFY_*` code injected next to `kind`.
606///
607/// The derive emits `{ "kind": "...", <variant fields> }`; this adds `"code"`
608/// so a consumer can branch on the code without re-deriving it from `kind`.
609/// Returns the enriched object (or the unmodified serde value if, impossibly,
610/// the failure didn't serialize as a JSON object).
611fn failure_json(f: &crate::pipeline::ManifestVerificationFailure) -> serde_json::Value {
612    let mut value = serde_json::json!(f);
613    if let Some(obj) = value.as_object_mut() {
614        obj.insert(
615            "code".to_string(),
616            serde_json::Value::String(f.error_code().to_string()),
617        );
618    }
619    value
620}
621
622/// Serialize a [`ManifestVerification`] to JSON, replacing the derive's plain
623/// `failures` array with one whose entries each carry their `RIVET_VERIFY_*`
624/// `code`.  All other fields (`depth_level`, `passed`, counts, …) ride the
625/// derive unchanged, so the stable wire contract is preserved and only widened.
626fn verification_json(v: &ManifestVerification) -> serde_json::Value {
627    let mut value = serde_json::json!(v);
628    if let Some(obj) = value.as_object_mut() {
629        let failures: Vec<serde_json::Value> = v.failures.iter().map(failure_json).collect();
630        obj.insert("failures".to_string(), serde_json::Value::Array(failures));
631    }
632    value
633}
634
635fn render_json(
636    results: &[ExportVerdict],
637    hard_failures: &[String],
638    out_path: Option<String>,
639) -> Result<()> {
640    // L14: surface advisory (non-fatal) entries — `UntrackedObject` surplus —
641    // in a dedicated top-level `warnings` array so a consumer can tell at a
642    // glance that "failures means failures".  The per-export
643    // `verification.failures` array is the stable wire contract (consumers
644    // branch on `failures[].kind`), so advisory entries stay there too — this
645    // is an additive lens over the same data, not a relocation.  Each entry
646    // also carries its stable `RIVET_VERIFY_*` `code` next to `kind`.
647    let warnings: Vec<serde_json::Value> = results
648        .iter()
649        .flat_map(|r| {
650            r.verification
651                .failures
652                .iter()
653                .filter(|f| !f.is_fatal())
654                .map(move |f| {
655                    serde_json::json!({
656                        "export_name": r.name,
657                        "warning": failure_json(f),
658                    })
659                })
660        })
661        .collect();
662
663    let payload = serde_json::json!({
664        "exports": results
665            .iter()
666            .map(|r| {
667                serde_json::json!({
668                    "export_name": r.name,
669                    "resolved_prefix": r.resolved_prefix,
670                    "verification": verification_json(&r.verification),
671                })
672            })
673            .collect::<Vec<_>>(),
674        "warnings": warnings,
675        "errors": hard_failures,
676    });
677    let serialized = serde_json::to_string_pretty(&payload)?;
678    match out_path {
679        Some(p) => {
680            std::fs::write(Path::new(&p), &serialized)?;
681            log::info!("rivet validate: wrote JSON report to {}", p);
682        }
683        None => {
684            println!("{}", serialized);
685        }
686    }
687    Ok(())
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    // ── ValidateTarget::placeholder_context ────────────────────────────────
695
696    #[test]
697    fn target_default_uses_today() {
698        let target = ValidateTarget::default();
699        let ctx = target.placeholder_context("orders");
700        assert_eq!(ctx.date, chrono::Utc::now().date_naive());
701        assert_eq!(ctx.export_name, "orders");
702        assert!(ctx.run_id.is_none());
703    }
704
705    #[test]
706    fn target_with_date_overrides_today() {
707        let target = ValidateTarget {
708            date: Some(NaiveDate::from_ymd_opt(2026, 5, 21).unwrap()),
709            ..Default::default()
710        };
711        let ctx = target.placeholder_context("orders");
712        assert_eq!(ctx.date, NaiveDate::from_ymd_opt(2026, 5, 21).unwrap());
713        assert!(ctx.run_id.is_none());
714    }
715
716    #[test]
717    fn target_composes_date_and_run_id() {
718        // Regression for the "run yesterday, validate today" scenario:
719        // operator passes both --date and --run-id; the resolver must see
720        // both.
721        let target = ValidateTarget {
722            date: Some(NaiveDate::from_ymd_opt(2026, 5, 21).unwrap()),
723            run_id: Some("r-abc123".into()),
724            prefix_override: None,
725            ..Default::default()
726        };
727        let ctx = target.placeholder_context("orders");
728        assert_eq!(ctx.date, NaiveDate::from_ymd_opt(2026, 5, 21).unwrap());
729        assert_eq!(ctx.run_id.as_deref(), Some("r-abc123"));
730    }
731
732    // ── resolved_prefix_for_display ────────────────────────────────────────
733
734    #[test]
735    fn resolved_prefix_prefers_cloud_prefix_over_path() {
736        let dest = crate::config::DestinationConfig {
737            destination_type: crate::config::DestinationType::S3,
738            prefix: Some("exports/2026-05-21/orders/".into()),
739            path: Some("/scratch".into()),
740            ..Default::default()
741        };
742        assert_eq!(
743            resolved_prefix_for_display(&dest),
744            "exports/2026-05-21/orders/",
745        );
746    }
747
748    #[test]
749    fn resolved_prefix_falls_back_to_path_when_prefix_missing() {
750        let dest = crate::config::DestinationConfig {
751            destination_type: crate::config::DestinationType::Local,
752            prefix: None,
753            path: Some("/data/out".into()),
754            ..Default::default()
755        };
756        assert_eq!(resolved_prefix_for_display(&dest), "/data/out");
757    }
758
759    // ── verdict_fails_exit (exit-code policy) ──────────────────────────────
760
761    use crate::pipeline::ManifestVerificationFailure as VFailure;
762
763    /// Verdict shape `verify_at_destination` returns when `manifest.json`
764    /// exists but cannot be read: not legacy, not passed, one explicit
765    /// `ManifestReadError`.
766    fn read_error_verdict() -> ManifestVerification {
767        ManifestVerification {
768            legacy_run: false,
769            failures: vec![VFailure::ManifestReadError {
770                detail: "permission denied".into(),
771            }],
772            ..ManifestVerification::legacy()
773        }
774    }
775
776    #[test]
777    fn exit_gate_counts_manifest_read_error_as_failure() {
778        assert!(verdict_fails_exit(&read_error_verdict()));
779    }
780
781    #[test]
782    fn exit_gate_keeps_legacy_run_at_zero() {
783        // M6: no manifest, no failures — "cannot certify" is not "found a
784        // problem".
785        assert!(!verdict_fails_exit(&ManifestVerification::legacy()));
786    }
787
788    #[test]
789    fn exit_gate_keeps_advisory_untracked_at_zero() {
790        let v = ManifestVerification {
791            manifest_found: true,
792            legacy_run: false,
793            passed: true,
794            parts_verified: 1,
795            failures: vec![VFailure::UntrackedObject {
796                key: "stray.parquet".into(),
797                size_bytes: 9,
798            }],
799            ..ManifestVerification::legacy()
800        };
801        assert!(!verdict_fails_exit(&v));
802    }
803
804    #[test]
805    fn exit_gate_counts_fatal_failure_on_found_manifest() {
806        let v = ManifestVerification {
807            manifest_found: true,
808            legacy_run: false,
809            failures: vec![VFailure::PartMissing {
810                part_id: 1,
811                path: "part-000001.parquet".into(),
812            }],
813            ..ManifestVerification::legacy()
814        };
815        assert!(verdict_fails_exit(&v));
816    }
817
818    #[test]
819    fn value_checksum_mismatch_flips_verdict_and_fails_exit_gate() {
820        // #104: a `--depth full` value-checksum mismatch is post-write
821        // corruption (verified-wrong). The reclassification folds it into the
822        // verdict — sets passed=false + pushes ValueChecksumMismatch — so the
823        // exit gate fires (DataIntegrity / exit 3) instead of leaving a PASSED
824        // headline + a generic exit 1.
825        let reclassified = ManifestVerification {
826            manifest_found: true,
827            legacy_run: false,
828            passed: false,
829            failures: vec![VFailure::ValueChecksumMismatch {
830                detail: "export 'e': column 'id' checksum differs".into(),
831            }],
832            ..ManifestVerification::legacy()
833        };
834        assert!(
835            verdict_fails_exit(&reclassified),
836            "a value-checksum mismatch must fail the exit gate (exit 3), not pass silently"
837        );
838
839        // The bug this closes: as a `hard_failure` the verdict kept passed=true,
840        // so the gate (correctly, for passed=true) did NOT fire — status PASSED,
841        // exit 1. Proven here so a regression that stops flipping `passed` is caught.
842        let not_reclassified = ManifestVerification {
843            manifest_found: true,
844            legacy_run: false,
845            passed: true,
846            failures: vec![VFailure::ValueChecksumMismatch {
847                detail: "same corruption, left in hard_failures".into(),
848            }],
849            ..ManifestVerification::legacy()
850        };
851        assert!(
852            !verdict_fails_exit(&not_reclassified),
853            "with passed still true the gate wrongly passes — this is exactly bug #104"
854        );
855    }
856
857    // ── run_validate_command end-to-end (local destination; the source URL
858    //     is never dialed — see tests/validate_historical.rs) ──────────────
859
860    use crate::manifest::{
861        MANIFEST_VERSION, ManifestDestination, ManifestPart, ManifestSource, ManifestStatus,
862        PartStatus, RunManifest,
863    };
864
865    fn success_manifest(parts: Vec<ManifestPart>) -> RunManifest {
866        let row_count: i64 = parts.iter().map(|p| p.rows).sum();
867        let part_count = parts.len() as u32;
868        RunManifest {
869            row_hash: None,
870            mode: "batch".to_string(),
871            manifest_version: MANIFEST_VERSION,
872            run_id: "r-validate-cmd".into(),
873            export_name: "orders".into(),
874            started_at: "2026-06-09T12:00:00Z".into(),
875            finished_at: "2026-06-09T12:01:00Z".into(),
876            status: ManifestStatus::Success,
877            source: ManifestSource {
878                engine: "postgres".into(),
879                schema: Some("public".into()),
880                table: Some("orders".into()),
881                extraction: None,
882            },
883            destination: ManifestDestination {
884                kind: "local".into(),
885                uri: "file:///tmp/out".into(),
886            },
887            format: "parquet".into(),
888            compression: "zstd".into(),
889            schema_fingerprint: "xxh3:0123456789abcdef".into(),
890            row_count,
891            part_count,
892            parts,
893            column_checksums: None,
894            checksum_key_column: None,
895        }
896    }
897
898    /// Land `manifest.json` + `_SUCCESS` at `prefix` via the public writer
899    /// surface — same path the `rivet run` end-of-run writer takes.
900    fn stage_dataset(prefix: &Path, m: &RunManifest) {
901        std::fs::create_dir_all(prefix).unwrap();
902        let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
903            destination_type: crate::config::DestinationType::Local,
904            path: Some(prefix.to_string_lossy().into_owned()),
905            ..Default::default()
906        })
907        .unwrap();
908        crate::pipeline::write_manifest(&*dest, m).unwrap();
909    }
910
911    /// Config with a single export pointing at `prefix`.  Written next to —
912    /// never inside — the prefix, so it can't surface as untracked surplus.
913    fn write_cfg(dir: &Path, prefix: &Path) -> std::path::PathBuf {
914        let cfg = dir.join("rivet.yaml");
915        let yaml = format!(
916            "source:\n  type: postgres\n  url: postgresql://nobody@localhost/nope\nexports:\n  - name: orders\n    query: \"SELECT 1\"\n    mode: full\n    format: parquet\n    destination:\n      type: local\n      path: \"{}\"\n",
917            prefix.to_string_lossy()
918        );
919        std::fs::write(&cfg, yaml).unwrap();
920        cfg
921    }
922
923    /// In-process twin of the live roast test (tests/roast_validate_exit.rs):
924    /// `manifest.json` present but unreadable must exit non-zero.  head()
925    /// (fs::metadata) succeeds, read() (fs::read) hits EACCES — exactly the
926    /// `ManifestReadError` verdict.
927    #[cfg(unix)]
928    #[test]
929    fn unreadable_manifest_fails_the_command() {
930        use std::os::unix::fs::PermissionsExt;
931
932        let dir = tempfile::tempdir().unwrap();
933        let prefix = dir.path().join("out");
934        stage_dataset(&prefix, &success_manifest(Vec::new()));
935        let cfg = write_cfg(dir.path(), &prefix);
936
937        let manifest_path = prefix.join(crate::manifest::MANIFEST_FILENAME);
938        std::fs::set_permissions(&manifest_path, std::fs::Permissions::from_mode(0o000)).unwrap();
939        if std::fs::read(&manifest_path).is_ok() {
940            // euid 0 ignores file modes — the degraded state can't be staged.
941            eprintln!("skipping unreadable_manifest_fails_the_command: running as root");
942            return;
943        }
944
945        let report = dir.path().join("report.json");
946        let err = run_validate_command(
947            cfg.to_str().unwrap(),
948            Some("orders"),
949            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
950            ValidateTarget::default(),
951        )
952        .expect_err("an unreadable manifest is an explicit failure, not exit 0");
953        // #7 bughunt: an unreadable manifest is COULD-NOT-VERIFY (operational,
954        // exit 1), NOT verified-wrong corruption (exit 3). A scheduler must be
955        // free to retry a permissions/network blip, not stop-the-line.
956        assert!(
957            format!("{err:#}").contains("could not be verified"),
958            "got: {err:#}"
959        );
960        assert!(
961            err.downcast_ref::<crate::error::DataIntegrityError>()
962                .is_none(),
963            "a read error must not be classed as data-integrity (exit 3): {err:#}"
964        );
965
966        // The JSON report (written before the bail) still carries the
967        // verdict so the operator sees why.
968        let json: serde_json::Value =
969            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
970        let verification = &json["exports"][0]["verification"];
971        assert_eq!(verification["manifest_found"], false);
972        assert_eq!(verification["legacy_run"], false);
973        assert_eq!(verification["failures"][0]["kind"], "manifest_read_error");
974    }
975
976    #[test]
977    fn untracked_surplus_alone_keeps_exit_zero() {
978        // The advisory neighbor of the read-error fix: gating on
979        // `has_failures()` alone would flip this verdict to non-zero, but
980        // surplus cleanup is `--resume`'s job (M9), not validate's.
981        let dir = tempfile::tempdir().unwrap();
982        let prefix = dir.path().join("out");
983        stage_dataset(&prefix, &success_manifest(Vec::new()));
984        std::fs::write(prefix.join("rogue.parquet"), b"XX").unwrap();
985        let cfg = write_cfg(dir.path(), &prefix);
986
987        let report = dir.path().join("report.json");
988        run_validate_command(
989            cfg.to_str().unwrap(),
990            Some("orders"),
991            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
992            ValidateTarget::default(),
993        )
994        .expect("advisory untracked surplus must not flip the exit code");
995
996        let json: serde_json::Value =
997            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
998        let verification = &json["exports"][0]["verification"];
999        assert_eq!(verification["passed"], true);
1000        // The stable wire contract is preserved: untracked entries still ride
1001        // `verification.failures` (consumers branch on `failures[].kind`).
1002        assert_eq!(verification["failures"][0]["kind"], "untracked_object");
1003
1004        // L14: …and the same advisory entry is also surfaced in the top-level
1005        // `warnings` array so "failures means failures" — an exit-0 verdict no
1006        // longer hides a surplus object under a "failure" label.
1007        let warnings = json["warnings"].as_array().expect("warnings array present");
1008        assert_eq!(warnings.len(), 1, "the untracked surplus is one warning");
1009        assert_eq!(warnings[0]["export_name"], "orders");
1010        assert_eq!(warnings[0]["warning"]["kind"], "untracked_object");
1011        assert_eq!(warnings[0]["warning"]["key"], "rogue.parquet");
1012    }
1013
1014    #[test]
1015    fn json_warnings_array_is_empty_when_no_advisory_failures() {
1016        // A clean dataset with no surplus → no warnings.  Guards against the
1017        // `warnings` lens accidentally picking up fatal failures.
1018        let dir = tempfile::tempdir().unwrap();
1019        let prefix = dir.path().join("out");
1020        stage_dataset(&prefix, &success_manifest(Vec::new()));
1021        let cfg = write_cfg(dir.path(), &prefix);
1022
1023        let report = dir.path().join("report.json");
1024        run_validate_command(
1025            cfg.to_str().unwrap(),
1026            Some("orders"),
1027            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1028            ValidateTarget::default(),
1029        )
1030        .expect("a clean dataset must pass");
1031
1032        let json: serde_json::Value =
1033            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1034        assert_eq!(
1035            json["warnings"]
1036                .as_array()
1037                .expect("warnings array present")
1038                .len(),
1039            0,
1040            "no surplus → no warnings"
1041        );
1042    }
1043
1044    #[test]
1045    fn multiplex_cdc_validates_each_table_sub_prefix() {
1046        // Regression: a `tables:` CDC stream lands each table under its OWN
1047        // sub-prefix (`<base>/<table>/`, via `cdc_job::dest_for_table`). The base
1048        // prefix holds no manifest, so validating it alone read back as an empty
1049        // "legacy_run" and produced a single base verdict — the operator could
1050        // not certify the stream. The command must descend into each table.
1051        let dir = tempfile::tempdir().unwrap();
1052        let base = dir.path().join("cdc");
1053        stage_dataset(&base.join("alpha"), &success_manifest(Vec::new()));
1054        stage_dataset(&base.join("beta"), &success_manifest(Vec::new()));
1055        let cfg = write_multiplex_cfg(dir.path(), &base);
1056
1057        let report = dir.path().join("report.json");
1058        run_validate_command(
1059            cfg.to_str().unwrap(),
1060            None,
1061            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1062            // Sample depth: exercise the per-table descent + manifest verify
1063            // without the full-depth part download (empty datasets carry no
1064            // parquet to __pos-check).
1065            ValidateTarget {
1066                depth: ValidateDepth::Sample,
1067                ..Default::default()
1068            },
1069        )
1070        .expect("both table sub-prefixes are complete — the stream must validate");
1071
1072        let json: serde_json::Value =
1073            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1074        let names: Vec<&str> = json["exports"]
1075            .as_array()
1076            .unwrap()
1077            .iter()
1078            .map(|e| e["export_name"].as_str().unwrap())
1079            .collect();
1080        // ONE verdict per table, named `<export>/<table>` — proof the descent
1081        // ran (the un-fixed command produced a single "cdc" verdict at the base).
1082        assert_eq!(names, vec!["cdc/alpha", "cdc/beta"], "per-table descent");
1083        for e in json["exports"].as_array().unwrap() {
1084            assert_eq!(e["verification"]["passed"], true, "each table passes");
1085        }
1086    }
1087
1088    fn write_multiplex_cfg(dir: &Path, base: &Path) -> std::path::PathBuf {
1089        let cfg = dir.join("rivet-cdc.yaml");
1090        let yaml = format!(
1091            "source:\n  type: mysql\n  url: mysql://nobody@localhost/nope\nexports:\n  - name: cdc\n    tables: [alpha, beta]\n    mode: cdc\n    format: parquet\n    cdc:\n      server_id: 1\n    destination:\n      type: local\n      path: \"{}\"\n",
1092            base.to_string_lossy()
1093        );
1094        std::fs::write(&cfg, yaml).unwrap();
1095        cfg
1096    }
1097
1098    #[test]
1099    fn single_table_cdc_certifies_snapshot_and_hides_its_untracked() {
1100        // A single-table CDC export with `initial: snapshot` lands its snapshot as
1101        // a nested `snapshot/` dataset under the change prefix. Validate must
1102        // (a) give the snapshot its OWN verdict (so a missing/broken snapshot is
1103        // caught) and (b) NOT flag the snapshot files as "untracked" at the change
1104        // prefix — they are a separately-verified dataset, not orphans.
1105        let dir = tempfile::tempdir().unwrap();
1106        let base = dir.path().join("cdc");
1107        stage_dataset(&base, &success_manifest(Vec::new()));
1108        stage_dataset(&base.join("snapshot"), &success_manifest(Vec::new()));
1109        let cfg = write_single_cdc_cfg(dir.path(), &base);
1110
1111        let report = dir.path().join("report.json");
1112        run_validate_command(
1113            cfg.to_str().unwrap(),
1114            None,
1115            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1116            ValidateTarget {
1117                depth: ValidateDepth::Sample,
1118                ..Default::default()
1119            },
1120        )
1121        .expect("snapshot + change datasets are complete");
1122
1123        let json: serde_json::Value =
1124            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1125        let exports = json["exports"].as_array().unwrap();
1126        let names: Vec<&str> = exports
1127            .iter()
1128            .map(|e| e["export_name"].as_str().unwrap())
1129            .collect();
1130        // The snapshot got its own verdict, and the change prefix its own.
1131        assert!(
1132            names.contains(&"cdc/snapshot"),
1133            "snapshot certified: {names:?}"
1134        );
1135        assert!(names.contains(&"cdc"), "change-prefix verdict: {names:?}");
1136        // The change-prefix verdict carries NO untracked-snapshot advisory.
1137        let change = exports.iter().find(|e| e["export_name"] == "cdc").unwrap();
1138        let failures = change["verification"]["failures"].as_array().unwrap();
1139        assert!(
1140            failures.iter().all(|f| f["kind"] != "untracked_object"),
1141            "snapshot files must not read as untracked surplus: {failures:?}"
1142        );
1143    }
1144
1145    fn write_single_cdc_cfg(dir: &Path, base: &Path) -> std::path::PathBuf {
1146        let cfg = dir.join("rivet-single-cdc.yaml");
1147        let yaml = format!(
1148            "source:\n  type: mysql\n  url: mysql://nobody@localhost/nope\nexports:\n  - name: cdc\n    table: t\n    mode: cdc\n    format: parquet\n    cdc:\n      initial: snapshot\n      server_id: 1\n      checkpoint: ./ck\n    destination:\n      type: local\n      path: \"{}\"\n",
1149            base.to_string_lossy()
1150        );
1151        std::fs::write(&cfg, yaml).unwrap();
1152        cfg
1153    }
1154
1155    #[test]
1156    fn missing_part_fails_the_command() {
1157        let dir = tempfile::tempdir().unwrap();
1158        let prefix = dir.path().join("out");
1159        let m = success_manifest(vec![ManifestPart {
1160            part_id: 1,
1161            path: "part-000001.parquet".into(),
1162            rows: 10,
1163            size_bytes: 4,
1164            content_fingerprint: "xxh3:1111111111111111".into(),
1165            content_md5: String::new(),
1166            status: PartStatus::Committed,
1167        }]);
1168        stage_dataset(&prefix, &m); // the part itself is never written
1169        let cfg = write_cfg(dir.path(), &prefix);
1170
1171        let err = run_validate_command(
1172            cfg.to_str().unwrap(),
1173            Some("orders"),
1174            ValidateOutputFormat::Json(None),
1175            ValidateTarget::default(),
1176        )
1177        .expect_err("a missing committed part must fail verification");
1178        assert!(
1179            format!("{err:#}").contains("1 export(s) failed verification"),
1180            "got: {err:#}"
1181        );
1182    }
1183
1184    // ── finding #20: operator-pinned --prefix requires a manifest ────────────
1185
1186    /// `--prefix` at a real, complete dataset still passes — the normal
1187    /// "validate exactly this prefix" case must not regress.
1188    #[test]
1189    fn prefix_override_with_real_manifest_passes() {
1190        let dir = tempfile::tempdir().unwrap();
1191        let prefix = dir.path().join("out");
1192        stage_dataset(&prefix, &success_manifest(Vec::new()));
1193        let cfg = write_cfg(dir.path(), &prefix);
1194
1195        run_validate_command(
1196            cfg.to_str().unwrap(),
1197            Some("orders"),
1198            ValidateOutputFormat::Json(None),
1199            ValidateTarget {
1200                prefix_override: Some(prefix.to_string_lossy().into_owned()),
1201                ..Default::default()
1202            },
1203        )
1204        .expect("a real dataset under a pinned --prefix must pass");
1205    }
1206
1207    /// `--prefix` at a never-written directory FAILS (exit non-zero): the
1208    /// operator asserted a dataset lives here, so an absent manifest is a
1209    /// refusal reason, not the benign legacy-run pass. This is the in-process
1210    /// twin of the live `audit_validate_absent_prefix_can_fail` roast.
1211    #[test]
1212    fn prefix_override_at_absent_manifest_fails() {
1213        let dir = tempfile::tempdir().unwrap();
1214        // The export's config destination is irrelevant — `--prefix` overrides
1215        // it. Point the override at a dir that exists but was never written.
1216        let cfg_prefix = dir.path().join("cfg_dest");
1217        std::fs::create_dir_all(&cfg_prefix).unwrap();
1218        let cfg = write_cfg(dir.path(), &cfg_prefix);
1219        let empty_prefix = dir.path().join("never_written");
1220        std::fs::create_dir_all(&empty_prefix).unwrap();
1221
1222        let report = dir.path().join("report.json");
1223        let err = run_validate_command(
1224            cfg.to_str().unwrap(),
1225            Some("orders"),
1226            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1227            ValidateTarget {
1228                prefix_override: Some(empty_prefix.to_string_lossy().into_owned()),
1229                ..Default::default()
1230            },
1231        )
1232        .expect_err("a never-written prefix pinned via --prefix must fail, not legacy-pass");
1233        assert!(
1234            format!("{err:#}").contains("1 export(s) failed verification"),
1235            "got: {err:#}"
1236        );
1237
1238        // The verdict (written before the bail) carries the explicit reason so
1239        // the operator sees why the gate refused, not a bare exit code.
1240        let json: serde_json::Value =
1241            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1242        let verification = &json["exports"][0]["verification"];
1243        assert_eq!(verification["manifest_found"], false);
1244        assert_eq!(verification["legacy_run"], false);
1245        assert_eq!(
1246            verification["failures"][0]["kind"],
1247            "manifest_required_but_absent"
1248        );
1249    }
1250
1251    /// Without `--prefix`, an absent manifest stays the benign M6 legacy-run
1252    /// pass (exit 0) — today's behaviour is preserved for config-resolved
1253    /// destinations that may legitimately be pre-0.7.0 prefixes.
1254    #[test]
1255    fn absent_manifest_without_prefix_override_stays_legacy_pass() {
1256        let dir = tempfile::tempdir().unwrap();
1257        let prefix = dir.path().join("out");
1258        std::fs::create_dir_all(&prefix).unwrap(); // exists, but no manifest
1259        let cfg = write_cfg(dir.path(), &prefix);
1260
1261        run_validate_command(
1262            cfg.to_str().unwrap(),
1263            Some("orders"),
1264            ValidateOutputFormat::Json(None),
1265            ValidateTarget::default(), // no --prefix
1266        )
1267        .expect("an absent manifest with no pinned --prefix is a legacy pass (exit 0)");
1268    }
1269
1270    // ── graded verify layer (--depth) end-to-end ─────────────────────────
1271
1272    /// Stage a dataset that passes sections 1-5 (manifest reads + is
1273    /// self-consistent, the single part is present at the recorded size,
1274    /// `_SUCCESS` matches) **but** records a non-empty `column_checksums`, so
1275    /// the Form B re-read is *reachable*. The part body is deliberately NOT
1276    /// valid Parquet, so if Form B runs it errors on the Parquet open — making
1277    /// "did Form B run?" observable as a pass/fail of the command.
1278    fn stage_dataset_form_b_would_fail(prefix: &Path) {
1279        std::fs::create_dir_all(prefix).unwrap();
1280        // 4-byte non-Parquet body; the manifest records size 4 so the part
1281        // reconcile (size-only, empty content_md5) passes.
1282        let part_body: &[u8] = b"AAAA";
1283        std::fs::write(prefix.join("part-000001.parquet"), part_body).unwrap();
1284
1285        let mut m = success_manifest(vec![ManifestPart {
1286            part_id: 1,
1287            path: "part-000001.parquet".into(),
1288            rows: 1,
1289            size_bytes: part_body.len() as u64,
1290            content_fingerprint: "xxh3:1111111111111111".into(),
1291            content_md5: String::new(),
1292            status: PartStatus::Committed,
1293        }]);
1294        // Non-empty → Form B does NOT early-return; it proceeds to read the
1295        // (garbage) part as Parquet and fail.
1296        m.column_checksums = Some(vec![crate::manifest::ColumnChecksum {
1297            name: "id".into(),
1298            checksum: "0".into(),
1299        }]);
1300        stage_dataset(prefix, &m);
1301    }
1302
1303    #[test]
1304    fn sample_depth_does_not_run_form_b() {
1305        // At `--depth sample` the structural checks (parts present, _SUCCESS,
1306        // self-consistency) all pass and the Form B value re-read is skipped —
1307        // so the command succeeds even though the part body is not real Parquet.
1308        let dir = tempfile::tempdir().unwrap();
1309        let prefix = dir.path().join("out");
1310        stage_dataset_form_b_would_fail(&prefix);
1311        let cfg = write_cfg(dir.path(), &prefix);
1312
1313        let report = dir.path().join("report.json");
1314        run_validate_command(
1315            cfg.to_str().unwrap(),
1316            Some("orders"),
1317            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1318            ValidateTarget {
1319                depth: ValidateDepth::Sample,
1320                ..Default::default()
1321            },
1322        )
1323        .expect("sample depth skips Form B, so a non-Parquet part still passes");
1324
1325        let json: serde_json::Value =
1326            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1327        let verification = &json["exports"][0]["verification"];
1328        assert_eq!(verification["passed"], true);
1329        assert_eq!(verification["parts_verified"], 1, "sample reconciles parts");
1330        assert_eq!(verification["depth_level"], "sample");
1331    }
1332
1333    #[test]
1334    fn full_depth_runs_form_b() {
1335        // The contrast: identical dataset, `--depth full`. Sections 1-5 still
1336        // pass (so `manifest_verified` is true and Form B is gated open), Form B
1337        // re-reads the part, fails to parse it as Parquet, and the command exits
1338        // non-zero. Proves Form B runs at full depth and only at full depth.
1339        let dir = tempfile::tempdir().unwrap();
1340        let prefix = dir.path().join("out");
1341        stage_dataset_form_b_would_fail(&prefix);
1342        let cfg = write_cfg(dir.path(), &prefix);
1343
1344        let err = run_validate_command(
1345            cfg.to_str().unwrap(),
1346            Some("orders"),
1347            ValidateOutputFormat::Json(None),
1348            ValidateTarget {
1349                depth: ValidateDepth::Full,
1350                ..Default::default()
1351            },
1352        )
1353        .expect_err("full depth runs Form B, which fails on a non-Parquet part");
1354        assert!(
1355            format!("{err:#}").contains("1 export(s) failed verification"),
1356            "got: {err:#}"
1357        );
1358    }
1359
1360    #[test]
1361    fn json_report_carries_failure_code_and_depth_level() {
1362        // render_json injects the stable `RIVET_VERIFY_*` code next to each
1363        // failure's `kind`, and the verdict carries the `depth_level` it ran at.
1364        // A missing committed part gives us a fatal failure to inspect.
1365        let dir = tempfile::tempdir().unwrap();
1366        let prefix = dir.path().join("out");
1367        let m = success_manifest(vec![ManifestPart {
1368            part_id: 1,
1369            path: "part-000001.parquet".into(),
1370            rows: 10,
1371            size_bytes: 4,
1372            content_fingerprint: "xxh3:1111111111111111".into(),
1373            content_md5: String::new(),
1374            status: PartStatus::Committed,
1375        }]);
1376        stage_dataset(&prefix, &m); // the part itself is never written → PartMissing
1377        let cfg = write_cfg(dir.path(), &prefix);
1378
1379        let report = dir.path().join("report.json");
1380        let _ = run_validate_command(
1381            cfg.to_str().unwrap(),
1382            Some("orders"),
1383            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1384            ValidateTarget {
1385                depth: ValidateDepth::Sample,
1386                ..Default::default()
1387            },
1388        )
1389        .expect_err("a missing part fails the command");
1390
1391        let json: serde_json::Value =
1392            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1393        let verification = &json["exports"][0]["verification"];
1394        // depth_level surfaces the level the pass ran at.
1395        assert_eq!(verification["depth_level"], "sample");
1396        // The PartMissing failure carries BOTH its stable kind and its code.
1397        let failure = &verification["failures"][0];
1398        assert_eq!(failure["kind"], "part_missing");
1399        assert_eq!(failure["code"], "RIVET_VERIFY_PART_MISSING");
1400        // The per-variant fields ride alongside (the derive output is widened,
1401        // not replaced).
1402        assert_eq!(failure["part_id"], 1);
1403    }
1404
1405    #[test]
1406    fn json_warning_entry_also_carries_its_code() {
1407        // The advisory `warnings` lens carries the code too — an untracked
1408        // surplus surfaces `RIVET_VERIFY_UNTRACKED_OBJECT` without flipping exit.
1409        let dir = tempfile::tempdir().unwrap();
1410        let prefix = dir.path().join("out");
1411        stage_dataset(&prefix, &success_manifest(Vec::new()));
1412        std::fs::write(prefix.join("rogue.parquet"), b"XX").unwrap();
1413        let cfg = write_cfg(dir.path(), &prefix);
1414
1415        let report = dir.path().join("report.json");
1416        run_validate_command(
1417            cfg.to_str().unwrap(),
1418            Some("orders"),
1419            ValidateOutputFormat::Json(Some(report.to_string_lossy().into_owned())),
1420            ValidateTarget::default(),
1421        )
1422        .expect("advisory untracked surplus must not flip the exit code");
1423
1424        let json: serde_json::Value =
1425            serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap();
1426        let warning = &json["warnings"][0]["warning"];
1427        assert_eq!(warning["kind"], "untracked_object");
1428        assert_eq!(warning["code"], "RIVET_VERIFY_UNTRACKED_OBJECT");
1429        // And the default depth (full) is recorded on the verdict.
1430        assert_eq!(json["exports"][0]["verification"]["depth_level"], "full");
1431    }
1432}