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