Skip to main content

rivet/
manifest.rs

1//! **Layer: Trust contract**
2//!
3//! Public JSON manifest written next to every cloud-or-local-file run's
4//! output.  Defines the wire schema and the in-memory builder; the actual
5//! writer (atomic rename / atomic PUT) lives next to the destination
6//! implementations.
7//!
8//! Invariants are documented in [`docs/adr/0012-cloud-manifest-contract.md`].
9//! This module owns *only* the data types and a tiny set of pure helpers —
10//! ordering, atomicity, and `_SUCCESS` semantics belong to the writer.
11//!
12//! The manifest is read by:
13//! - `--resume` (decision matrix M8: skip / rewrite / quarantine)
14//! - `--validate` (M5: every listed part exists at recorded size)
15//! - `--reconcile` (manifest row counts vs source `COUNT(*)`)
16//! - the run report (informational; not a verdict source)
17//!
18//! Forward compatibility: callers MUST ignore unknown fields when reading.
19//! Field additions are non-breaking; field removals or type changes require
20//! a [`MANIFEST_VERSION`] bump.
21
22// The wire types (RunManifest, ManifestPart, ...) and the writer-side
23// helpers (success_marker_body) are already wired into the pipeline; the
24// reader-side helpers (validate_self_consistency, committed_rows,
25// committed_part_count, parse_success_marker, ManifestInconsistency) ship
26// next when `--validate` / `--reconcile` learn to inspect the manifest.
27// Mark the whole module as dead-code-tolerant until then so the bin crate
28// (which doesn't compile tests) stays clean.
29#![allow(dead_code)]
30
31use serde::{Deserialize, Serialize};
32
33/// Current manifest schema version.  See ADR-0012 §Manifest schema.
34pub const MANIFEST_VERSION: u32 = 1;
35
36/// File name of the manifest at the destination prefix.
37pub const MANIFEST_FILENAME: &str = "manifest.json";
38
39/// File name of the success marker.  Written *after* the manifest per M2;
40/// its presence implies M5 (every listed part exists at recorded size).
41pub const SUCCESS_FILENAME: &str = "_SUCCESS";
42
43/// Prefix under which untracked / corrupt parts are moved on resume (M9).
44/// Layout: `<prefix>/_quarantine/<run_id>/<original-name>`.
45pub const QUARANTINE_PREFIX: &str = "_quarantine";
46
47/// Writability probe `rivet doctor` drops at the destination prefix.  It is a
48/// Rivet-internal sidecar (like [`MANIFEST_FILENAME`] / [`SUCCESS_FILENAME`]),
49/// so the manifest-aware `--validate` pass must not flag it as an untracked
50/// foreign object when a run follows a `doctor` against the same prefix.
51pub const DOCTOR_PROBE_FILENAME: &str = ".rivet_doctor_probe";
52
53/// Join a manifest-relative key (e.g. a part `path`, [`MANIFEST_FILENAME`])
54/// onto a destination sub-directory.  An empty `dir` returns `key` unchanged
55/// — the common case, since production callers pass `""` (the manifest lives
56/// at the prefix root).  Shared by the destination-verification and
57/// resume-reconciliation paths so both speak the same key namespace.
58pub fn join_key(dir: &str, key: &str) -> String {
59    let dir = dir.trim_end_matches('/');
60    if dir.is_empty() {
61        key.to_string()
62    } else {
63        format!("{dir}/{key}")
64    }
65}
66
67/// Compute the body of the `_SUCCESS` marker for a given serialized manifest.
68///
69/// Format: a single line `"xxh3:<16-hex>\n"`.  ADR-0012 M2 — `_SUCCESS`
70/// carries the manifest fingerprint so an orchestrator can detect manifest
71/// changes (rerun, resume that completed, repair) with a cheap `GET _SUCCESS`
72/// instead of refetching the full manifest body.
73///
74/// `manifest_bytes` must be the exact bytes that were written to `manifest.json`
75/// — usually the result of `serde_json::to_vec_pretty(&RunManifest)`.  The
76/// caller is responsible for using the same bytes for both writes; computing
77/// the fingerprint from a re-serialized struct would risk encoding drift
78/// (key ordering, whitespace) producing a different hash.
79pub fn success_marker_body(manifest_bytes: &[u8]) -> String {
80    use xxhash_rust::xxh3::xxh3_64;
81    format!("xxh3:{:016x}\n", xxh3_64(manifest_bytes))
82}
83
84/// Parse the fingerprint out of a `_SUCCESS` marker body.
85///
86/// Returns `Some("xxh3:<hex>")` on a well-formed marker, `None` on anything
87/// else (empty file, missing prefix, wrong length, non-hex body).  Trailing
88/// whitespace and newlines are tolerated to match the on-wire shape produced
89/// by [`success_marker_body`].
90///
91/// Used by `--validate` and by external polling consumers (Airflow sensors,
92/// CI checks) to decide whether a cached manifest is still current.
93pub fn parse_success_marker(body: &str) -> Option<&str> {
94    let trimmed = body.trim_end_matches(|c: char| c.is_ascii_whitespace());
95    if trimmed.len() != "xxh3:".len() + 16 {
96        return None;
97    }
98    let (prefix, hex) = trimmed.split_at("xxh3:".len());
99    if prefix != "xxh3:" {
100        return None;
101    }
102    if !hex
103        .chars()
104        .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
105    {
106        return None;
107    }
108    Some(trimmed)
109}
110
111/// One per-column Form B checksum, keyed by column **name** (not position) so a
112/// column reorder between export and validate can never silently misalign the
113/// comparison (the positional `Vec<String>` it replaced could). `checksum` is the
114/// per-column xxh3, XOR-combined over the whole export, as a decimal string
115/// (JSON-stable).
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
117pub struct ColumnChecksum {
118    pub name: String,
119    pub checksum: String,
120}
121
122/// Public, stable JSON shape for the run manifest.
123///
124/// One manifest is written per `run_id` per export.  See ADR-0012 M4
125/// (Append-Only Per Run) for the resume-across-interruption story.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct RunManifest {
128    pub manifest_version: u32,
129    pub run_id: String,
130    pub export_name: String,
131    /// Which pipeline shape wrote this manifest — `batch` or `cdc`. Guards a
132    /// prefix against silent cross-shape clobbering (finding #44: a CDC run
133    /// overwrote a batch export's manifest at a shared prefix, orphaning its
134    /// parts from `rivet validate`). Defaults to `batch` for manifests
135    /// written before this field existed.
136    #[serde(default = "default_manifest_mode")]
137    pub mode: String,
138    pub started_at: String,
139    pub finished_at: String,
140    pub status: ManifestStatus,
141    pub source: ManifestSource,
142    pub destination: ManifestDestination,
143    pub format: String,
144    pub compression: String,
145    /// xxh3 fingerprint of the column schema; see [`crate::state::schema_fingerprint`].
146    pub schema_fingerprint: String,
147    pub row_count: i64,
148    pub part_count: u32,
149    pub parts: Vec<ManifestPart>,
150    /// Per-column value checksum over the whole export (Form B), keyed by column
151    /// name — the per-column xxh3 XOR-combined over the run. `rivet validate`
152    /// re-reads the parts and recomputes this to catch an `Arrow→Parquet` encode
153    /// fault or post-write corruption — the step the in-process Form A check
154    /// cannot see. Optional for back-compat: older manifests omit it (no
155    /// `MANIFEST_VERSION` bump), newer readers tolerate its absence.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub column_checksums: Option<Vec<ColumnChecksum>>,
158    /// The column the Form B checksum is keyed to (`xxh3(key ‖ value)`, the
159    /// export's cursor/key column) so `validate` re-keys identically. `None` ⇒
160    /// un-keyed (a full export with no cursor). See [`ColumnChecksum`].
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub checksum_key_column: Option<String>,
163}
164
165/// Terminal status of the run *as recorded by the writer*.
166///
167/// `success` is only written when M2 (Manifest Before SUCCESS) is satisfied
168/// — i.e. when the writer is about to drop the `_SUCCESS` marker.
169/// `failed` and `interrupted` manifests serve as audit trails and as input
170/// to resume; they do NOT trigger `_SUCCESS`.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum ManifestStatus {
174    Success,
175    Failed,
176    Interrupted,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct ManifestSource {
181    pub engine: String,
182    pub schema: Option<String>,
183    pub table: Option<String>,
184    /// The extraction contract this run fulfilled — strategy, cursor identity,
185    /// and the cursor RANGE this extract covered. Ported from the
186    /// pip_db_replicator meta-catalog idea: cursor metadata travels WITH the
187    /// extract so a downstream warehouse can reconcile continuity
188    /// (`run N+1.cursor_low` must follow `run N.cursor_high` — a gap is a
189    /// silently-skipped range) without querying rivet's private state.
190    /// `None` on manifests written before this field existed, and on paths
191    /// that carry no cursor (a full snapshot records only the strategy).
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub extraction: Option<ExtractionMetadata>,
194}
195
196/// Cursor/strategy metadata shipped in the manifest for warehouse-side
197/// reconciliation. Cursor bounds are STRING-encoded and type-tagged: a lexical
198/// order lies on numbers (the same trap as a binlog `__pos` string), so the
199/// consumer compares by `cursor_type`, not by raw string order.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct ExtractionMetadata {
202    /// `full` / `incremental` / `chunked` / `keyset` / `timewindow`.
203    pub strategy: String,
204    /// Resolved cursor/key column, when the strategy has one.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub cursor_column: Option<String>,
207    /// Source type of the cursor column (for typed range comparison).
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub cursor_type: Option<String>,
210    /// Lowest cursor value covered by THIS extract (the prior run's high, or
211    /// the min seen this run). `None` for a full snapshot / first run.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub cursor_low: Option<String>,
214    /// Highest cursor value covered — the value the NEXT run must resume from.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub cursor_high: Option<String>,
217    /// Source-side row count at extraction time, when cheaply known — distinct
218    /// from the manifest's `row_count` (rows EXTRACTED). A divergence is the
219    /// reconciliation signal. `None` when not probed.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub source_row_count: Option<i64>,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
225pub struct ManifestDestination {
226    pub kind: String,
227    pub uri: String,
228}
229
230/// One committed (or quarantined) output part.
231///
232/// `path` is **relative to the destination prefix** (ADR-0012 §Manifest
233/// schema) so the manifest is portable across copies of the dataset.
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
235pub struct ManifestPart {
236    pub part_id: u32,
237    pub path: String,
238    pub rows: i64,
239    pub size_bytes: u64,
240    /// xxh3 fingerprint of the part body.  Format mirrors [`crate::state::schema_fingerprint`]:
241    /// `"xxh3:<16-hex>"`.  Algorithm prefix MUST be checked before interpreting
242    /// the hex body (sha256/blake3 reserved for future hashers).
243    pub content_fingerprint: String,
244    /// Base64 MD5 of the part body, in GCS's `md5Hash` encoding — lets
245    /// destination verification compare against the object's listing metadata
246    /// with **no download** (GCS/S3/Azure surface this; the comparison rides
247    /// the listing `--validate` already does).  Empty for legacy manifests and
248    /// for parts whose MD5 could not be computed; the check then degrades to
249    /// size-only.  `#[serde(default)]` keeps pre-0.7.x manifests parseable.
250    #[serde(default)]
251    pub content_md5: String,
252    pub status: PartStatus,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum PartStatus {
258    /// Listed in the active manifest at the destination.
259    Committed,
260    /// Found in a prior manifest but rejected on resume (M9); retained for audit.
261    Quarantined,
262}
263
264impl RunManifest {
265    /// Sum of `rows` across `Committed` parts.  Used by M5 sanity checks and
266    /// by `--reconcile` to compare against source `COUNT(*)`.
267    pub fn committed_rows(&self) -> i64 {
268        self.parts
269            .iter()
270            .filter(|p| p.status == PartStatus::Committed)
271            .map(|p| p.rows)
272            .sum()
273    }
274
275    /// Number of `Committed` parts.
276    pub fn committed_part_count(&self) -> usize {
277        self.parts
278            .iter()
279            .filter(|p| p.status == PartStatus::Committed)
280            .count()
281    }
282
283    /// Verify that the recorded aggregates (`row_count`, `part_count`) match
284    /// the actual `Committed` parts in `parts`.  A mismatch is a writer bug;
285    /// callers should refuse to act on the manifest until investigated.
286    pub fn validate_self_consistency(&self) -> std::result::Result<(), ManifestInconsistency> {
287        if self.manifest_version != MANIFEST_VERSION {
288            return Err(ManifestInconsistency::UnsupportedVersion {
289                found: self.manifest_version,
290                supported: MANIFEST_VERSION,
291            });
292        }
293        let actual_parts = self.committed_part_count();
294        if actual_parts != self.part_count as usize {
295            return Err(ManifestInconsistency::PartCountMismatch {
296                declared: self.part_count,
297                actual: actual_parts,
298            });
299        }
300        let actual_rows = self.committed_rows();
301        if actual_rows != self.row_count {
302            return Err(ManifestInconsistency::RowCountMismatch {
303                declared: self.row_count,
304                actual: actual_rows,
305            });
306        }
307        // Part IDs must be unique within a manifest.
308        let mut ids: Vec<u32> = self.parts.iter().map(|p| p.part_id).collect();
309        ids.sort_unstable();
310        for w in ids.windows(2) {
311            if w[0] == w[1] {
312                return Err(ManifestInconsistency::DuplicatePartId(w[0]));
313            }
314        }
315        Ok(())
316    }
317}
318
319/// Self-consistency failures detected by [`RunManifest::validate_self_consistency`].
320///
321/// These represent writer bugs, not destination drift; M5 destination-state
322/// checks live in the validate command path.
323#[derive(Debug, PartialEq)]
324pub enum ManifestInconsistency {
325    UnsupportedVersion { found: u32, supported: u32 },
326    PartCountMismatch { declared: u32, actual: usize },
327    RowCountMismatch { declared: i64, actual: i64 },
328    DuplicatePartId(u32),
329}
330
331impl std::fmt::Display for ManifestInconsistency {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        match self {
334            Self::UnsupportedVersion { found, supported } => write!(
335                f,
336                "manifest_version {found} is not supported by this build (expected {supported})"
337            ),
338            Self::PartCountMismatch { declared, actual } => write!(
339                f,
340                "part_count declares {declared} parts but {actual} committed parts found"
341            ),
342            Self::RowCountMismatch { declared, actual } => write!(
343                f,
344                "row_count declares {declared} rows but committed parts sum to {actual}"
345            ),
346            Self::DuplicatePartId(id) => {
347                write!(f, "duplicate part_id {id} in manifest.parts")
348            }
349        }
350    }
351}
352
353impl std::error::Error for ManifestInconsistency {}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn part(id: u32, rows: i64, size: u64) -> ManifestPart {
360        ManifestPart {
361            part_id: id,
362            path: format!("part-{id:06}.parquet"),
363            rows,
364            size_bytes: size,
365            content_fingerprint: format!("xxh3:{:016x}", id as u64),
366            content_md5: String::new(),
367            status: PartStatus::Committed,
368        }
369    }
370
371    fn manifest_with_parts(parts: Vec<ManifestPart>) -> RunManifest {
372        let row_count = parts
373            .iter()
374            .filter(|p| p.status == PartStatus::Committed)
375            .map(|p| p.rows)
376            .sum();
377        let part_count = parts
378            .iter()
379            .filter(|p| p.status == PartStatus::Committed)
380            .count() as u32;
381        RunManifest {
382            mode: "batch".to_string(),
383            manifest_version: MANIFEST_VERSION,
384            run_id: "orders_20260521T120000.000".into(),
385            export_name: "public.orders".into(),
386            started_at: "2026-05-21T12:00:00Z".into(),
387            finished_at: "2026-05-21T12:14:33Z".into(),
388            status: ManifestStatus::Success,
389            source: ManifestSource {
390                engine: "postgres".into(),
391                schema: Some("public".into()),
392                table: Some("orders".into()),
393                extraction: None,
394            },
395            destination: ManifestDestination {
396                kind: "gcs".into(),
397                uri: "gs://rivet-exports/public.orders/run/".into(),
398            },
399            format: "parquet".into(),
400            compression: "zstd".into(),
401            schema_fingerprint: "xxh3:0123456789abcdef".into(),
402            row_count,
403            part_count,
404            parts,
405            column_checksums: None,
406            checksum_key_column: None,
407        }
408    }
409
410    // ── constants ───────────────────────────────────────────────────────────
411
412    #[test]
413    fn manifest_version_is_one() {
414        assert_eq!(MANIFEST_VERSION, 1);
415    }
416
417    #[test]
418    fn filenames_are_stable() {
419        assert_eq!(MANIFEST_FILENAME, "manifest.json");
420        assert_eq!(SUCCESS_FILENAME, "_SUCCESS");
421        assert_eq!(QUARANTINE_PREFIX, "_quarantine");
422    }
423
424    // ── self-consistency ────────────────────────────────────────────────────
425
426    #[test]
427    fn self_consistent_manifest_validates() {
428        let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
429        assert_eq!(m.validate_self_consistency(), Ok(()));
430    }
431
432    #[test]
433    fn rejects_part_count_mismatch() {
434        let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
435        m.part_count = 5;
436        assert!(matches!(
437            m.validate_self_consistency(),
438            Err(ManifestInconsistency::PartCountMismatch {
439                declared: 5,
440                actual: 1
441            })
442        ));
443    }
444
445    #[test]
446    fn rejects_row_count_mismatch() {
447        let mut m = manifest_with_parts(vec![part(1, 100, 4096)]);
448        m.row_count = 999;
449        assert!(matches!(
450            m.validate_self_consistency(),
451            Err(ManifestInconsistency::RowCountMismatch {
452                declared: 999,
453                actual: 100
454            })
455        ));
456    }
457
458    #[test]
459    fn rejects_duplicate_part_id() {
460        let m = manifest_with_parts(vec![part(1, 100, 4096), part(1, 200, 8192)]);
461        let err = m.validate_self_consistency().unwrap_err();
462        assert_eq!(err, ManifestInconsistency::DuplicatePartId(1));
463    }
464
465    #[test]
466    fn rejects_unsupported_version() {
467        let mut m = manifest_with_parts(vec![]);
468        m.manifest_version = 999;
469        m.part_count = 0;
470        m.row_count = 0;
471        assert!(matches!(
472            m.validate_self_consistency(),
473            Err(ManifestInconsistency::UnsupportedVersion {
474                found: 999,
475                supported: 1
476            })
477        ));
478    }
479
480    // ── quarantined parts ──────────────────────────────────────────────────
481
482    #[test]
483    fn quarantined_parts_do_not_count_toward_row_or_part_totals() {
484        let mut p_q = part(2, 999, 8192);
485        p_q.status = PartStatus::Quarantined;
486        let m = manifest_with_parts(vec![part(1, 100, 4096), p_q]);
487
488        // The factory only counts committed; manifest must validate.
489        assert_eq!(m.validate_self_consistency(), Ok(()));
490        assert_eq!(m.committed_rows(), 100);
491        assert_eq!(m.committed_part_count(), 1);
492    }
493
494    // ── serde roundtrip ────────────────────────────────────────────────────
495
496    #[test]
497    fn json_roundtrip_preserves_fields() {
498        let m = manifest_with_parts(vec![part(1, 100, 4096), part(2, 200, 8192)]);
499        let json = serde_json::to_string_pretty(&m).unwrap();
500        let parsed: RunManifest = serde_json::from_str(&json).unwrap();
501        assert_eq!(m, parsed);
502    }
503
504    #[test]
505    fn status_serializes_as_snake_case() {
506        let m = manifest_with_parts(vec![]);
507        // Force part_count=0 so the empty-parts manifest still validates self-consistency,
508        // then check the wire form.  (This test cares about the enum encoding, not totals.)
509        let mut m = m;
510        m.part_count = 0;
511        m.row_count = 0;
512        let json = serde_json::to_string(&m).unwrap();
513        assert!(json.contains("\"status\":\"success\""));
514
515        m.status = ManifestStatus::Interrupted;
516        let json = serde_json::to_string(&m).unwrap();
517        assert!(json.contains("\"status\":\"interrupted\""));
518    }
519
520    // ── success marker ─────────────────────────────────────────────────────
521
522    #[test]
523    fn success_marker_body_is_xxh3_prefix_plus_16_hex_plus_newline() {
524        let body = success_marker_body(b"some manifest bytes");
525        assert!(body.starts_with("xxh3:"), "body = {body:?}");
526        assert!(body.ends_with('\n'), "body = {body:?}");
527        let trimmed = body.trim_end();
528        let hex = &trimmed["xxh3:".len()..];
529        assert_eq!(hex.len(), 16, "body = {body:?}");
530        assert!(
531            hex.chars()
532                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
533        );
534    }
535
536    #[test]
537    fn success_marker_body_is_deterministic_for_same_input() {
538        let a = success_marker_body(b"hello");
539        let b = success_marker_body(b"hello");
540        assert_eq!(a, b);
541    }
542
543    #[test]
544    fn success_marker_body_differs_for_different_manifest_bytes() {
545        let a = success_marker_body(b"manifest one");
546        let b = success_marker_body(b"manifest two");
547        assert_ne!(a, b);
548    }
549
550    #[test]
551    fn parse_success_marker_roundtrips_with_writer() {
552        let body = success_marker_body(b"some manifest bytes");
553        let fp = parse_success_marker(&body).expect("must parse");
554        assert!(fp.starts_with("xxh3:"));
555        assert_eq!(fp.len(), "xxh3:".len() + 16);
556    }
557
558    #[test]
559    fn parse_success_marker_rejects_malformed_bodies() {
560        assert_eq!(parse_success_marker(""), None);
561        assert_eq!(parse_success_marker("\n"), None);
562        assert_eq!(parse_success_marker("sha256:0123456789abcdef"), None);
563        // Wrong hex length:
564        assert_eq!(parse_success_marker("xxh3:0123\n"), None);
565        // Uppercase hex (we emit lowercase; reject to keep the format strict):
566        assert_eq!(parse_success_marker("xxh3:0123456789ABCDEF\n"), None);
567        // Non-hex body:
568        assert_eq!(parse_success_marker("xxh3:zzzzzzzzzzzzzzzz\n"), None);
569        // Missing prefix:
570        assert_eq!(parse_success_marker("0123456789abcdef\n"), None);
571    }
572
573    #[test]
574    fn parse_success_marker_tolerates_trailing_whitespace() {
575        let body = "xxh3:0123456789abcdef\n";
576        assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
577        // CRLF on Windows, double newline, trailing spaces — all fine.
578        let body = "xxh3:0123456789abcdef\r\n";
579        assert_eq!(parse_success_marker(body), Some("xxh3:0123456789abcdef"));
580    }
581
582    #[test]
583    fn unknown_fields_are_ignored_by_reader() {
584        // ADR-0012 forward-compatibility contract: a reader compiled against
585        // v1 must tolerate v2-style fields that it doesn't recognise.
586        let json = r#"{
587            "manifest_version": 1,
588            "run_id": "r1",
589            "export_name": "t",
590            "started_at": "2026-01-01T00:00:00Z",
591            "finished_at": "2026-01-01T00:01:00Z",
592            "status": "success",
593            "source": {"engine": "postgres"},
594            "destination": {"kind": "local", "uri": "file:///tmp/out/"},
595            "format": "parquet",
596            "compression": "zstd",
597            "schema_fingerprint": "xxh3:0000000000000000",
598            "row_count": 0,
599            "part_count": 0,
600            "parts": [],
601            "future_field_added_in_v2": {"nested": true}
602        }"#;
603        let parsed: RunManifest = serde_json::from_str(json).unwrap();
604        assert_eq!(parsed.run_id, "r1");
605        assert_eq!(parsed.validate_self_consistency(), Ok(()));
606    }
607}
608
609fn default_manifest_mode() -> String {
610    "batch".to_string()
611}
612
613/// Finding #44 guard: refuse to overwrite a manifest written by the OTHER
614/// pipeline shape. Same-shape overwrites stay allowed (a batch full re-run
615/// replaces its own manifest by design; CDC extends its own). A missing or
616/// unreadable manifest is not this guard's business — corruption surfaces in
617/// `rivet validate`, and first runs must not require a read round-trip.
618pub fn guard_manifest_mode(
619    dest: &dyn crate::destination::Destination,
620    new_mode: &str,
621) -> anyhow::Result<()> {
622    let Ok(bytes) = dest.read("manifest.json") else {
623        return Ok(());
624    };
625    let Ok(existing) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
626        return Ok(());
627    };
628    // A manifest WITHOUT the field predates 0.16.6 — every live CDC
629    // deployment's prefix looks like that after an upgrade, and refusing it
630    // would brick their own resume. Absent mode ⇒ unknown ⇒ allow; only an
631    // EXPLICIT cross-shape mismatch refuses.
632    let Some(existing_mode) = existing.get("mode").and_then(|m| m.as_str()) else {
633        return Ok(());
634    };
635    if existing_mode != new_mode {
636        anyhow::bail!(
637            "destination already holds a '{existing_mode}' manifest (run_id {run}); refusing to \
638             overwrite it with a '{new_mode}' manifest — a batch export and a CDC export sharing \
639             one prefix silently destroy each other's audit trail. Give this export its own \
640             prefix (the scaffold now uses exports/<table>/cdc/ for CDC).",
641            run = existing
642                .get("run_id")
643                .and_then(|r| r.as_str())
644                .unwrap_or("unknown"),
645        );
646    }
647    Ok(())
648}
649
650#[cfg(test)]
651mod mode_guard_tests {
652    use super::*;
653
654    #[test]
655    fn pre_extraction_manifests_parse_with_none() {
656        // Manifests written before the extraction section (the real v0.16
657        // fixture) must parse — the field is opt-in.
658        let old = std::fs::read_to_string(
659            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
660                .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
661        )
662        .expect("compat fixture");
663        let m: RunManifest = serde_json::from_str(&old).expect("parses");
664        assert!(m.source.extraction.is_none());
665    }
666
667    #[test]
668    fn extraction_roundtrips_and_omits_none_fields() {
669        let ex = ExtractionMetadata {
670            strategy: "incremental".into(),
671            cursor_column: Some("id".into()),
672            cursor_type: None,
673            cursor_low: Some("1000".into()),
674            cursor_high: Some("2000".into()),
675            source_row_count: None,
676        };
677        let j = serde_json::to_string(&ex).unwrap();
678        // skip_serializing_if omits the None fields entirely.
679        assert!(!j.contains("cursor_type"), "None fields omitted: {j}");
680        assert!(!j.contains("source_row_count"), "{j}");
681        assert!(j.contains("\"cursor_low\":\"1000\""), "{j}");
682        let back: ExtractionMetadata = serde_json::from_str(&j).unwrap();
683        assert_eq!(back, ex);
684    }
685
686    #[test]
687    fn pre_mode_manifests_default_to_batch() {
688        // The REAL committed v0.16 manifest (compat fixture) predates the
689        // `mode` field — it must parse and read as batch.
690        let old = std::fs::read_to_string(
691            std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
692                .join("tests/fixtures/compat/v0.16/cdc_manifest.json"),
693        )
694        .expect("compat fixture");
695        let m: RunManifest = serde_json::from_str(&old).expect("old manifests parse");
696        assert_eq!(m.mode, "batch");
697    }
698
699    #[test]
700    fn cross_shape_overwrite_is_refused_same_shape_allowed() {
701        let d = tempfile::tempdir().unwrap();
702        let dest = crate::destination::create_destination(&crate::config::DestinationConfig {
703            destination_type: crate::config::DestinationType::Local,
704            path: Some(d.path().to_str().unwrap().to_string()),
705            ..Default::default()
706        })
707        .unwrap();
708        // empty prefix: any mode fine
709        guard_manifest_mode(dest.as_ref(), "batch").unwrap();
710        std::fs::write(
711            d.path().join("manifest.json"),
712            r#"{"mode":"batch","run_id":"r1"}"#,
713        )
714        .unwrap();
715        guard_manifest_mode(dest.as_ref(), "batch").expect("same shape re-run allowed");
716        let err = guard_manifest_mode(dest.as_ref(), "cdc")
717            .unwrap_err()
718            .to_string();
719        assert!(err.contains("refusing to"), "loud refusal: {err}");
720        assert!(
721            err.contains("exports/<table>/cdc/"),
722            "carries the recovery: {err}"
723        );
724        // A pre-0.16.6 manifest (no mode field) must be ALLOWED — every live
725        // CDC deployment's prefix looks like that right after an upgrade, and
726        // refusing would brick their own resume. Unknown ⇒ pass.
727        std::fs::write(d.path().join("manifest.json"), r#"{"run_id":"legacy"}"#).unwrap();
728        guard_manifest_mode(dest.as_ref(), "cdc").expect("legacy prefixes must keep resuming");
729        guard_manifest_mode(dest.as_ref(), "batch").expect("in either direction");
730    }
731}