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