Build: The Ledger + the Conjunction Keystone Entity
Maps to: Task 2.3 (the run ledger) + the
Conjunctiondomain type (defined in Part I's domain build, first emitted for real this arc). Kind: Build — spec and test names only. No implementation.
Objective
Build the run ledger — the append-only record of every pipeline run that Part V's orchestrator reads for incrementality, retry, and backfill — and pin down the Conjunction keystone entity that the Space-Track transform you just built emits. The ledger is new code (ledger.rs); Conjunction already exists in domain.rs from Part I, but this is the arc where it stops being a definition and starts being data, so we revisit it as the entity it was designed to be.
By the end, cargo test -p etl-core covers the ledger's three behaviors: append-and-read-back, failures are ignored by last_success, and the watermark advances across successful runs.
Part A — the Conjunction keystone (recap, domain.rs)
Conjunction is the reason the whole project exists: a predicted close approach between two tracked objects. It is the "keystone" because it is the entity that references other entities — a conjunction is meaningless without the two NoradIds it points at. Its shipped shape (from Part I):
Conjunction {
id: String, // composite key now; content-addressed in Part IV
tca: DateTime<Utc>, // time of closest approach
miss_distance_km: f64, // closest separation, in kilometers
collision_probability: f64, // 0.0 when the source omits it
primary: NoradId, // the two objects, by catalog number...
secondary: NoradId, // ...and this is where id-safety earns its keep
}
Why the keystone makes the newtype pay off. primary and secondary are both NoradId, not bare u32. If they were plain integers, swapping the two arguments when constructing a Conjunction would compile silently and mislabel a collision-avoidance record — the exact bug the newtype chapter warned about. Because both are NoradId, the values cannot be confused with any other kind of id (a launch id, a payload count); the ordering of the two NoradIds within a conjunction is then a discipline the transform gets right by construction. The keystone entity is where Part I's id-safety seam earns its keep.
You do not write new code in this part — Conjunction was built and tested in domain.rs already. The point is to recognize what the Space-Track transform produced: the first real Conjunction values, keyed by the composite id the transform formats.
Part B — the run ledger (ledger.rs)
The ledger concept page is the reference; this is the interface to build.
Create: crates/etl-core/src/ledger.rs; declare the module and re-export Ledger, RunRecord, and Outcome from lib.rs.
Dependencies: none new. The append-only file uses tokio::fs::OpenOptions and tokio::io::AsyncWriteExt (both under tokio's full features), and serde/serde_json for the JSONL encoding — all already declared in etl-core.
Outcome — how a run ended. Derives Debug, Clone, PartialEq, Serialize, Deserialize; tagged #[serde(tag = "outcome", rename_all = "snake_case")] so each variant is self-describing on the wire (the same tagging idea as Row's kind):
Outcome::Success
Outcome::Failed { reason: String }
PartialEq is not optional here — last_success compares rec.outcome == Outcome::Success, and without the derive that comparison will not compile (error[E0369]).
RunRecord — one line of the ledger. Derives Debug, Clone, PartialEq, Serialize, Deserialize:
RunRecord {
source: String, // which source ran
started_at: DateTime<Utc>,
finished_at: DateTime<Utc>,
outcome: Outcome,
watermark: Option<DateTime<Utc>>, // high-water mark reached; None before first success
rows_written: usize,
}
Ledger — one field, path: PathBuf; constructor new(path: impl Into<PathBuf>). Two async methods:
append(&self, rec: &RunRecord) -> Result<(), EtlError>— openpathwith create + append (OpenOptions::new().create(true).append(true)), serialize the record withserde_json::to_string(failure →EtlError::Parse), and write the line followed by a'\n'. One record per line — JSONL. The?on the file ops relies onEtlError's#[from] std::io::Errorfrom Part I.last_success(&self, source: &str) -> Result<Option<RunRecord>, EtlError>— read the file to a string; if it does not exist yet, returnOk(None)(match onErrorKind::NotFound), not an error. Parse each non-empty line into aRunRecord; keep the last one whosesourcematches and whoseoutcome == Outcome::Success. Return that, orNone.
Test names to hit
append_then_last_success_reads_back— append one successfulRunRecordfor"celestrak"; assertlast_success("celestrak")returnsSome(rec)equal to what you wrote, andlast_success("spacetrack")returnsNone. Proves the round-trip and the source filter. (Use a temp path —std::env::temp_dir()plus the process id — andremove_filefirst for a clean slate, exactly as the JSONL sink tests do.)last_success_ignores_failures— append aSuccessat watermark2026-07-18…, then aFailed { reason }at watermark2026-07-19…. Assertlast_successreturns the run whose watermark is still2026-07-18…. This is the load-bearing test: the failed run was recorded but must not advance the watermark.watermark_advances_across_successful_runs— append twoSuccessruns at2026-07-18…then2026-07-19…; assertlast_successreturns the2026-07-19…watermark. Successive successes move the mark forward.
last_success_ignores_failures, say which watermark you expect back — the failed run's (07-19) or the last success's (07-18). If your last_success filtered on source but forgot the outcome == Success clause, which one would it return? That one missing clause is the difference between a correct incremental pull and one that skips data a failed run never fetched.
.create(true).append(true). If you reach for .write(true) without .append(true), or .truncate(true), each append overwrites from the start of the file and the ledger silently forgets every prior run — no error, no panic, just an amnesiac pipeline. This is the same append-only discipline the JSONL sink follows for rows, applied to run history.
The build loop (you drive)
OutcomeandRunRecordfirst. Define the types with their derives and serde tags. If a comparison later fails to compile, you droppedPartialEqonOutcome.append+append_then_last_success_reads_back. Write the round-trip test, implementappend(create+append, one line) and a firstlast_success. Predict the returned record and theNonefor the absent source before running.last_success_ignores_failures. Predict the watermark (see the callout), then confirm your filter includes theoutcome == Successclause.watermark_advances_across_successful_runs. Two successes; assert the later watermark wins becauselast_successtakes the last match.- Run green, commit.
Done when
cargo test -p etl-core is green across the three ledger tests (alongside the domain, pipeline, and sink tests from earlier arcs). The pipeline can now remember its own runs — the memory that Part IV's retry loop and Part V's incremental executor will both read from. Every seam the orchestrator needs is now in place: the E/T/L traits, the file contract, the error taxonomy, and — as of this chapter — the run ledger.