ADR-0004: Destination Write Contracts
Status: Accepted
Date: 2026-04
Context: Rivet writes exported data to four backends — local filesystem, S3, GCS, and stdout. Their failure modes, commit boundaries, and write guarantees differ. The planning and recovery layers must be able to reason about these differences without inspecting backend internals.
Problem
State and manifest writes (ADR-0001 invariants I2–I4) must happen only after the destination write is durably committed. But “committed” means different things for different backends:
- Local writes stage into a dot-prefixed temp file in the target directory and commit with an atomic same-filesystem
rename(OPT-6), so a failure leaves nothing at the final path — retry-safe, no partial-write risk. - S3 and GCS object writes are not committed until the writer handle is closed (
dst.close()); a mid-upload failure leaves nothing at the destination. - stdout streams data immediately with no atomic commit point; a retry produces duplicate or corrupt output.
Without an explicit contract, the pipeline has no safe way to determine: when is it safe to advance the cursor? when is it safe to record a manifest entry? is a failed write safe to retry automatically?
Decision
Introduce two types in src/destination/mod.rs:
WriteCommitProtocol— when a write becomes durably committed and visible to readers.DestinationCapabilities— the full set of operational guarantees for a backend.
Add a capabilities() method to the Destination trait so each backend declares its own contract. The pipeline can inspect capabilities without downcasting.
Per-Backend Capability Table
| Backend | commit_protocol | idempotent_overwrite | retry_safe | partial_write_risk |
|---|---|---|---|---|
LocalDestination | Atomic | true | true | false |
S3Destination | FinalizeOnClose | true | true | false |
GcsDestination | FinalizeOnClose | true | true | false |
AzureDestination | FinalizeOnClose | true | true | false |
StdoutDestination | Streaming | false | false | true |
S3Destination / GcsDestination / AzureDestination are all type aliases for
CloudDestination<B> (src/destination/{s3,gcs,azure}.rs); they share one
capabilities() body in cloud.rs, so the three cloud rows are identical by
construction, not by coincidence.
WriteCommitProtocol semantics
write() returns Ok(WriteOutcome) (not Ok(())): on success the file is
present per the commit protocol below, and the outcome carries the store’s own
content checksum when the upload reported one (GCS/Azure single Put Blob MD5,
S3 single PutObject ETag), which the commit path compares to the locally
computed MD5 for a fail-fast, no-download transit-integrity check. None for
backends/paths that report none (local FS, streamed multipart).
Atomic: a successfulwrite()means the full file is present at the destination. The only Atomic backend (LocalDestination) stages into a temp file and commits via atomic same-filesystem rename, so a failure leaves nothing at the final path (partial_write_risk = false,retry_safe = true); an Atomic backend that could leave a partial artifact would declarepartial_write_risk = true, in which case the caller would need to clean up before retrying.FinalizeOnClose: The object is committed only when the internal writer handle is closed. A mid-upload failure leaves nothing at the destination — the object is never partially visible to readers.retry_safe = truebecause a failed upload can be retried from scratch with no cleanup needed.Streaming: Data is written to an unbuffered output with no atomic commit boundary. Partial output may be observable beforewrite()returns. Retrying after failure produces duplicate or corrupt output. There is no safe commit moment.
Alignment with ADR-0001 Invariants I2–I4
ADR-0001 requires that state writes (manifest, cursor, schema) happen only after the destination write succeeds. This ADR makes the commit boundary explicit:
- I2 (Write Before Manifest):
record_fileis called afterdest.write()returnsOk(()). ForAtomicandFinalizeOnClosebackends, this is the commit boundary. - I3 (Write Before Cursor):
st.update()is called after the file-writing loop. ForAtomicandFinalizeOnClosebackends, all files are committed before the cursor advances. - I4 (Metric After Verdict): Unchanged — metrics are recorded at the terminal state of the run.
The ordering is made explicit in the source at the shared commit seam: pipeline/commit.rs::{write_part_file, record_part} (which every runner, including pipeline/single.rs:run_single_export at its record_part call, goes through) documents that state writes happen only after destination.write() returns Ok.
Runtime Capability Inspection
pipeline/single.rs:run_single_export inspects dest.capabilities() at runtime and logs the commit protocol for every run:
export 'orders': destination commit_protocol=Atomic idempotent=true retry_safe=true partial_risk=false
When a destination that is not retry-safe (retry_safe = false) is configured with automatic retries (max_retries > 0), a WARN is emitted once per export at capability-logging time, before any retry occurs:
export 'orders': stdout destination is not retry-safe (max_retries=2); partial artifacts may exist at destination on failure — manual cleanup may be needed
This surfaces retry-safety mismatches without blocking the run. With current backends this can fire only for stdout (Streaming); local, S3, GCS and Azure all declare retry_safe: true.
Known Gap: stdout state writes
StdoutDestination has commit_protocol: Streaming. There is no safe moment to advance state after a streaming write — any output may have been partially consumed by the reader before write() returns.
Current behavior: The pipeline does not special-case stdout for state writes. If stdout is used as a destination, cursor and manifest writes proceed as normal after write() returns. This is safe only because stdout is used exclusively in development/piping scenarios where state persistence is not meaningful. The plan validation layer rejects stdout + chunked and stdout + max_file_size combinations via Rejected diagnostics before execution starts.
If stdout is ever used in a production pipeline with cursor or manifest state, this gap must be addressed. The fix is for the pipeline to inspect capabilities().commit_protocol and skip or warn on state writes when Streaming.
Consequences
- Each backend’s operational contract is now machine-readable and located with the implementation.
- The planning and recovery layers can inspect
capabilities()without coupling to backend types. - The stdout gap is documented rather than hidden; future callers are warned.
- No breaking changes —
capabilities()is a new trait method with a defined contract.