Skip to main content

rivet/pipeline/
summary.rs

1//! **Layer: Observability**
2//!
3//! `RunSummary` is the single observability artifact for a pipeline run.
4//! It accumulates operational data during execution and is consumed by:
5//! - the end-of-run terminal output (`print`)
6//! - the metrics store (`state::record_metric`)
7//! - the notification system (`notify::maybe_send`)
8//!
9//! `RunSummary` is written to by execution modules (row counts, byte counts, retries)
10//! but it makes no execution decisions itself — it is a pure data accumulator.
11//!
12//! It embeds a `RunJournal` so that all pipeline modules — which already hold
13//! `&mut RunSummary` — can record structured events via `summary.journal.record()`
14//! without any signature changes.  In a future epic the relationship will invert:
15//! `RunSummary` will be derived from `RunJournal`.
16
17use super::ipc::{self, ChildEvent};
18use super::{format_bytes, multi_export_mode, strip_chunked_recovery_hint};
19use crate::journal::{PlanSnapshot, RunEvent, RunJournal};
20use crate::manifest::ManifestPart;
21use crate::plan::ResolvedRunPlan;
22
23/// Build a `PlanSnapshot` from a `ResolvedRunPlan`.
24///
25/// Lives here rather than on `journal` itself so that the journal module
26/// stays free of plan/pipeline dependencies (avoids the state→pipeline cycle
27/// we used to have via `state::journal_store`).
28fn plan_snapshot_from(plan: &ResolvedRunPlan) -> PlanSnapshot {
29    PlanSnapshot {
30        export_name: plan.export_name.clone(),
31        base_query: plan.base_query.clone(),
32        strategy: plan.strategy.mode_label().to_string(),
33        format: plan.format.label().to_string(),
34        compression: plan.compression.label().to_string(),
35        destination_type: plan.destination.destination_type.label().to_string(),
36        tuning_profile: plan.tuning_profile_label.clone(),
37        batch_size: plan.tuning.batch_size,
38        validate: plan.validate,
39        reconcile: plan.reconcile,
40        resume: plan.resume,
41        chunk_key: plan.strategy.chunk_key().map(|c| c.to_string()),
42        resumable: plan.strategy.is_resumable(),
43        row_hash: crate::enrich::RowHashContract::of(&plan.meta_columns.row_hash),
44    }
45}
46
47/// Context recorded when a run was launched via `rivet apply` rather than
48/// `rivet run`.  Provides the audit trail for **F5**: which plan artifact
49/// was applied, whether `--force` was passed, and which preflight checks
50/// `--force` actually bypassed.
51///
52/// `None` on `RunSummary` means the run came from `rivet run` (no plan
53/// artifact was applied).
54#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
55pub struct ApplyContext {
56    /// `plan_id` from the applied `PlanArtifact`.
57    pub plan_id: String,
58    /// Was `--force` passed to `rivet apply`?
59    pub forced: bool,
60    /// Names of preflight checks that `--force` actually overrode for this
61    /// run.  Possible values: `"staleness"`, `"cursor_drift"`.  Empty when
62    /// `forced` is true but no check actually had to be bypassed (the plan
63    /// was fresh and the cursor matched).
64    pub force_bypassed: Vec<String>,
65}
66
67/// Accumulates operational data during a pipeline run for summary and metrics.
68///
69/// The embedded `journal` is the structured event log for this run.  Use
70/// `summary.journal.record(event)` at any call site that already holds
71/// `&mut RunSummary`.
72#[derive(Debug, Clone, Default)]
73pub struct RunSummary {
74    pub run_id: String,
75    pub export_name: String,
76    pub status: String,
77    pub total_rows: i64,
78    pub files_produced: usize,
79    pub bytes_written: u64,
80    /// Incremented after each successful `dest.write()`. Non-zero means a previous
81    /// attempt already committed data — retrying from the same cursor would duplicate rows.
82    pub files_committed: usize,
83    pub duration_ms: i64,
84    pub peak_rss_mb: i64,
85    pub retries: u32,
86    /// Of `retries`, how many opened a FRESH source connection (a
87    /// reconnect-class error: network reset, "gone away", tunnel drop). The
88    /// field-signal for a flaky link — N reconnects = N blips survived. Distinct
89    /// from a same-connection retry (a lock wait) that `retries` also counts.
90    pub reconnects: u32,
91    /// True when this run picked up a prior CRASHED run instead of starting fresh
92    /// (keyset via the in-progress run_id, chunked via `--resume`). A resume-hit is
93    /// the tell that the previous run died — visible in the run's own log line.
94    pub resumed: bool,
95    /// True when this run's chunk boundaries came from a plan artifact
96    /// (`ChunkSource::Precomputed`), i.e. `rivet apply`, rather than being
97    /// detected against the live source.
98    ///
99    /// It exists for the same reason `resumed` does: the pre-chunk schema-drift
100    /// gate lives inside `prepare_chunk_plan`, which only the `Detect` arm calls
101    /// — deliberately, per that function's contract ("drift was evaluated on the
102    /// original planning run that produced the ranges"). So a precomputed run
103    /// legitimately ends with `schema_changed == None`, which
104    /// `check_post_run_invariants` would otherwise read as a runner that skipped
105    /// its facade. Without this flag the exemption would have to be "any apply",
106    /// which is broader than the truth: an apply whose artifact carries no ranges
107    /// falls back to `Detect` and DOES run the gate.
108    pub chunks_precomputed: bool,
109    /// True once this run entered `run_export` — the shared batch-runner dispatch
110    /// choke point, which always has a `StateStore`. It marks a run subject to the
111    /// per-runner facade contract (ADR-0018): every batch runner MUST apply the
112    /// schema-drift gate + Form-B harvest. `check_post_run_invariants` asserts those
113    /// facades ran ONLY for such runs, so a CDC run or a direct-`run_single_export`
114    /// unit test (state = None, never through `run_export`) is not held to the
115    /// contract and cannot false-positive. This is the structural half of the
116    /// runner-bypass guard the `runner-coverage-matrix` tracks by hand.
117    pub state_backed: bool,
118    pub validated: Option<bool>,
119    pub schema_changed: Option<bool>,
120    pub quality_passed: Option<bool>,
121    /// Form B per-column value checksums (name-keyed), harvested from the sink;
122    /// recorded in the manifest so `validate` re-reads + verifies. Empty = none.
123    pub column_checksums: Vec<crate::manifest::ColumnChecksum>,
124    /// The column the Form B checksum is keyed to (cursor/key column); `None` = un-keyed.
125    pub checksum_key_column: Option<String>,
126    /// Set when a checkpoint RESUME hydrated pre-crash parts into
127    /// `manifest_parts` whose per-column checksum contribution is unrecoverable
128    /// (file_log stores no per-column checksums, and the prior manifest keeps
129    /// only the run-wide XOR, not per-part). The run-wide Form B XOR this run
130    /// harvests would then cover only the re-exported parts while the manifest
131    /// lists ALL parts, so `harvest_column_checksums` SUPPRESSES Form B entirely
132    /// rather than record a partial XOR that `validate --depth full` would flag
133    /// as a false mismatch — mirroring how a rehydrated part's empty md5 degrades
134    /// to a size-only check. An incomplete integrity record must be ABSENT, not
135    /// partial-and-lying.
136    pub column_checksums_incomplete: bool,
137    /// Cursor range this run covered (incremental strategies) — travels to the
138    /// manifest's extraction section for warehouse-side continuity checks.
139    /// v1 ships column + low + high; cursor_type / source_row_count are
140    /// follow-ups (they need extra source plumbing).
141    pub cursor_column: Option<String>,
142    pub cursor_low: Option<String>,
143    pub cursor_high: Option<String>,
144    pub error_message: Option<String>,
145    /// v18 failure forensics. `offending_value`: the last key read before an
146    /// unadvanceable keyset row (with `cursor_high` this brackets the value that
147    /// broke advancing — e.g. a u64 above i64::MAX). `server_context_json`: a JSON
148    /// snapshot of the source server's version + limits captured at OPEN (the
149    /// statement-timeout that surfaces as ERROR 3024 is unexplainable without it).
150    /// Both flow onto the failed `export_metrics` row so a post-mortem needs
151    /// neither the source table nor a still-live server.
152    pub offending_value: Option<String>,
153    pub server_context_json: Option<String>,
154    /// The keyset/chunk key's SOURCE native type (`bigint unsigned`, `numeric(11,4)`),
155    /// resolved by the open probe — folded into `key_descriptor_json` so the "was the
156    /// key unsigned" answer travels on the metric row.
157    pub key_native_type: Option<String>,
158    /// `profile` from YAML, or `balanced (default)` if omitted.
159    pub tuning_profile: String,
160    /// Configured `batch_size` from YAML/profile (FETCH cap before `batch_size_memory_mb` override).
161    pub batch_size: usize,
162    /// When set, actual FETCH size is derived from schema (see logs).
163    pub batch_size_memory_mb: Option<usize>,
164    pub format: String,
165    pub mode: String,
166    pub compression: String,
167    /// Where the files were written, as an operator would type it to find them
168    /// again (`./output`, `s3://bucket/prefix`, …). `None` for stdout or a
169    /// summary built outside the run path (tests). Surfaced on the success card
170    /// so a newcomer isn't left wondering where their data landed.
171    pub destination_uri: Option<String>,
172    /// Postgres `pg_stat_database.temp_bytes` delta around the run. `None` for
173    /// non-Postgres sources or when the snapshot probe failed (no admin perms
174    /// not required — the view is readable by any role). When set and large,
175    /// indicates cursor / sort spill to `pgsql_tmp/` — the safe action is to
176    /// shrink `tuning.batch_size` or set `tuning.batch_size_memory_mb` below
177    /// PG's `work_mem`.
178    pub pg_temp_bytes_delta: Option<i64>,
179    /// Human-readable parenthetical attached to `status: skipped` so the
180    /// operator knows *why* there was nothing to export this run (e.g.
181    /// `"no new rows since cursor 'updated_at'"`). Always `None` when
182    /// `status != "skipped"`. Surfaced in the console summary card as
183    /// `status: skipped (<reason>)`.
184    pub skip_reason: Option<String>,
185    /// Source COUNT(*) result for reconciliation (None = not requested or not applicable).
186    pub source_count: Option<i64>,
187    /// Whether reconciliation passed (Some(true) = match, Some(false) = mismatch, None = skipped).
188    pub reconciled: Option<bool>,
189    /// Committed parts accumulated during the run, in commit order.  Populated by
190    /// `pipeline::manifest_writer::record_committed_part` at each `dest.write`
191    /// site (ADR-0012 M1 — Parts Before Manifest).  Drained at finalize into a
192    /// `RunManifest` by [`crate::pipeline::manifest_writer::write_manifest`].
193    pub manifest_parts: Vec<ManifestPart>,
194    /// xxh3 fingerprint of the dest-facing column schema for this run, in the
195    /// canonical `xxh3:<16-hex>` form produced by [`crate::state::schema_fingerprint`].
196    ///
197    /// Recorded by [`crate::pipeline::manifest_writer::record_run_schema_fingerprint`]
198    /// the first time the sink has resolved a schema (i.e. on the first batch
199    /// of any chunk).  Idempotent within a run — the schema is identical across
200    /// chunks, so later writes are no-ops.
201    ///
202    /// `finalize_manifest` reads this directly so the manifest's
203    /// `schema_fingerprint` no longer depends on the per-export schema row
204    /// happening to land in `state` before the manifest write.  The state
205    /// lookup remains a fallback for resume scenarios where the summary was
206    /// reconstructed without ever seeing a live schema.
207    pub schema_fingerprint: Option<String>,
208    /// Result of the manifest-aware `--validate` pass (ADR-0012 M5/M6,
209    /// ADR-0013).  Populated by `pipeline::job::finalize_validate_manifest`
210    /// after `finalize_manifest` succeeds; `None` when the run targeted a
211    /// streaming destination, when `--validate` was not requested, or when
212    /// the run failed before any manifest could be written.
213    pub manifest_verification: Option<crate::pipeline::ManifestVerification>,
214    /// Apply-time context (plan_id, --force usage, bypassed checks).
215    /// `None` when the run came from `rivet run` rather than `rivet apply`.
216    /// See [`ApplyContext`] and finding **F5** of the 0.7.5 audit.
217    pub apply_context: Option<ApplyContext>,
218    /// Structured event log for this run.  Answers the four DoD observability questions.
219    pub journal: RunJournal,
220}
221
222/// One `(label, value)` line in the rendered summary block. The label is a
223/// fixed string literal; the value is computed per run.
224type Row = (&'static str, String);
225
226impl RunSummary {
227    pub(super) fn new(plan: &ResolvedRunPlan) -> Self {
228        let run_id = format!(
229            "{}_{}",
230            plan.export_name,
231            chrono::Utc::now().format("%Y%m%dT%H%M%S%.3f"),
232        );
233        let mut journal = RunJournal::new(&run_id, &plan.export_name);
234        journal.record(RunEvent::PlanResolved(Box::new(plan_snapshot_from(plan))));
235
236        ipc::emit_event(&ChildEvent::Started {
237            export_name: plan.export_name.clone(),
238            run_id: run_id.clone(),
239            mode: plan.strategy.mode_label().to_string(),
240            tuning_profile: plan.tuning_profile_label.clone(),
241            batch_size: plan.tuning.batch_size,
242        });
243
244        Self {
245            run_id,
246            export_name: plan.export_name.clone(),
247            status: "running".into(),
248            total_rows: 0,
249            files_produced: 0,
250            bytes_written: 0,
251            files_committed: 0,
252            duration_ms: 0,
253            peak_rss_mb: 0,
254            retries: 0,
255            reconnects: 0,
256            resumed: false,
257            chunks_precomputed: false,
258            state_backed: false,
259            validated: None,
260            schema_changed: None,
261            quality_passed: None,
262            error_message: None,
263            offending_value: None,
264            server_context_json: None,
265            key_native_type: None,
266            tuning_profile: plan.tuning_profile_label.clone(),
267            batch_size: plan.tuning.batch_size,
268            batch_size_memory_mb: plan.tuning.batch_size_memory_mb,
269            format: plan.format.label().to_string(),
270            mode: plan.strategy.mode_label().to_string(),
271            compression: plan.compression.label().to_string(),
272            destination_uri: (!matches!(
273                plan.destination.destination_type,
274                crate::config::DestinationType::Stdout
275            ))
276            .then(|| crate::pipeline::finalize::destination_uri_for_manifest(&plan.destination)),
277            pg_temp_bytes_delta: None,
278            skip_reason: None,
279            source_count: None,
280            reconciled: None,
281            manifest_parts: Vec::new(),
282            schema_fingerprint: None,
283            manifest_verification: None,
284            apply_context: None,
285            column_checksums: Vec::new(),
286            column_checksums_incomplete: false,
287            checksum_key_column: None,
288            cursor_column: None,
289            cursor_low: None,
290            cursor_high: None,
291            journal,
292        }
293    }
294
295    /// One canonical builder for tests across the crate + integration suite.
296    ///
297    /// Every field is filled with a sensible default; callers tweak only what
298    /// the test cares about via the chainable setters below.  This replaces
299    /// the seven copies of `stub_summary` / `fresh_summary` / `make_summary`
300    /// / `dummy_summary` / `empty_summary` that used to live in `notify.rs`,
301    /// `report.rs`, `chunked/{exec,mod}.rs`, `manifest_writer.rs`, and the
302    /// two integration-test files.  When `RunSummary` gains a field, it is
303    /// updated here once instead of across nine sites.
304    ///
305    /// Available outside `pipeline` (`pub` not `pub(crate)`) so integration
306    /// tests in `tests/` can use it via `RunSummary::stub_for_testing(...)`.
307    /// The `_for_testing` suffix is the convention from elsewhere in the
308    /// codebase (`destination_for_tests`, etc.) — production code should
309    /// never call it.
310    ///
311    /// `#[allow(dead_code)]` on each helper because the bin target's
312    /// dead-code analysis doesn't see uses from integration tests in `tests/`.
313    /// The lib's unit tests + the integration suite together exercise every
314    /// helper; the attribute is a no-op for them.
315    #[doc(hidden)]
316    #[allow(dead_code)]
317    pub fn stub_for_testing(run_id: impl Into<String>, export_name: impl Into<String>) -> Self {
318        let run_id = run_id.into();
319        let export_name = export_name.into();
320        let journal = RunJournal::new(&run_id, &export_name);
321        Self {
322            run_id,
323            export_name,
324            status: "running".into(),
325            total_rows: 0,
326            files_produced: 0,
327            bytes_written: 0,
328            files_committed: 0,
329            duration_ms: 0,
330            peak_rss_mb: 0,
331            retries: 0,
332            reconnects: 0,
333            resumed: false,
334            chunks_precomputed: false,
335            state_backed: false,
336            validated: None,
337            schema_changed: None,
338            quality_passed: None,
339            error_message: None,
340            offending_value: None,
341            server_context_json: None,
342            key_native_type: None,
343            tuning_profile: "balanced".into(),
344            batch_size: 1000,
345            batch_size_memory_mb: None,
346            format: "parquet".into(),
347            mode: "snapshot".into(),
348            compression: "zstd".into(),
349            destination_uri: None,
350            pg_temp_bytes_delta: None,
351            skip_reason: None,
352            source_count: None,
353            reconciled: None,
354            manifest_parts: Vec::new(),
355            schema_fingerprint: None,
356            manifest_verification: None,
357            apply_context: None,
358            column_checksums: Vec::new(),
359            column_checksums_incomplete: false,
360            checksum_key_column: None,
361            cursor_column: None,
362            cursor_low: None,
363            cursor_high: None,
364            journal,
365        }
366    }
367
368    /// Test-only chainable setter for the run's status field.
369    ///
370    /// Used to build success/failed/running variants without re-listing every
371    /// field.  Keeps the journal in sync: terminal statuses get a matching
372    /// `RunCompleted` event recorded so consumers reading
373    /// `journal.final_outcome()` see the right shape.
374    #[doc(hidden)]
375    #[allow(dead_code)]
376    pub fn with_status(mut self, status: impl Into<String>) -> Self {
377        let s = status.into();
378        if (s == "success" || s == "failed") && self.journal.final_outcome().is_none() {
379            self.journal.record(RunEvent::RunCompleted {
380                status: s.clone(),
381                error_message: self.error_message.clone(),
382                duration_ms: self.duration_ms,
383            });
384        }
385        self.status = s;
386        self
387    }
388
389    /// Test-only setter — record `files_committed` so resume-hint logic
390    /// (`pipeline::report`) can detect the "failed run with committed files"
391    /// path that produces a resume command.
392    #[doc(hidden)]
393    #[allow(dead_code)]
394    pub fn with_files_committed(mut self, n: usize) -> Self {
395        self.files_committed = n;
396        self
397    }
398
399    /// Test-only setter — replace the recorded manifest parts (and adjust
400    /// `total_rows` / `bytes_written` / `files_produced` to keep them
401    /// consistent with the parts list, the way real production code does).
402    #[doc(hidden)]
403    #[allow(dead_code)]
404    pub fn with_manifest_parts(mut self, parts: Vec<crate::manifest::ManifestPart>) -> Self {
405        self.total_rows = parts.iter().map(|p| p.rows).sum();
406        self.bytes_written = parts.iter().map(|p| p.size_bytes).sum();
407        self.files_produced = parts.len();
408        self.files_committed = parts.len();
409        self.manifest_parts = parts;
410        self
411    }
412
413    /// Test-only setter — error_message + (optionally) status.  Common
414    /// shape for the "failed-run" fixtures that populated 4+ existing
415    /// stubs.
416    #[doc(hidden)]
417    #[allow(dead_code)]
418    pub fn with_error(mut self, msg: impl Into<String>) -> Self {
419        self.error_message = Some(msg.into());
420        self
421    }
422
423    /// Test-only setter — record a PlanResolved event in the journal so
424    /// downstream observability paths (`journal.plan_snapshot()`,
425    /// `RunReport::from_summary` plan_origin lookup) see the same shape
426    /// they would on a real run.
427    #[doc(hidden)]
428    #[allow(dead_code)]
429    pub fn with_plan_snapshot(mut self, snap: PlanSnapshot) -> Self {
430        self.journal.record(RunEvent::PlanResolved(Box::new(snap)));
431        self
432    }
433
434    pub(super) fn print(&self) {
435        // Capturing mode (IPC child or in-process channel): emit a
436        // `Finished` event and let the unified UI thread render the card.
437        // No stderr block here — the renderer owns the screen.
438        if ipc::capturing_events() {
439            ipc::emit_event(&ChildEvent::Finished {
440                export_name: self.export_name.clone(),
441                run_id: self.run_id.clone(),
442                status: self.status.clone(),
443                total_rows: self.total_rows,
444                files_produced: self.files_produced as u64,
445                bytes_written: self.bytes_written,
446                duration_ms: self.duration_ms,
447                peak_rss_mb: self.peak_rss_mb,
448                error_message: self.error_message.clone(),
449            });
450            return;
451        }
452
453        self.print_stderr_block();
454    }
455
456    /// Write the per-export summary block to stderr, bypassing IPC capture.
457    /// Used after the in-process card UI finishes on single-export sequential
458    /// runs so operators still get the detailed `── name ──` block below the
459    /// live card line.
460    pub(super) fn print_stderr_block(&self) {
461        let block = if multi_export_mode() {
462            self.render_compact()
463        } else {
464            // Render the whole block into a single buffer so the call site
465            // emits one `write_all` to stderr.  Without this, parallel
466            // exports could interleave individual lines from different
467            // `RunSummary::print()` calls — visible as garbled blocks in
468            // `--parallel-exports` runs.
469            self.render().trim_end_matches('\n').to_string()
470        };
471
472        use std::io::Write;
473        // V9 (CWE-150): the block embeds error_message, which can carry
474        // attacker-controlled ANSI/OSC escapes from a malicious source DB. The
475        // single-export path reaches the operator terminal here (the parallel
476        // renderer sanitises separately). Funnel the whole block through the
477        // shared sanitiser before write — it preserves the renderer's own
478        // multi-byte glyphs (✓/✗/──) and strips only C0/C1/DEL control bytes.
479        let mut buf = super::parent_ui::sanitize_terminal(&block);
480        buf.push('\n');
481        let stderr = std::io::stderr();
482        let mut handle = stderr.lock();
483        let _ = handle.write_all(buf.as_bytes());
484        let _ = handle.flush();
485    }
486
487    /// Compact one-line summary used when several exports run in the same
488    /// invocation.  Mirrors the parent_ui card line so `--parallel-exports`
489    /// (threads), sequential, and `--parallel-export-processes` (processes)
490    /// produce visually consistent per-export rows.
491    fn render_compact(&self) -> String {
492        const NAME_COL: usize = 22;
493        const MODE_COL: usize = 8;
494        let icon = match self.status.as_str() {
495            "success" => "✓",
496            "failed" => "✗",
497            _ => "•",
498        };
499        let body = if self.status == "failed" {
500            let err = self
501                .error_message
502                .as_deref()
503                .unwrap_or("(no error message recorded)");
504            let (cause, _) = strip_chunked_recovery_hint(err);
505            // Collapse multi-line / extremely long errors so the compact
506            // line stays one row tall.  Full payload lives in the stderr
507            // log above the run summary.
508            compact_error(cause)
509        } else {
510            let rss = if self.peak_rss_mb > 0 {
511                format!("  RSS {} MB", fmt_thousands(self.peak_rss_mb))
512            } else {
513                String::new()
514            };
515            format!(
516                "{} rows  {} files  {}  {}{}",
517                fmt_thousands(self.total_rows),
518                fmt_thousands(self.files_produced as i64),
519                format_bytes(self.bytes_written),
520                fmt_duration_ms(self.duration_ms),
521                rss
522            )
523        };
524        format!(
525            "{} {:<name$}  {:<mode$}  {}",
526            icon,
527            self.export_name,
528            self.mode,
529            body,
530            name = NAME_COL,
531            mode = MODE_COL,
532        )
533    }
534
535    /// Build the block as a string.  Module-private so tests can assert
536    /// formatting without capturing stderr.
537    ///
538    /// Adaptive layout: assemble the `(label, value)` rows that apply to this
539    /// run from a flat manifest of per-row providers, then [`format_block`]
540    /// pads labels to the longest so columns line up *within* the block (the
541    /// header is fixed-width so consecutive blocks stay uniform regardless of
542    /// which optional fields are present). Each provider owns one row's gate +
543    /// formatting and is unit-testable in isolation; this method owns only
544    /// their order — completing the pattern begun by [`incremental_position_line`]
545    /// / [`time_window_skip_line`].
546    fn render(&self) -> String {
547        let mut rows: Vec<Row> = Vec::with_capacity(16);
548        rows.push(("run_id", self.run_id.clone()));
549        rows.push(self.status_row());
550        rows.push(self.tuning_row());
551        rows.push(("rows", fmt_thousands(self.total_rows)));
552        rows.push(("files", fmt_thousands(self.files_produced as i64)));
553        rows.extend(self.output_row());
554        rows.extend(self.position_row());
555        rows.extend(self.bytes_row());
556        rows.push(("duration", fmt_duration_ms(self.duration_ms)));
557        rows.extend(self.peak_rss_row());
558        rows.extend(self.pg_temp_spill_row());
559        rows.extend(self.compression_row());
560        rows.extend(self.retries_row());
561        rows.extend(self.outcome_rows());
562        rows.extend(self.error_row());
563        format_block(&self.export_name, &rows)
564    }
565
566    /// `status`, annotated with the skip reason when the run was skipped.
567    fn status_row(&self) -> Row {
568        let value = match (&self.status, &self.skip_reason) {
569            (s, Some(reason)) if s == "skipped" => format!("{s} ({reason})"),
570            (s, _) => s.clone(),
571        };
572        ("status", value)
573    }
574
575    /// `tuning` — profile + configured batch_size, noting the memory-derived
576    /// FETCH override when `batch_size_memory_mb` is set.
577    fn tuning_row(&self) -> Row {
578        let value = match self.batch_size_memory_mb {
579            Some(mem) => format!(
580                "profile={}, batch_size={} (batch_size_memory_mb={}MiB → effective FETCH in logs)",
581                self.tuning_profile,
582                fmt_thousands(self.batch_size as i64),
583                mem
584            ),
585            None => format!(
586                "profile={}, batch_size={}",
587                self.tuning_profile,
588                fmt_thousands(self.batch_size as i64)
589            ),
590        };
591        ("tuning", value)
592    }
593
594    /// `output` — where the files landed, so a newcomer isn't left guessing
595    /// after a successful export. Only when we actually wrote somewhere
596    /// addressable (skips stdout and 0-file skips).
597    fn output_row(&self) -> Option<Row> {
598        if self.files_produced > 0 {
599            self.destination_uri.clone().map(|uri| ("output", uri))
600        } else {
601            None
602        }
603    }
604
605    /// `cursor` (incremental) xor `window` (time_window): the position line for
606    /// a 0-row run. On a bare `0 rows  0 files` run this tells the operator the
607    /// incremental boundary held / the rolling window was simply empty, rather
608    /// than leaving an empty run indistinguishable from a misconfigured one.
609    /// `None` for a run that produced rows or whose skip carries no position.
610    /// (Detail lives in the two helpers it wraps.)
611    fn position_row(&self) -> Option<Row> {
612        if let Some(pos) = incremental_position_line(self.skip_reason.as_deref()) {
613            Some(("cursor", pos))
614        } else {
615            time_window_skip_line(&self.mode, self.skip_reason.as_deref()).map(|w| ("window", w))
616        }
617    }
618
619    /// `bytes` — only when something was written.
620    fn bytes_row(&self) -> Option<Row> {
621        if self.bytes_written > 0 {
622            Some(("bytes", format_bytes(self.bytes_written)))
623        } else {
624            None
625        }
626    }
627
628    /// `peak RSS` — only when sampled during the run.
629    fn peak_rss_row(&self) -> Option<Row> {
630        if self.peak_rss_mb > 0 {
631            Some((
632                "peak RSS",
633                format!(
634                    "{} MB (sampled during run)",
635                    fmt_thousands(self.peak_rss_mb)
636                ),
637            ))
638        } else {
639            None
640        }
641    }
642
643    /// `pg temp spill` — PostgreSQL temp-file spill around the run. Chatters
644    /// only on actual spill (`> 0`); annotates a tuning hint above 100 MB.
645    /// `None` for non-Postgres sources, a failed probe, or no spill.
646    fn pg_temp_spill_row(&self) -> Option<Row> {
647        let temp = self.pg_temp_bytes_delta?;
648        if temp <= 0 {
649            return None;
650        }
651        let temp_mb = temp as f64 / (1024.0 * 1024.0);
652        let label = if temp > 100 * 1024 * 1024 {
653            format!(
654                "{:.1} MB ⚠ shrink tuning.batch_size or set batch_size_memory_mb",
655                temp_mb
656            )
657        } else {
658            format!("{:.1} MB", temp_mb)
659        };
660        Some(("pg temp spill", label))
661    }
662
663    /// `compression` — only when it differs from the parquet default (zstd).
664    fn compression_row(&self) -> Option<Row> {
665        if self.format == "parquet" && self.compression != "zstd" {
666            Some(("compression", self.compression.clone()))
667        } else {
668            None
669        }
670    }
671
672    /// `retries` — only when the run had to retry.
673    fn retries_row(&self) -> Option<Row> {
674        if self.retries > 0 {
675            Some(("retries", self.retries.to_string()))
676        } else {
677            None
678        }
679    }
680
681    /// Post-extraction check outcomes: `--validate`, schema-drift, the quality
682    /// gate, `--reconcile`, plus the advisory verify nudge. Grouped so the
683    /// nudge's "ran neither verification pass" gate sits next to the very
684    /// fields it inspects — when it was added (#4) it landed as a 10-line block
685    /// appended to a flat ladder; here that dependency is local.
686    fn outcome_rows(&self) -> Vec<Row> {
687        let mut rows: Vec<Row> = Vec::new();
688        if let Some(v) = self.validated {
689            rows.push(("validated", if v { "pass".into() } else { "FAIL".into() }));
690        }
691        if let Some(sc) = self.schema_changed {
692            rows.push((
693                "schema",
694                if sc {
695                    "CHANGED".into()
696                } else {
697                    "unchanged".into()
698                },
699            ));
700        }
701        if let Some(q) = self.quality_passed {
702            rows.push(("quality", if q { "pass".into() } else { "FAIL".into() }));
703        }
704        if let Some(reconciled) = self.reconciled {
705            let src = self
706                .source_count
707                .map(fmt_thousands)
708                .unwrap_or_else(|| "?".into());
709            let exported = fmt_thousands(self.total_rows);
710            let value = if reconciled {
711                format!("MATCH ({exported}/{src})")
712            } else {
713                format!("MISMATCH (exported {exported} vs source {src})")
714            };
715            rows.push(("reconcile", value));
716        }
717        // Nudge: a successful run that wrote files but ran neither verification
718        // pass leaves completeness unconfirmed. Advisory only — so skipping
719        // verification is a deliberate choice, not an oversight (a pilot loaded
720        // hundreds of millions of rows across 5 runs with 0 verified).
721        if self.status == "success"
722            && self.files_produced > 0
723            && self.validated.is_none()
724            && self.reconciled.is_none()
725        {
726            rows.push((
727                "verify",
728                "not run — add `--reconcile` (count vs source) or `rivet validate` (re-read outputs)"
729                    .into(),
730            ));
731        }
732        rows
733    }
734
735    /// `error` — the failure message, with its own multi-line structure
736    /// preserved (the detailed block indents continuation lines under the
737    /// value column; flattening to `"; "`-joined text is the compact
738    /// one-liner's job, not this one's — a quality failure's multi-line
739    /// `failed:\n  - <check>\n  Fix …` stays readable here).
740    fn error_row(&self) -> Option<Row> {
741        self.error_message
742            .as_ref()
743            .map(|err| ("error", err.trim_end().to_string()))
744    }
745
746    /// Sanity-check the post-run summary ↔ manifest_parts coherence. Used as
747    /// a `debug_assert!`-style runtime gate from `finalize_manifest` so any
748    /// future runner that bumps `bytes_written` / `files_committed` /
749    /// `files_produced` without going through `pipeline::commit::record_part`
750    /// is caught the moment it finishes a real export. Compiled out in
751    /// release builds via the `cfg!(debug_assertions)` guard at the call site.
752    ///
753    /// **Resume-safe inequalities only**: on resume, `manifest_parts` carries
754    /// prior runs' parts via `chunked::resume_m8` while `bytes_written` /
755    /// `files_committed` reflect only the current invocation — so strict
756    /// equality is wrong across resume boundaries. Strict equality on the
757    /// non-resume path is pinned by `pipeline::commit::tests`.
758    ///
759    /// Returns `Ok(())` when the summary satisfies the invariants, else an
760    /// `Err(String)` naming which one was violated and by how much.
761    /// `is_resume_run` is the actual `--resume` invocation (plan.resume), NOT the
762    /// `resumed` diagnosis flag (adopted_prior_work). They differ on a checkpoint
763    /// resume that crashed BEFORE its first chunk committed: no prior manifest exists
764    /// so adopted_prior_work() — and thus `resumed` — is false, yet the drift gate was
765    /// still legitimately skipped (plan.resume bypasses prepare_chunk_plan). Keying the
766    /// exemption on the flag as well as `resumed` stops the guard from panicking such a
767    /// legitimate crash-recovery resume in debug/test builds.
768    pub fn check_post_run_invariants(&self, is_resume_run: bool) -> Result<(), String> {
769        let parts_bytes: u64 = self.manifest_parts.iter().map(|p| p.size_bytes).sum();
770
771        if self.files_committed > self.manifest_parts.len() {
772            // Subsumes the empty-part-list case: with no parts recorded, ANY
773            // committed file trips this. The dedicated `manifest_parts.is_empty()`
774            // branch that used to sit below was therefore unreachable — it needed
775            // `files_committed > 0` AND `len() == 0`, which this returns on first —
776            // so its message never reached anyone and mutation testing found its
777            // condition unkillable. Its diagnosis is folded in here instead of
778            // deleted, because the ADR reference is the useful half.
779            return Err(format!(
780                "summary.files_committed ({}) > manifest_parts.len() ({}) — \
781                 a runner bumped files_committed without commit::record_part. \
782                 With an EMPTY part list this is also the ADR-0012 M1 gap: the \
783                 cloud manifest would ship with no parts (what parallel_checkpoint \
784                 did before e9b0796)",
785                self.files_committed,
786                self.manifest_parts.len()
787            ));
788        }
789        if self.files_produced > self.manifest_parts.len() {
790            return Err(format!(
791                "summary.files_produced ({}) > manifest_parts.len() ({}) — \
792                 a runner bumped files_produced without commit::record_part",
793                self.files_produced,
794                self.manifest_parts.len()
795            ));
796        }
797        if self.bytes_written > parts_bytes {
798            return Err(format!(
799                "summary.bytes_written ({}) > sum(manifest_parts.size_bytes) ({}) — \
800                 a runner bumped bytes_written without commit::record_part",
801                self.bytes_written, parts_bytes
802            ));
803        }
804        // Invariant audit gap #1, weak form: a successful run that produced
805        // rows for THIS invocation must have committed at least one file.
806        // The strict form ("rows_written <= rows_read") would require a
807        // separate source-side row counter we do not track, and concurrent
808        // INSERTs on the source (live_oltp_load) make a source_count
809        // comparison brittle. This weak form catches a fabrication shape:
810        // total_rows accumulated but nothing reached the destination — a
811        // runner that fetched and silently dropped rows produces exactly
812        // this signature. Resume-safe: total_rows reflects only this
813        // invocation, so a resume with no work to do legitimately ends at
814        // total_rows=0 / files_committed=0 and the guard does not fire.
815        if self.status == "success" && self.total_rows > 0 && self.files_committed == 0 {
816            return Err(format!(
817                "summary.total_rows={} but files_committed=0 — rows extracted from \
818                 source but no files committed (no output reached the destination)",
819                self.total_rows
820            ));
821        }
822        // Runner-bypass guard (structural half of runner-coverage-matrix). A
823        // batch run (`state_backed`, i.e. through `run_export`) that committed
824        // parts MUST have applied the per-runner facades (ADR-0018). Each facade
825        // leaves a telltale on the summary; a runner that owns its loop and forgets
826        // to CALL a facade leaves it absent — the exact bypass class that recurs
827        // (this session's parallel-keyset drift-gate miss; earlier, Form-B on the
828        // large-table runners). Gated on `state_backed` so CDC and direct-runner
829        // unit tests (never through `run_export`) are not held to the contract.
830        if self.state_backed && self.status == "success" && self.files_committed > 0 {
831            // Schema-drift gate: every batch runner calls check_from_sink_schema /
832            // check_from_type_mappings, which ALWAYS leaves schema_changed = Some(_)
833            // when it runs (Some(false) even on a skip/tracking-error). `None` here
834            // means no runner ever called the gate — EXCEPT on a resume, where the
835            // drift gate is legitimately skipped (the baseline was set on the
836            // original run; chunked's `--resume` bypasses prepare_chunk_plan, the
837            // only caller of check_from_type_mappings). Exempt both the adopted-work
838            // diagnosis (`resumed`) AND the raw `--resume` flag (`is_resume_run`) — a
839            // resume that crashed before its first commit adopts nothing yet still
840            // skipped the gate.
841            if !self.resumed
842                && !is_resume_run
843                && !self.chunks_precomputed
844                && self.schema_changed.is_none()
845            {
846                return Err(
847                    "state_backed success committed parts but schema_changed is None — \
848                     the on_schema_drift gate was never applied (no runner called \
849                     check_from_sink_schema / check_from_type_mappings). This is the \
850                     runner-bypass class: a runner owning its loop skipped the facade."
851                        .to_string(),
852                );
853            }
854            // Form-B harvest — Parquet ONLY: the sink's track_checksum skips
855            // non-Parquet formats by design, so CSV/JSONL legitimately harvest no
856            // checksums (empty, not flagged incomplete). Gate the check on the
857            // Parquet format so a correct CSV/JSONL export is not a false positive.
858            if self.format == "parquet"
859                && self.column_checksums.is_empty()
860                && !self.column_checksums_incomplete
861            {
862                return Err(
863                    "state_backed success committed parts but column_checksums is empty \
864                     and not flagged incomplete — Form-B was never harvested (no runner \
865                     called harvest_column_checksums). Runner-bypass class."
866                        .to_string(),
867                );
868            }
869        }
870        Ok(())
871    }
872}
873
874/// Reduce a possibly-multi-line execution error to a single-line, bounded-
875/// length cause suitable for the per-export summary block and the compact
876/// one-liner.  Keeps the user-actionable bit and drops noisy diagnostic
877/// payloads (long URLs, query strings, repeated chunk errors).
878///
879/// Recognised shapes:
880/// - `parallel checkpoint worker errors:\nchunk N: <msg>\nchunk M: <msg>` →
881///   `parallel checkpoint workers failed: K chunk(s) (chunk N: <truncated>)`.
882///   The full per-chunk detail is already in stderr logs.
883/// - Generic multi-line: newlines are replaced with `; ` and the result is
884///   clamped to 240 characters with an ellipsis.
885fn compact_error(raw: &str) -> String {
886    const MAX_CHARS: usize = 240;
887    if let Some(summary) = summarize_parallel_chunk_errors(raw) {
888        return clamp_chars(&summary, MAX_CHARS);
889    }
890    let collapsed: String = raw
891        .lines()
892        .map(str::trim_end)
893        .filter(|s| !s.is_empty())
894        .collect::<Vec<_>>()
895        .join("; ");
896    clamp_chars(&collapsed, MAX_CHARS)
897}
898
899/// Derive the summary block's `cursor:` line from a `skip_reason`.
900///
901/// `skip_reason` for an incremental no-op is `"no new rows since cursor
902/// '<col>'"` (set by the runner); we lift the column out and report the
903/// position as held. Returns `None` for the non-cursor `"source returned 0
904/// rows"` skip and for `None` (a run that actually produced rows). The cursor
905/// *value* isn't carried on the summary yet, so this is column-level only.
906fn incremental_position_line(skip_reason: Option<&str>) -> Option<String> {
907    let col = skip_reason?
908        .strip_prefix("no new rows since cursor '")?
909        .strip_suffix('\'')?;
910    Some(format!("'{col}' unchanged (no new rows this run)"))
911}
912
913/// Derive the summary block's `window:` line for a time_window run that
914/// returned nothing.
915///
916/// A `TimeWindow` strategy has no cursor column, so a 0-row run reports the
917/// generic `"source returned 0 rows"` skip — the `incremental_position_line`
918/// branch never fires and, without this line, an empty time window looks
919/// identical to any other empty export. Surfacing it lets the operator tell an
920/// *empty window* (data simply outside the rolling range) from a *misconfigured
921/// window* (wrong `time_column` / `days_window`).
922///
923/// Keyed on `mode == "timewindow"` (set from `ExtractionStrategy::mode_label`)
924/// plus a set skip reason, so it only fires on a skipped time_window run and
925/// never on incremental/snapshot/chunked/keyset. The window column, days, and
926/// computed lower bound are not carried on `RunSummary`, so this reports the
927/// strategy-level fact and where to look — the concrete bound is a follow-up
928/// once the runner records it onto the summary.
929fn time_window_skip_line(mode: &str, skip_reason: Option<&str>) -> Option<String> {
930    skip_reason?;
931    if mode != "timewindow" {
932        return None;
933    }
934    Some("rolling time window matched no rows — check `time_column`/`days_window`".to_string())
935}
936
937fn summarize_parallel_chunk_errors(raw: &str) -> Option<String> {
938    let header_pos = raw.find("parallel checkpoint worker errors:")?;
939    let prefix = raw[..header_pos].trim_end_matches(": ").trim_end();
940    let tail = &raw[header_pos + "parallel checkpoint worker errors:".len()..];
941
942    let chunk_lines: Vec<&str> = tail
943        .lines()
944        .map(str::trim)
945        .filter(|l| l.starts_with("chunk "))
946        .collect();
947    if chunk_lines.is_empty() {
948        return None;
949    }
950    let first_chunk_full = chunk_lines[0];
951    // Truncate the example chunk message; the URL/payload is in stderr logs.
952    let first_chunk_short = clamp_chars(first_chunk_full, 140);
953    let prefix = if prefix.is_empty() {
954        String::new()
955    } else {
956        format!("{}: ", prefix)
957    };
958    Some(format!(
959        "{}parallel checkpoint workers failed: {} chunk(s) ({}); see stderr for full payloads",
960        prefix,
961        chunk_lines.len(),
962        first_chunk_short
963    ))
964}
965
966fn clamp_chars(s: &str, max_chars: usize) -> String {
967    if max_chars == 0 {
968        return String::new();
969    }
970    if s.chars().count() <= max_chars {
971        return s.to_string();
972    }
973    let keep = max_chars.saturating_sub(1);
974    let mut out: String = s.chars().take(keep).collect();
975    out.push('…');
976    out
977}
978
979/// Render a `── name ─────…─` header plus one indented `label:  value` line
980/// per row, all joined into a single string ending with `\n`.
981fn format_block(name: &str, rows: &[(&str, String)]) -> String {
982    const HEADER_WIDTH: usize = 60;
983    let label_w = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
984
985    let prefix = format!("── {} ", name);
986    let prefix_chars = prefix.chars().count();
987    let dashes = HEADER_WIDTH.saturating_sub(prefix_chars);
988    let mut out = String::with_capacity(HEADER_WIDTH * (rows.len() + 3));
989    out.push('\n');
990    out.push_str(&prefix);
991    for _ in 0..dashes {
992        out.push('─');
993    }
994    out.push('\n');
995    // Continuation lines of a multi-line value (e.g. the multi-line `error`
996    // row) are indented to align under the value column, so block-shaped
997    // messages stay readable instead of being flattened onto one line.
998    let value_indent = " ".repeat(2 + (label_w + 1) + 2);
999    for (label, value) in rows {
1000        // `label_w + 1` so the colon stays attached to the label and the
1001        // value column starts uniformly two spaces after it.
1002        let mut lines = value.split('\n');
1003        let first = lines.next().unwrap_or("");
1004        out.push_str(&format!(
1005            "  {:<width$}  {}\n",
1006            format!("{label}:"),
1007            first,
1008            width = label_w + 1
1009        ));
1010        for cont in lines {
1011            out.push_str(&value_indent);
1012            out.push_str(cont);
1013            out.push('\n');
1014        }
1015    }
1016    out
1017}
1018
1019fn fmt_duration_ms(ms: i64) -> String {
1020    if ms < 1000 {
1021        return format!("{}ms", ms);
1022    }
1023    let total_secs = ms / 1000;
1024    let h = total_secs / 3600;
1025    let m = (total_secs % 3600) / 60;
1026    let s_frac = (ms % 60_000) as f64 / 1000.0;
1027    if h > 0 {
1028        format!("{}h {:02}m {:04.1}s", h, m, s_frac)
1029    } else if m > 0 {
1030        format!("{}m {:04.1}s", m, s_frac)
1031    } else {
1032        format!("{:.1}s", ms as f64 / 1000.0)
1033    }
1034}
1035
1036/// Format integers with a comma every three digits.  Negative values keep
1037/// their sign.  Used for rows / files / batch_size so large numbers stay
1038/// readable: `39_990_376` → `39,990,376`.
1039fn fmt_thousands(n: i64) -> String {
1040    let abs = n.unsigned_abs();
1041    let s = abs.to_string();
1042    let bytes = s.as_bytes();
1043    let mut out = String::with_capacity(s.len() + s.len() / 3 + 1);
1044    if n < 0 {
1045        out.push('-');
1046    }
1047    for (i, b) in bytes.iter().enumerate() {
1048        let from_end = bytes.len() - i;
1049        if i > 0 && from_end.is_multiple_of(3) {
1050            out.push(',');
1051        }
1052        out.push(*b as char);
1053    }
1054    out
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    #[test]
1062    fn fmt_thousands_handles_small_and_large() {
1063        assert_eq!(fmt_thousands(0), "0");
1064        assert_eq!(fmt_thousands(7), "7");
1065        assert_eq!(fmt_thousands(999), "999");
1066        assert_eq!(fmt_thousands(1_000), "1,000");
1067        assert_eq!(fmt_thousands(1_000_908), "1,000,908");
1068        assert_eq!(fmt_thousands(39_990_376), "39,990,376");
1069        assert_eq!(fmt_thousands(-1_234), "-1,234");
1070        assert_eq!(fmt_thousands(i64::MAX), "9,223,372,036,854,775,807");
1071    }
1072
1073    #[test]
1074    fn fmt_duration_picks_unit() {
1075        assert_eq!(fmt_duration_ms(0), "0ms");
1076        assert_eq!(fmt_duration_ms(800), "800ms");
1077        assert_eq!(fmt_duration_ms(1_500), "1.5s");
1078        assert_eq!(fmt_duration_ms(68_400), "1m 08.4s");
1079        assert_eq!(fmt_duration_ms(3_725_300), "1h 02m 05.3s");
1080    }
1081
1082    #[test]
1083    fn format_block_pads_labels_uniformly() {
1084        let rows = vec![
1085            ("run_id", "abc".to_string()),
1086            ("rows", "42".to_string()),
1087            ("compression", "zstd".to_string()),
1088        ];
1089        let out = format_block("orders", &rows);
1090
1091        // Each value column starts at the same character position.
1092        let lines: Vec<&str> = out.lines().filter(|l| l.contains(':')).collect();
1093        assert_eq!(lines.len(), 3);
1094        let value_starts: Vec<usize> = lines
1095            .iter()
1096            .map(|l| l.find(':').unwrap() + l[l.find(':').unwrap()..].find(' ').unwrap())
1097            .collect();
1098        // The value (after `label:` plus padding plus two spaces) starts at the
1099        // same column for every row.  We verify by checking all lines have the
1100        // value substring at the same byte offset.
1101        let value_col = lines[0].rfind("abc").unwrap();
1102        assert_eq!(lines[1].rfind("42").unwrap(), value_col);
1103        assert_eq!(lines[2].rfind("zstd").unwrap(), value_col);
1104        // Sanity: silence unused.
1105        let _ = value_starts;
1106    }
1107
1108    #[test]
1109    fn format_block_header_has_consistent_width() {
1110        let block_a = format_block("a", &[("rows", "1".into())]);
1111        let block_b = format_block("orders_table_xyz", &[("rows", "1".into())]);
1112        let header_a = block_a.lines().nth(1).unwrap();
1113        let header_b = block_b.lines().nth(1).unwrap();
1114        assert_eq!(
1115            header_a.chars().count(),
1116            header_b.chars().count(),
1117            "headers must be the same width regardless of name length: {:?} vs {:?}",
1118            header_a,
1119            header_b
1120        );
1121    }
1122
1123    #[test]
1124    fn render_produces_a_single_string_with_trailing_newline() {
1125        use crate::plan::{
1126            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1127            MetaColumns, ResolvedRunPlan,
1128        };
1129        use crate::tuning::SourceTuning;
1130        let plan = ResolvedRunPlan {
1131            export_name: "orders".into(),
1132            base_query: "SELECT 1".into(),
1133            strategy: ExtractionStrategy::Snapshot,
1134            format: FormatType::Parquet,
1135            compression: CompressionType::default(),
1136            compression_level: None,
1137            max_file_size_bytes: None,
1138            skip_empty: false,
1139            meta_columns: MetaColumns::default(),
1140            destination: DestinationConfig {
1141                destination_type: DestinationType::Local,
1142                path: Some("./out".into()),
1143                ..Default::default()
1144            },
1145            quality: None,
1146            tuning: SourceTuning::from_config(None),
1147            tuning_profile_label: "balanced (default)".into(),
1148            validate: false,
1149            reconcile: false,
1150            resume: false,
1151            source: crate::config::SourceConfig {
1152                source_type: crate::config::SourceType::Postgres,
1153                url: Some("postgresql://localhost/test".into()),
1154                url_env: None,
1155                url_file: None,
1156                host: None,
1157                port: None,
1158                user: None,
1159                password: None,
1160                password_env: None,
1161                database: None,
1162                environment: None,
1163                tuning: None,
1164                tls: None,
1165                mongo: None,
1166            },
1167            column_overrides: Default::default(),
1168            verify: crate::config::VerifyMode::Size,
1169            schema_drift_policy: Default::default(),
1170            shape_drift_warn_factor: 2.0,
1171            parquet: None,
1172        };
1173        let mut s = RunSummary::new(&plan);
1174        s.status = "success".into();
1175        s.total_rows = 1_000_908;
1176        s.files_produced = 11;
1177        s.bytes_written = 32 * 1024 * 1024 + 400 * 1024;
1178        s.duration_ms = 68_400;
1179        s.peak_rss_mb = 884;
1180
1181        let block = s.render();
1182        assert!(
1183            block.starts_with('\n'),
1184            "block should start with a blank line"
1185        );
1186        assert!(block.ends_with('\n'), "block should end with a newline");
1187        assert!(block.contains("── orders "));
1188        assert!(
1189            block.contains("1,000,908"),
1190            "rows should be formatted with thousands separator: {}",
1191            block
1192        );
1193        assert!(block.contains("1m 08.4s"), "duration formatting: {}", block);
1194        // No raw progress-bar bleed: header dashes still present, no carriage
1195        // returns or escape sequences.
1196        assert!(!block.contains('\r'));
1197
1198        // Compact one-liner used in multi-export runs.
1199        let line = s.render_compact();
1200        assert!(line.starts_with("✓ "), "success icon present: {:?}", line);
1201        assert!(line.contains("orders"), "export name present: {:?}", line);
1202        assert!(line.contains("1,000,908 rows"), "rows present: {:?}", line);
1203        assert!(line.contains("32.4 MB"), "bytes present: {:?}", line);
1204        assert!(line.contains("1m 08.4s"), "duration present: {:?}", line);
1205        assert!(line.contains("RSS 884 MB"), "rss present: {:?}", line);
1206        assert!(!line.contains('\n'), "single line: {:?}", line);
1207    }
1208
1209    #[test]
1210    fn compact_error_summarises_parallel_chunk_errors() {
1211        let raw = "export 'page_views': parallel checkpoint worker errors:\n\
1212                   chunk 4: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202442_chunk4.parquet?partNumber=1&uploadId=ABPnzm7RqplA, called: http_util::Client::send } => send http request, source: error sending request: client error (SendRequest): dispatch task is gone\n\
1213                   chunk 5: Unexpected (temporary) at write, context: { url: https://storage.googleapis.com/rivet_data_test/exports%2Fpage_views%2Fpage_views_20260430_202443_chunk5.parquet?partNumber=1&uploadId=ABPnzm6q, called: http_util::Client::send } => send http request, source: dispatch task is gone";
1214        let out = compact_error(raw);
1215        assert!(
1216            out.contains("2 chunk(s)"),
1217            "should report number of failed chunks: {:?}",
1218            out
1219        );
1220        assert!(
1221            out.starts_with("export 'page_views': parallel checkpoint workers failed:"),
1222            "should keep export prefix and use compact phrasing: {:?}",
1223            out
1224        );
1225        assert!(
1226            out.contains("chunk 4:"),
1227            "should include the first chunk as an example: {:?}",
1228            out
1229        );
1230        assert!(!out.contains('\n'), "single line output: {:?}", out);
1231        assert!(
1232            out.chars().count() <= 240,
1233            "must be clamped to <=240 chars, got {}: {:?}",
1234            out.chars().count(),
1235            out
1236        );
1237    }
1238
1239    #[test]
1240    fn compact_error_collapses_generic_multiline() {
1241        let raw = "first line of trouble\nsecond line with detail\n\nthird line\n";
1242        let out = compact_error(raw);
1243        assert_eq!(
1244            out, "first line of trouble; second line with detail; third line",
1245            "newlines should collapse to '; ' and blanks dropped"
1246        );
1247    }
1248
1249    #[test]
1250    fn compact_error_clamps_excessively_long_lines() {
1251        let raw = "x".repeat(1_000);
1252        let out = compact_error(&raw);
1253        assert_eq!(out.chars().count(), 240);
1254        assert!(out.ends_with('…'));
1255    }
1256
1257    #[test]
1258    fn render_compact_strips_chunked_recovery_hint_for_failed() {
1259        use crate::plan::{
1260            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1261            MetaColumns, ResolvedRunPlan,
1262        };
1263        use crate::tuning::SourceTuning;
1264        let plan = ResolvedRunPlan {
1265            export_name: "events".into(),
1266            base_query: "SELECT 1".into(),
1267            strategy: ExtractionStrategy::Snapshot,
1268            format: FormatType::Parquet,
1269            compression: CompressionType::default(),
1270            compression_level: None,
1271            max_file_size_bytes: None,
1272            skip_empty: false,
1273            meta_columns: MetaColumns::default(),
1274            destination: DestinationConfig {
1275                destination_type: DestinationType::Local,
1276                path: Some("./out".into()),
1277                ..Default::default()
1278            },
1279            quality: None,
1280            tuning: SourceTuning::from_config(None),
1281            tuning_profile_label: "balanced (default)".into(),
1282            validate: false,
1283            reconcile: false,
1284            resume: false,
1285            source: crate::config::SourceConfig {
1286                source_type: crate::config::SourceType::Postgres,
1287                url: Some("postgresql://localhost/test".into()),
1288                url_env: None,
1289                url_file: None,
1290                host: None,
1291                port: None,
1292                user: None,
1293                password: None,
1294                password_env: None,
1295                database: None,
1296                environment: None,
1297                tuning: None,
1298                tls: None,
1299                mongo: None,
1300            },
1301            column_overrides: Default::default(),
1302            verify: crate::config::VerifyMode::Size,
1303            schema_drift_policy: Default::default(),
1304            shape_drift_warn_factor: 2.0,
1305            parquet: None,
1306        };
1307        let mut s = RunSummary::new(&plan);
1308        s.status = "failed".into();
1309        s.error_message = Some(
1310            "export 'events': --resume but no in-progress chunk checkpoint; \
1311             run without --resume first or `rivet state reset-chunks --config x.yaml --export events`"
1312                .to_string(),
1313        );
1314
1315        let line = s.render_compact();
1316        assert!(line.starts_with("✗ "), "failure icon: {:?}", line);
1317        assert!(line.contains("events"), "name present: {:?}", line);
1318        assert!(
1319            line.contains("--resume but no in-progress chunk checkpoint"),
1320            "cause kept: {:?}",
1321            line
1322        );
1323        assert!(
1324            !line.contains("rivet state reset-chunks"),
1325            "recovery hint should be stripped from per-export line: {:?}",
1326            line
1327        );
1328        assert!(!line.contains('\n'), "single line: {:?}", line);
1329    }
1330
1331    fn plan_for(export_name: &str) -> crate::plan::ResolvedRunPlan {
1332        use crate::plan::{
1333            CompressionType, DestinationConfig, DestinationType, ExtractionStrategy, FormatType,
1334            MetaColumns, ResolvedRunPlan,
1335        };
1336        use crate::tuning::SourceTuning;
1337        ResolvedRunPlan {
1338            export_name: export_name.into(),
1339            base_query: "SELECT 1".into(),
1340            strategy: ExtractionStrategy::Snapshot,
1341            format: FormatType::Parquet,
1342            compression: CompressionType::default(),
1343            compression_level: None,
1344            max_file_size_bytes: None,
1345            skip_empty: false,
1346            meta_columns: MetaColumns::default(),
1347            destination: DestinationConfig {
1348                destination_type: DestinationType::Local,
1349                path: Some("./out".into()),
1350                ..Default::default()
1351            },
1352            quality: None,
1353            tuning: SourceTuning::from_config(None),
1354            tuning_profile_label: "balanced (default)".into(),
1355            validate: false,
1356            reconcile: false,
1357            resume: false,
1358            source: crate::config::SourceConfig {
1359                source_type: crate::config::SourceType::Postgres,
1360                url: Some("postgresql://localhost/test".into()),
1361                url_env: None,
1362                url_file: None,
1363                host: None,
1364                port: None,
1365                user: None,
1366                password: None,
1367                password_env: None,
1368                database: None,
1369                environment: None,
1370                tuning: None,
1371                tls: None,
1372                mongo: None,
1373            },
1374            column_overrides: Default::default(),
1375            verify: crate::config::VerifyMode::Size,
1376            schema_drift_policy: Default::default(),
1377            shape_drift_warn_factor: 2.0,
1378            parquet: None,
1379        }
1380    }
1381
1382    #[test]
1383    fn plan_snapshot_records_chunk_key_and_resumable_for_post_mortem() {
1384        use crate::plan::{ExtractionStrategy, KeysetPlan};
1385        // A keyset export must persist WHICH column paged (`id`) and that
1386        // checkpoint-resume was on, so a post-mortem from the state DB alone
1387        // explains the strategy decision without re-querying the source schema.
1388        let mut plan = plan_for("statistic_lifetime");
1389        plan.strategy = ExtractionStrategy::Keyset(KeysetPlan {
1390            key_column: "id".into(),
1391            chunk_size: 1_000_000,
1392            checkpoint: true,
1393            incremental: false,
1394            parallel: 1,
1395        });
1396        let s = RunSummary::new(&plan);
1397        let snap = s.journal.plan_snapshot().expect("PlanResolved recorded");
1398        assert_eq!(snap.chunk_key.as_deref(), Some("id"));
1399        assert!(
1400            snap.resumable,
1401            "chunk_checkpoint on → resumable must be recorded"
1402        );
1403
1404        // Full (snapshot) export → no paging column, not resumable.
1405        let full = RunSummary::new(&plan_for("small"));
1406        let fsnap = full.journal.plan_snapshot().unwrap();
1407        assert_eq!(fsnap.chunk_key, None);
1408        assert!(!fsnap.resumable);
1409    }
1410
1411    /// The snapshot is where the row-hash contract enters the trust artifacts:
1412    /// the manifest builder copies it verbatim, so the manifest can only ever
1413    /// advertise what the RESOLVED PLAN was going to hash. Taking it from
1414    /// anywhere else — the raw config, a default — would let the two disagree,
1415    /// and a reader has no way to notice.
1416    #[test]
1417    fn plan_snapshot_records_the_row_hash_contract_the_run_applies() {
1418        let mut plan = plan_for("orders");
1419        let bare = RunSummary::new(&plan);
1420        assert_eq!(
1421            bare.journal.plan_snapshot().unwrap().row_hash,
1422            None,
1423            "no hash column written ⇒ nothing to advertise"
1424        );
1425
1426        plan.meta_columns.row_hash =
1427            crate::config::RowHash::Columns(vec!["id".into(), "status".into()]);
1428        let hashed = RunSummary::new(&plan);
1429        let snap = hashed.journal.plan_snapshot().unwrap();
1430        let c = snap.row_hash.clone().expect("a declared set is recorded");
1431        assert_eq!(c.column, crate::enrich::COL_ROW_HASH);
1432        assert_eq!(c.covered, plan.meta_columns.row_hash);
1433        assert_eq!(c.render, crate::enrich::ROW_HASH_RENDER_ID);
1434    }
1435
1436    #[test]
1437    fn plan_snapshot_deserializes_pre_field_journal_as_defaults() {
1438        // A journal written before chunk_key/resumable existed (real: the state
1439        // DBs already in the field) must still deserialize — the new fields
1440        // default to None/false, never a hard error that would break `--resume`
1441        // or a post-mortem read of an older run.
1442        let legacy = r#"{
1443            "export_name":"orders","base_query":"SELECT 1","strategy":"keyset",
1444            "format":"parquet","compression":"zstd","destination_type":"gcs",
1445            "tuning_profile":"balanced","batch_size":10000,
1446            "validate":false,"reconcile":false,"resume":false
1447        }"#;
1448        let snap: crate::journal::PlanSnapshot = serde_json::from_str(legacy).unwrap();
1449        assert_eq!(snap.chunk_key, None);
1450        assert!(!snap.resumable);
1451        assert_eq!(snap.strategy, "keyset");
1452    }
1453
1454    #[test]
1455    fn render_preserves_multiline_error_block() {
1456        // L19: a multi-line error (a quality failure here) must stay multi-line
1457        // in the detailed single-export block — not collapsed to `"; "`-joined
1458        // text the way the compact one-liner does.
1459        let mut s = RunSummary::new(&plan_for("orders"));
1460        s.status = "failed".into();
1461        s.error_message = Some(
1462            "export 'orders': 1 quality check(s) failed:\n  \
1463             - row_count 10 below minimum 999999\n  \
1464             Fix the source data, or adjust the thresholds under `quality:` in your config."
1465                .to_string(),
1466        );
1467
1468        let block = s.render();
1469        // The collapsed form joined lines with `"; "` — assert that flattening
1470        // is gone and the original newline structure survives.
1471        assert!(
1472            !block.contains("failed:;"),
1473            "error must not be '; '-flattened in the detailed block: {block}"
1474        );
1475        assert!(
1476            block.contains("- row_count 10 below minimum 999999"),
1477            "failing check line present: {block}"
1478        );
1479        // Each part of the multi-line error lands on its own line.
1480        let err_lines: Vec<&str> = block
1481            .lines()
1482            .filter(|l| {
1483                l.contains("quality check(s) failed")
1484                    || l.contains("row_count 10 below minimum")
1485                    || l.contains("Fix the source data")
1486            })
1487            .collect();
1488        assert_eq!(
1489            err_lines.len(),
1490            3,
1491            "all three error lines should render on separate lines: {block}"
1492        );
1493        // Continuation lines are indented under the value column, not at col 0.
1494        for l in &err_lines {
1495            assert!(l.starts_with(' '), "error line should be indented: {l:?}");
1496        }
1497    }
1498
1499    #[test]
1500    fn render_nudges_verification_when_unverified_success() {
1501        // #4: a successful run that wrote files but ran no verification pass
1502        // should surface an advisory `verify:` line — so skipping it is a choice.
1503        let mut s = RunSummary::new(&plan_for("orders"));
1504        s.status = "success".into();
1505        s.files_produced = 3;
1506        s.total_rows = 1_000;
1507        // validated / reconciled left None (no --validate / --reconcile).
1508        let block = s.render();
1509        assert!(
1510            block.lines().any(|l| l.trim_start().starts_with("verify:")),
1511            "expected a verify nudge on an unverified success: {block}"
1512        );
1513
1514        // A run that verified must NOT nudge.
1515        let mut s2 = RunSummary::new(&plan_for("orders"));
1516        s2.status = "success".into();
1517        s2.files_produced = 3;
1518        s2.validated = Some(true);
1519        let block2 = s2.render();
1520        assert!(
1521            !block2
1522                .lines()
1523                .any(|l| l.trim_start().starts_with("verify:")),
1524            "a verified run must not nudge: {block2}"
1525        );
1526    }
1527
1528    #[test]
1529    fn pg_temp_spill_row_only_for_real_spill_and_annotates_large() {
1530        // Direct provider test — previously this threshold logic was only
1531        // reachable by rendering a whole block (and no test set the field).
1532        let mut s = RunSummary::stub_for_testing("r", "orders");
1533        assert_eq!(s.pg_temp_spill_row(), None, "no delta → no row");
1534        s.pg_temp_bytes_delta = Some(0);
1535        assert_eq!(s.pg_temp_spill_row(), None, "zero spill → no row");
1536        s.pg_temp_bytes_delta = Some(-5);
1537        assert_eq!(s.pg_temp_spill_row(), None, "negative delta → no row");
1538
1539        s.pg_temp_bytes_delta = Some(50 * 1024 * 1024);
1540        let (label, value) = s.pg_temp_spill_row().expect("50MB spill → row");
1541        assert_eq!(label, "pg temp spill");
1542        assert!(
1543            value.contains("50.0 MB") && !value.contains('⚠'),
1544            "small spill is plain info: {value:?}"
1545        );
1546
1547        s.pg_temp_bytes_delta = Some(200 * 1024 * 1024);
1548        let (_, value) = s.pg_temp_spill_row().expect("200MB spill → row");
1549        assert!(
1550            value.contains('⚠') && value.contains("batch_size"),
1551            "spill over 100 MB carries the tuning hint: {value:?}"
1552        );
1553    }
1554
1555    #[test]
1556    fn post_run_invariants_flag_a_runner_that_skipped_a_facade() {
1557        use crate::manifest::{ColumnChecksum, ManifestPart, PartStatus};
1558        // A state_backed success that committed one part — the shape every batch
1559        // runner produces. base() applied BOTH facades; each variant drops one.
1560        let base = || {
1561            let mut s = RunSummary::stub_for_testing("r", "orders");
1562            s.status = "success".into();
1563            s.state_backed = true;
1564            s.files_committed = 1;
1565            s.files_produced = 1;
1566            s.bytes_written = 10;
1567            s.total_rows = 5;
1568            s.manifest_parts.push(ManifestPart {
1569                part_id: 1,
1570                path: "p.parquet".into(),
1571                rows: 5,
1572                size_bytes: 10,
1573                content_fingerprint: String::new(),
1574                content_md5: String::new(),
1575                status: PartStatus::Committed,
1576            });
1577            s
1578        };
1579        let ck = || ColumnChecksum {
1580            name: "id".into(),
1581            checksum: "1".into(),
1582        };
1583
1584        // Both facades applied → passes.
1585        let mut ok = base();
1586        ok.schema_changed = Some(false);
1587        ok.column_checksums.push(ck());
1588        assert!(ok.check_post_run_invariants(false).is_ok());
1589
1590        // Drift gate skipped (schema_changed None) → RED.
1591        let mut no_drift = base();
1592        no_drift.column_checksums.push(ck());
1593        let e = no_drift.check_post_run_invariants(false).unwrap_err();
1594        assert!(e.contains("on_schema_drift gate was never applied"), "{e}");
1595
1596        // Form-B harvest skipped (empty + not incomplete) → RED.
1597        let mut no_formb = base();
1598        no_formb.schema_changed = Some(false);
1599        let e = no_formb.check_post_run_invariants(false).unwrap_err();
1600        assert!(e.contains("Form-B was never harvested"), "{e}");
1601
1602        // A suppressed Form-B (checkpoint resume) is NOT a bypass.
1603        let mut suppressed = base();
1604        suppressed.schema_changed = Some(false);
1605        suppressed.column_checksums_incomplete = true;
1606        assert!(suppressed.check_post_run_invariants(false).is_ok());
1607
1608        // NOT state_backed (CDC / a direct-runner unit test) is exempt from the contract.
1609        let mut cdc = base();
1610        cdc.state_backed = false;
1611        assert!(
1612            cdc.check_post_run_invariants(false).is_ok(),
1613            "a non-run_export run must not be held to the facade contract"
1614        );
1615
1616        // `rivet apply` with PRECOMPUTED chunk boundaries legitimately skips the
1617        // drift gate too: the gate lives inside `prepare_chunk_plan`, whose own
1618        // contract says a precomputed source never calls it ("drift was evaluated
1619        // on the original planning run that produced the ranges"). Without this
1620        // exemption the guard reads a correct apply as a runner-bypass — which is
1621        // exactly what happened the moment `apply` was fixed to actually replay
1622        // its artifact instead of re-detecting: every chunked apply started
1623        // aborting at finalize with exit 101.
1624        let mut applied = base();
1625        applied.chunks_precomputed = true;
1626        applied.column_checksums.push(ck());
1627        // schema_changed stays None
1628        assert!(
1629            applied.check_post_run_invariants(false).is_ok(),
1630            "an apply replaying precomputed chunks must not trip the drift-gate branch"
1631        );
1632
1633        // …and the exemption is NARROW: it is about the precomputed source, not
1634        // about "any apply". A run that did NOT come from an artifact still has to
1635        // show the gate ran, or the flag would be a blanket hole in the guard.
1636        let mut not_applied = base();
1637        not_applied.chunks_precomputed = false;
1638        not_applied.column_checksums.push(ck());
1639        assert!(
1640            not_applied.check_post_run_invariants(false).is_err(),
1641            "the precomputed exemption must not weaken the guard for ordinary runs"
1642        );
1643
1644        // ── the guard's own BOUNDARIES ────────────────────────────────────────
1645        // Every case above varies what the runner DID; none varies the conditions
1646        // that decide whether the guard looks at all. Mutation testing found seven
1647        // survivors in exactly those conditions (`status == "success"`,
1648        // `files_committed > 0`, `total_rows > 0`) — the comparisons could be
1649        // inverted or loosened and no test noticed, in the one function whose job
1650        // is to notice. These pin each boundary from BOTH sides.
1651
1652        // `files_committed > 0`: a success that committed NOTHING has no facade
1653        // obligation — there is no output to have applied them to. Loosening the
1654        // comparison to `>= 0` must not start flagging it.
1655        let mut nothing_committed = base();
1656        nothing_committed.files_committed = 0;
1657        nothing_committed.files_produced = 0;
1658        nothing_committed.bytes_written = 0;
1659        nothing_committed.total_rows = 0;
1660        nothing_committed.manifest_parts.clear();
1661        assert!(
1662            nothing_committed.check_post_run_invariants(false).is_ok(),
1663            "a success that committed no files owes no facades"
1664        );
1665
1666        // …and the same shape WITH rows extracted is the fabrication signature the
1667        // second branch exists for: rows read, nothing landed.
1668        let mut rows_but_no_files = base();
1669        rows_but_no_files.files_committed = 0;
1670        rows_but_no_files.files_produced = 0;
1671        rows_but_no_files.manifest_parts.clear();
1672        rows_but_no_files.bytes_written = 0;
1673        rows_but_no_files.total_rows = 5;
1674        let e = rows_but_no_files
1675            .check_post_run_invariants(false)
1676            .unwrap_err();
1677        assert!(e.contains("no files committed"), "{e}");
1678
1679        // `total_rows > 0`: zero rows AND zero files is a legitimate empty run
1680        // (a resume with nothing to do), not a fabrication.
1681        let mut empty_run = base();
1682        empty_run.files_committed = 0;
1683        empty_run.files_produced = 0;
1684        empty_run.total_rows = 0;
1685        empty_run.bytes_written = 0;
1686        empty_run.manifest_parts.clear();
1687        assert!(
1688            empty_run.check_post_run_invariants(false).is_ok(),
1689            "an empty run must not read as rows-extracted-but-nothing-landed"
1690        );
1691
1692        // `status == "success"`: a FAILED run is not held to any of it — the
1693        // facades may legitimately not have run, which is why it failed.
1694        let mut failed = base();
1695        failed.status = "failed".into();
1696        failed.schema_changed = Some(false);
1697        failed.column_checksums.push(ck());
1698        assert!(
1699            failed.check_post_run_invariants(false).is_ok(),
1700            "a failed run must not be graded against the success-only invariants"
1701        );
1702
1703        // Committed files with an EMPTY part list — the ADR-0012 M1 gap. It is
1704        // reported by the files_committed-vs-len branch, which subsumes it; a
1705        // dedicated branch for it was unreachable and has been removed.
1706        let mut no_parts = base();
1707        no_parts.schema_changed = Some(false);
1708        no_parts.column_checksums.push(ck());
1709        no_parts.manifest_parts.clear();
1710        let e = no_parts.check_post_run_invariants(false).unwrap_err();
1711        assert!(e.contains("ADR-0012 M1"), "{e}");
1712
1713        // A RESUME legitimately skips the drift gate (schema_changed None) — exempt (H2).
1714        let mut resumed = base();
1715        resumed.resumed = true;
1716        resumed.column_checksums.push(ck());
1717        // schema_changed stays None
1718        assert!(
1719            resumed.check_post_run_invariants(false).is_ok(),
1720            "a resume must not trip the drift-gate branch"
1721        );
1722
1723        // A --resume run that crashed BEFORE its first chunk committed adopts no prior
1724        // work, so `resumed` (adopted_prior_work) is FALSE — but the drift gate was
1725        // still legitimately skipped. Passing is_resume_run=true (plan.resume) must
1726        // exempt it; keying only off `resumed` would panic a legitimate crash-recovery
1727        // resume in debug/test. RED against `if !self.resumed && schema_changed.is_none()`.
1728        let mut resume_flag_no_adopt = base();
1729        resume_flag_no_adopt.resumed = false;
1730        resume_flag_no_adopt.column_checksums.push(ck());
1731        // schema_changed stays None (gate skipped on the --resume path)
1732        assert!(
1733            resume_flag_no_adopt.check_post_run_invariants(true).is_ok(),
1734            "a --resume run that adopted no prior work (crash before first commit) must be exempt"
1735        );
1736        // ...and the SAME summary with is_resume_run=false (a fresh run) MUST still trip.
1737        assert!(
1738            resume_flag_no_adopt
1739                .check_post_run_invariants(false)
1740                .unwrap_err()
1741                .contains("on_schema_drift gate was never applied"),
1742            "a fresh (non-resume) run with the gate skipped must still be caught"
1743        );
1744
1745        // A CSV/JSONL export harvests NO Form-B by design — exempt (M4).
1746        let mut csv = base();
1747        csv.schema_changed = Some(false);
1748        csv.format = "csv".into();
1749        // column_checksums empty, incomplete false
1750        assert!(
1751            csv.check_post_run_invariants(false).is_ok(),
1752            "a non-Parquet export must not trip the Form-B branch"
1753        );
1754    }
1755
1756    #[test]
1757    fn outcome_rows_format_reconcile_and_suppress_nudge_when_checked() {
1758        let mut s = RunSummary::stub_for_testing("r", "orders");
1759        s.reconciled = Some(true);
1760        s.source_count = Some(1_000);
1761        s.total_rows = 1_000;
1762        assert!(
1763            s.outcome_rows()
1764                .iter()
1765                .any(|(l, v)| *l == "reconcile" && v == "MATCH (1,000/1,000)"),
1766            "match wording: {:?}",
1767            s.outcome_rows()
1768        );
1769
1770        s.reconciled = Some(false);
1771        s.source_count = Some(1_200);
1772        let rows = s.outcome_rows();
1773        let recon = rows
1774            .iter()
1775            .find(|(l, _)| *l == "reconcile")
1776            .expect("reconcile row");
1777        assert!(
1778            recon.1.contains("MISMATCH") && recon.1.contains("1,000") && recon.1.contains("1,200"),
1779            "mismatch names both sides: {:?}",
1780            recon
1781        );
1782
1783        // A set reconcile result suppresses the verify nudge even on a
1784        // files-produced success (the nudge's gate lives beside this field).
1785        s.status = "success".into();
1786        s.files_produced = 2;
1787        assert!(
1788            !s.outcome_rows().iter().any(|(l, _)| *l == "verify"),
1789            "a reconciled run must not also nudge"
1790        );
1791    }
1792
1793    #[test]
1794    fn render_surfaces_cursor_position_on_zero_new_incremental() {
1795        // L27: a 0-new incremental run shows `0 rows  0 files`; without a
1796        // cursor line the operator can't tell the boundary held. Assert the
1797        // dedicated `cursor:` line appears, derived from `skip_reason`.
1798        let mut s = RunSummary::new(&plan_for("orders"));
1799        s.status = "skipped".into();
1800        s.skip_reason = Some("no new rows since cursor 'updated_at'".into());
1801
1802        let block = s.render();
1803        let cursor_line = block
1804            .lines()
1805            .find(|l| l.trim_start().starts_with("cursor:"))
1806            .unwrap_or_else(|| panic!("expected a cursor: line in block: {block}"));
1807        assert!(
1808            cursor_line.contains("'updated_at'"),
1809            "cursor line names the column: {cursor_line:?}"
1810        );
1811        assert!(
1812            cursor_line.contains("unchanged"),
1813            "cursor line reports the position held: {cursor_line:?}"
1814        );
1815    }
1816
1817    #[test]
1818    fn incremental_position_line_only_for_cursor_skips() {
1819        // The non-cursor 0-row skip and the no-skip case produce no cursor line.
1820        assert_eq!(
1821            incremental_position_line(Some("no new rows since cursor 'ts'")),
1822            Some("'ts' unchanged (no new rows this run)".into())
1823        );
1824        assert_eq!(
1825            incremental_position_line(Some("source returned 0 rows")),
1826            None
1827        );
1828        assert_eq!(incremental_position_line(None), None);
1829    }
1830
1831    #[test]
1832    fn render_surfaces_window_position_on_zero_row_time_window() {
1833        // L27 (time_window arm): a 0-row time_window run reports the generic
1834        // `"source returned 0 rows"` skip (the strategy has no cursor column),
1835        // so the `cursor:` branch never fires. Without a `window:` line the
1836        // operator can't tell an empty window from a wrong column/window —
1837        // assert the dedicated `window:` line appears for this mode.
1838        let mut s = RunSummary::new(&plan_for("events"));
1839        s.status = "skipped".into();
1840        s.mode = "timewindow".into();
1841        s.skip_reason = Some("source returned 0 rows".into());
1842
1843        let block = s.render();
1844        let window_line = block
1845            .lines()
1846            .find(|l| l.trim_start().starts_with("window:"))
1847            .unwrap_or_else(|| panic!("expected a window: line in block: {block}"));
1848        assert!(
1849            window_line.contains("matched no rows"),
1850            "window line reports the empty window: {window_line:?}"
1851        );
1852        assert!(
1853            window_line.contains("time_column") && window_line.contains("days_window"),
1854            "window line points at the window config to check: {window_line:?}"
1855        );
1856        // The generic 0-row skip must not also produce a `cursor:` line.
1857        assert!(
1858            !block.lines().any(|l| l.trim_start().starts_with("cursor:")),
1859            "no cursor line for a non-cursor strategy: {block}"
1860        );
1861    }
1862
1863    #[test]
1864    fn time_window_skip_line_only_for_skipped_time_window() {
1865        // Fires only when the run skipped AND the strategy is time_window.
1866        assert_eq!(
1867            time_window_skip_line("timewindow", Some("source returned 0 rows")),
1868            Some("rolling time window matched no rows — check `time_column`/`days_window`".into())
1869        );
1870        // Wrong mode → no window line (incremental/snapshot handle their own).
1871        assert_eq!(
1872            time_window_skip_line("incremental", Some("source returned 0 rows")),
1873            None
1874        );
1875        assert_eq!(
1876            time_window_skip_line("full", Some("source returned 0 rows")),
1877            None
1878        );
1879        // A time_window run that produced rows (no skip) gets no window line.
1880        assert_eq!(time_window_skip_line("timewindow", None), None);
1881    }
1882}