Skip to main content

choir_queue/
differential_ledger.rs

1//! Durable, advisory calibration receipts for D23 differential runs.
2//!
3//! One process owns a state directory. Every observation and adjudication is
4//! an append-only, versioned JSONL row. The derived receipt is replaceable and
5//! can always be rebuilt from those rows. No timestamp is used as evidence:
6//! observation ids are the local total order, and exact integer counts drive
7//! the `< 1/1000` calculation.
8//!
9//! Reproducibility is frozen along two axes, not one. The command file's raw
10//! bytes are hashed so a ledger cannot silently mix command versions, and the
11//! effective environment the runs execute in — the command file's declared
12//! `env` plus the small pass-through list in [`effective_environment`] — is
13//! hashed into `activation.json` the same way, so a ledger cannot silently
14//! mix environments either. A state directory activated before environment
15//! hashing existed adopts the current environment on its next observation and
16//! enforces it from then on; its earlier rows predate enforcement and cannot
17//! be retroactively attested.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs::{self, File, OpenOptions};
21use std::io::{BufRead, BufReader, Write};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicU64, Ordering};
24
25use choir_oplog::ContentHash;
26
27use crate::differential::{Calibration, DifferentialReport, Verdict};
28
29const FORMAT_VERSION: u64 = 1;
30static REPLACE_TEMP_ID: AtomicU64 = AtomicU64::new(0);
31
32/// Explicit argv loaded from a versioned JSON file.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct CommandSpec {
35    /// Executable name or path. It is never interpreted by a shell.
36    pub program: String,
37    /// Exact argv applied in all three worktrees.
38    pub args: Vec<String>,
39    /// Environment variables declared by the command file. The runs see
40    /// these plus the pass-through list in [`effective_environment`], and
41    /// nothing else.
42    pub env: BTreeMap<String, String>,
43    /// Per-run wall-clock bound in seconds, or `None` to wait forever.
44    /// Additive: an absent field decodes as before, and because the field
45    /// lives in the hashed file bytes, declaring one is a command change.
46    pub timeout_seconds: Option<u64>,
47    /// BLAKE3 content address of the command file bytes.
48    pub snapshot_hash: String,
49}
50
51/// Commit identities recorded with one three-worktree observation.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Revisions {
54    /// First-parent commit as a canonical full Git object id.
55    pub parent_a: String,
56    /// Second-parent commit as a canonical full Git object id.
57    pub parent_b: String,
58    /// Speculative merge commit as a canonical full Git object id.
59    pub merged: String,
60}
61
62fn target_dir_stays_inside_worktree(value: &str) -> bool {
63    let path = Path::new(value);
64    !value.is_empty()
65        && !path.is_absolute()
66        && path.components().all(|component| {
67            matches!(
68                component,
69                std::path::Component::CurDir | std::path::Component::Normal(_)
70            )
71        })
72}
73
74fn cargo_target_dirs_are_isolated(program: &str, args: &[String]) -> bool {
75    if Path::new(program)
76        .file_stem()
77        .and_then(|name| name.to_str())
78        != Some("cargo")
79    {
80        return true;
81    }
82    args.iter().enumerate().all(|(index, arg)| {
83        if arg == "--target-dir" {
84            args.get(index + 1)
85                .is_some_and(|value| target_dir_stays_inside_worktree(value))
86        } else if let Some(value) = arg.strip_prefix("--target-dir=") {
87            target_dir_stays_inside_worktree(value)
88        } else {
89            true
90        }
91    })
92}
93
94/// Result of durably appending one observation.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct RecordedObservation {
97    /// Monotonic id within this state directory.
98    pub observation_id: u64,
99    /// Exact report that was appended.
100    pub report: DifferentialReport,
101    /// Receipt rebuilt after the append.
102    pub calibration: serde_json::Value,
103}
104
105/// Reads and validates a command specification.
106///
107/// The file schema is
108/// `{"format_version":1,"program":"cargo","args":["test"],"env":{"NAME":"value"}}`
109/// with `env` optional and empty by default, plus an optional
110/// `"timeout_seconds"` bounding each run's wall clock (absent = wait
111/// forever). Its raw bytes are hashed so a
112/// ledger cannot silently mix command versions; because `env` lives in those
113/// bytes, a declared-environment change is a command change, and so is a
114/// timeout change.
115///
116/// # Errors
117///
118/// The file is unreadable, malformed, has the wrong version, has an empty
119/// program/non-string argument, declares an environment entry whose name is
120/// empty or contains `=` or NUL, declares a zero or non-integer
121/// `timeout_seconds`, or gives Cargo a target directory outside
122/// the current revision worktree.
123pub fn load_command(path: &Path) -> Result<CommandSpec, String> {
124    let bytes = fs::read(path).map_err(|error| format!("read differential command: {error}"))?;
125    let value: serde_json::Value = serde_json::from_slice(&bytes)
126        .map_err(|error| format!("parse differential command: {error}"))?;
127    if value["format_version"].as_u64() != Some(FORMAT_VERSION) {
128        return Err("differential command needs format_version 1".to_string());
129    }
130    let program = value["program"]
131        .as_str()
132        .filter(|program| !program.is_empty())
133        .ok_or("differential command needs a non-empty program")?
134        .to_string();
135    let args = value["args"]
136        .as_array()
137        .ok_or("differential command args must be an array")?
138        .iter()
139        .map(|arg| {
140            arg.as_str()
141                .map(str::to_string)
142                .ok_or("differential command arguments must be strings".to_string())
143        })
144        .collect::<Result<Vec<_>, _>>()?;
145    let env = match &value["env"] {
146        serde_json::Value::Null => BTreeMap::new(),
147        serde_json::Value::Object(entries) => entries
148            .iter()
149            .map(|(name, value)| {
150                if name.is_empty() || name.contains(['=', '\0']) {
151                    return Err(
152                        "differential command env names must be non-empty and free of = and NUL"
153                            .to_string(),
154                    );
155                }
156                value
157                    .as_str()
158                    .map(|value| (name.clone(), value.to_string()))
159                    .ok_or("differential command env values must be strings".to_string())
160            })
161            .collect::<Result<BTreeMap<_, _>, _>>()?,
162        _ => return Err("differential command env must be an object".to_string()),
163    };
164    let timeout_seconds = match &value["timeout_seconds"] {
165        serde_json::Value::Null => None,
166        value => Some(
167            value
168                .as_u64()
169                .filter(|seconds| *seconds > 0)
170                .ok_or("differential command timeout_seconds must be a positive integer")?,
171        ),
172    };
173    if !cargo_target_dirs_are_isolated(&program, &args) {
174        return Err("cargo target directory must stay inside each revision worktree".to_string());
175    }
176    Ok(CommandSpec {
177        program,
178        args,
179        env,
180        timeout_seconds,
181        snapshot_hash: ContentHash::blake3(&bytes).to_hex(),
182    })
183}
184
185/// The complete environment a differential run executes in.
186///
187/// Starts from the pass-through list — `PATH`, `HOME`, `TMPDIR`, taken from
188/// this process when present, because subprocess commands are unrunnable
189/// without them — and lets the command file's declared `env` override. The
190/// result is exactly what [`crate::differential::run_merged_vs_parents`]
191/// should be given, and exactly what [`environment_hash`] attests.
192#[must_use]
193pub fn effective_environment(declared: &BTreeMap<String, String>) -> BTreeMap<String, String> {
194    let mut env = BTreeMap::new();
195    for name in ["PATH", "HOME", "TMPDIR"] {
196        if let Ok(value) = std::env::var(name) {
197            env.insert(name.to_string(), value);
198        }
199    }
200    env.extend(declared.iter().map(|(k, v)| (k.clone(), v.clone())));
201    env
202}
203
204/// BLAKE3 content address of one effective environment.
205///
206/// The preimage is the canonical JSON encoding of the map; `BTreeMap` order
207/// makes it deterministic, the same discipline every hashed struct in the
208/// workspace relies on.
209#[must_use]
210pub fn environment_hash(env: &BTreeMap<String, String>) -> String {
211    ContentHash::blake3(&serde_json::to_vec(env).expect("string map always encodes")).to_hex()
212}
213
214fn chmod(path: &Path, mode: u32) -> Result<(), String> {
215    #[cfg(unix)]
216    {
217        use std::os::unix::fs::PermissionsExt;
218        fs::set_permissions(path, fs::Permissions::from_mode(mode))
219            .map_err(|error| format!("set calibration permissions: {error}"))?;
220    }
221    #[cfg(not(unix))]
222    let _ = (path, mode);
223    Ok(())
224}
225
226fn secure_append(path: &Path) -> Result<File, String> {
227    let mut options = OpenOptions::new();
228    options.create(true).append(true);
229    #[cfg(unix)]
230    {
231        use std::os::unix::fs::OpenOptionsExt;
232        options.mode(0o600);
233    }
234    let file = options
235        .open(path)
236        .map_err(|error| format!("open calibration stream: {error}"))?;
237    chmod(path, 0o600)?;
238    Ok(file)
239}
240
241fn append_row(path: &Path, row: &serde_json::Value) -> Result<(), String> {
242    let mut file = secure_append(path)?;
243    serde_json::to_writer(&mut file, row)
244        .map_err(|error| format!("encode calibration row: {error}"))?;
245    file.write_all(b"\n")
246        .map_err(|error| format!("append calibration row: {error}"))?;
247    file.sync_all()
248        .map_err(|error| format!("sync calibration row: {error}"))
249}
250
251fn read_rows(path: &Path) -> Result<Vec<serde_json::Value>, String> {
252    let file = File::open(path).map_err(|error| format!("open calibration stream: {error}"))?;
253    BufReader::new(file)
254        .lines()
255        .enumerate()
256        .filter_map(|(index, line)| match line {
257            Ok(line) if line.trim().is_empty() => None,
258            other => Some((index, other)),
259        })
260        .map(|(index, line)| {
261            let line = line.map_err(|error| format!("read calibration row: {error}"))?;
262            serde_json::from_str(&line)
263                .map_err(|error| format!("parse calibration row {}: {error}", index + 1))
264        })
265        .collect()
266}
267
268fn state_paths(state: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) {
269    (
270        state.join("activation.json"),
271        state.join("observations.jsonl"),
272        state.join("adjudications.jsonl"),
273        state.join("receipt.json"),
274    )
275}
276
277fn is_canonical_git_oid(revision: &str) -> bool {
278    matches!(revision.len(), 40 | 64)
279        && revision
280            .bytes()
281            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
282}
283
284fn validate_revisions(revisions: &Revisions) -> Result<(), String> {
285    if [
286        revisions.parent_a.as_str(),
287        revisions.parent_b.as_str(),
288        revisions.merged.as_str(),
289    ]
290    .into_iter()
291    .all(is_canonical_git_oid)
292    {
293        Ok(())
294    } else {
295        Err("observation revisions must use canonical Git object ids".to_string())
296    }
297}
298
299fn write_new(path: &Path, body: &[u8]) -> Result<(), String> {
300    let mut options = OpenOptions::new();
301    options.write(true).create_new(true);
302    #[cfg(unix)]
303    {
304        use std::os::unix::fs::OpenOptionsExt;
305        options.mode(0o600);
306    }
307    let mut file = options
308        .open(path)
309        .map_err(|error| format!("create calibration file: {error}"))?;
310    file.write_all(body)
311        .map_err(|error| format!("write calibration file: {error}"))?;
312    file.sync_all()
313        .map_err(|error| format!("sync calibration file: {error}"))?;
314    chmod(path, 0o600)
315}
316
317/// `environment_hash` is `Some` only on the run path: recording an
318/// observation must pin the environment, while adjudication and refresh
319/// neither run the command nor should be refused because the operator's
320/// shell has since changed.
321fn prepare_state(
322    state: &Path,
323    command_hash: &str,
324    environment_hash: Option<&str>,
325) -> Result<(), String> {
326    fs::create_dir_all(state).map_err(|error| format!("create calibration directory: {error}"))?;
327    chmod(state, 0o700)?;
328    let (activation, observations, adjudications, _) = state_paths(state);
329    if activation.exists() {
330        let mut value: serde_json::Value = serde_json::from_slice(
331            &fs::read(&activation)
332                .map_err(|error| format!("read calibration activation: {error}"))?,
333        )
334        .map_err(|error| format!("parse calibration activation: {error}"))?;
335        if value["format_version"].as_u64() != Some(FORMAT_VERSION)
336            || value["command_snapshot_hash"].as_str() != Some(command_hash)
337        {
338            return Err("calibration state belongs to a different command snapshot".to_string());
339        }
340        if let Some(environment_hash) = environment_hash {
341            match value["environment_hash"].as_str() {
342                Some(recorded) if recorded == environment_hash => {}
343                Some(_) => {
344                    return Err("calibration state belongs to a different environment".to_string());
345                }
346                // Activated before environments were hashed: adopt the
347                // current one and enforce it from here on. The rows already
348                // in the ledger predate enforcement, which the module doc
349                // says out loud rather than pretending to attest them.
350                None => {
351                    value["environment_hash"] =
352                        serde_json::Value::String(environment_hash.to_string());
353                    let body = serde_json::to_vec(&value)
354                        .map_err(|error| format!("encode calibration activation: {error}"))?;
355                    replace_file(state, &activation, &body)?;
356                }
357            }
358        }
359        chmod(&activation, 0o600)?;
360    } else {
361        let mut fields = serde_json::json!({
362            "format_version": FORMAT_VERSION,
363            "command_snapshot_hash": command_hash,
364        });
365        if let Some(environment_hash) = environment_hash {
366            fields["environment_hash"] = serde_json::Value::String(environment_hash.to_string());
367        }
368        let body = serde_json::to_vec(&fields)
369            .map_err(|error| format!("encode calibration activation: {error}"))?;
370        write_new(&activation, &body)?;
371    }
372    drop(secure_append(&observations)?);
373    drop(secure_append(&adjudications)?);
374    File::open(state)
375        .and_then(|directory| directory.sync_all())
376        .map_err(|error| format!("sync calibration directory: {error}"))
377}
378
379#[derive(Debug)]
380struct Folded {
381    next_id: u64,
382    reports: BTreeMap<u64, DifferentialReport>,
383    adjudicated_ids: BTreeSet<u64>,
384    receipt: serde_json::Value,
385}
386
387fn fold(state: &Path, command_hash: &str) -> Result<Folded, String> {
388    let (activation_path, observations_path, adjudications_path, _) = state_paths(state);
389    let mut reports = BTreeMap::new();
390    let mut unique_merge_commits = BTreeSet::new();
391    let rows = read_rows(&observations_path)?;
392    for (index, row) in rows.iter().enumerate() {
393        if row["format_version"].as_u64() != Some(FORMAT_VERSION) {
394            return Err("observation row needs format_version 1".to_string());
395        }
396        let expected = u64::try_from(index + 1).map_err(|_| "too many observations")?;
397        if row["observation_id"].as_u64() != Some(expected) {
398            return Err("observation ids must be contiguous from 1".to_string());
399        }
400        for field in ["parent_a", "parent_b", "merged"] {
401            if row["revisions"][field]
402                .as_str()
403                .filter(|revision| is_canonical_git_oid(revision))
404                .is_none()
405            {
406                return Err("observation row needs three canonical Git object ids".to_string());
407            }
408        }
409        unique_merge_commits.insert(
410            row["revisions"]["merged"]
411                .as_str()
412                .expect("validated above")
413                .to_string(),
414        );
415        reports.insert(expected, DifferentialReport::from_json(&row["report"])?);
416    }
417
418    let mut adjudications = BTreeMap::new();
419    for row in read_rows(&adjudications_path)? {
420        if row["format_version"].as_u64() != Some(FORMAT_VERSION) {
421            return Err("adjudication row needs format_version 1".to_string());
422        }
423        let id = row["observation_id"]
424            .as_u64()
425            .ok_or("adjudication row needs an observation_id")?;
426        let real = row["real_interaction"]
427            .as_bool()
428            .ok_or("adjudication row needs boolean real_interaction")?;
429        if adjudications.insert(id, real).is_some() {
430            return Err("an observation may be adjudicated only once".to_string());
431        }
432    }
433
434    let adjudicated_ids = adjudications.keys().copied().collect();
435    let mut calibration = Calibration::default();
436    for (id, report) in &reports {
437        match (report.verdict, adjudications.remove(id)) {
438            (Verdict::InteractionFailure, Some(real)) => calibration.record(report, Some(real))?,
439            (Verdict::InteractionFailure, None) => calibration.record_pending(report)?,
440            (_, Some(_)) => {
441                return Err("only an interaction failure may be adjudicated".to_string());
442            }
443            (_, None) => calibration.record(report, None)?,
444        }
445    }
446    if !adjudications.is_empty() {
447        return Err("adjudication refers to an unknown observation".to_string());
448    }
449
450    let mut receipt = calibration.receipt();
451    let receipt_object = receipt
452        .as_object_mut()
453        .ok_or("calibration receipt must be an object")?;
454    receipt_object.insert(
455        "command_snapshot_hash".to_string(),
456        serde_json::Value::String(command_hash.to_string()),
457    );
458    // Null means this state directory has never had its environment pinned,
459    // which is itself worth seeing in the receipt.
460    let activation: serde_json::Value = serde_json::from_slice(
461        &fs::read(&activation_path)
462            .map_err(|error| format!("read calibration activation: {error}"))?,
463    )
464    .map_err(|error| format!("parse calibration activation: {error}"))?;
465    receipt_object.insert(
466        "environment_hash".to_string(),
467        activation["environment_hash"].clone(),
468    );
469    receipt_object.insert("observations".to_string(), serde_json::json!(reports.len()));
470    receipt_object.insert(
471        "unique_merge_commits".to_string(),
472        serde_json::json!(unique_merge_commits.len()),
473    );
474    receipt_object.insert(
475        "has_conclusive_observation".to_string(),
476        serde_json::json!(receipt_object["evaluated_merges"].as_u64().unwrap_or(0) != 0),
477    );
478    receipt_object.insert(
479        "all_flags_adjudicated".to_string(),
480        serde_json::json!(receipt_object["pending_interactions"].as_u64() == Some(0)),
481    );
482    Ok(Folded {
483        next_id: u64::try_from(reports.len())
484            .map_err(|_| "too many observations")?
485            .saturating_add(1),
486        reports,
487        adjudicated_ids,
488        receipt,
489    })
490}
491
492fn replace_file(state: &Path, path: &Path, body: &[u8]) -> Result<(), String> {
493    let temp = state.join(format!(
494        ".replace-{}-{}",
495        std::process::id(),
496        REPLACE_TEMP_ID.fetch_add(1, Ordering::Relaxed)
497    ));
498    write_new(&temp, body)?;
499    fs::rename(&temp, path).map_err(|error| format!("replace calibration file: {error}"))?;
500    chmod(path, 0o600)?;
501    File::open(state)
502        .and_then(|directory| directory.sync_all())
503        .map_err(|error| format!("sync calibration directory: {error}"))
504}
505
506fn write_receipt(state: &Path, receipt: &serde_json::Value) -> Result<(), String> {
507    let (_, _, _, receipt_path) = state_paths(state);
508    let mut body = serde_json::to_vec_pretty(receipt)
509        .map_err(|error| format!("encode calibration receipt: {error}"))?;
510    body.push(b'\n');
511    replace_file(state, &receipt_path, &body)
512}
513
514/// Appends a report and rebuilds the advisory receipt.
515///
516/// `environment_hash` is the [`environment_hash`] of the
517/// [`effective_environment`] the report's three runs actually executed in.
518/// The first observation pins it in `activation.json`; later observations
519/// must match it or are refused, exactly as a changed command snapshot is.
520///
521/// # Errors
522///
523/// A revision is not a canonical full Git object id, state cannot be created,
524/// belongs to another command or another environment, contains an invalid
525/// row, or cannot be durably updated.
526pub fn record_observation(
527    state: &Path,
528    command_hash: &str,
529    environment_hash: &str,
530    revisions: &Revisions,
531    report: &DifferentialReport,
532) -> Result<RecordedObservation, String> {
533    validate_revisions(revisions)?;
534    prepare_state(state, command_hash, Some(environment_hash))?;
535    let before = fold(state, command_hash)?;
536    let (_, observations, _, _) = state_paths(state);
537    let row = serde_json::json!({
538        "format_version": FORMAT_VERSION,
539        "observation_id": before.next_id,
540        "revisions": {
541            "parent_a": revisions.parent_a,
542            "parent_b": revisions.parent_b,
543            "merged": revisions.merged,
544        },
545        "report": report.to_json(),
546    });
547    append_row(&observations, &row)?;
548    let after = fold(state, command_hash)?;
549    write_receipt(state, &after.receipt)?;
550    Ok(RecordedObservation {
551        observation_id: before.next_id,
552        report: report.clone(),
553        calibration: after.receipt,
554    })
555}
556
557/// Appends one ground-truth decision for a flagged observation and rebuilds
558/// the receipt. `true` means a real interaction; `false` means spurious.
559///
560/// # Errors
561///
562/// The id is absent, unflagged, already adjudicated, or state cannot be
563/// durably updated.
564pub fn adjudicate(
565    state: &Path,
566    command_hash: &str,
567    observation_id: u64,
568    real_interaction: bool,
569) -> Result<serde_json::Value, String> {
570    prepare_state(state, command_hash, None)?;
571    let before = fold(state, command_hash)?;
572    match before.reports.get(&observation_id) {
573        Some(report) if report.verdict == Verdict::InteractionFailure => {}
574        Some(_) => return Err("only an interaction failure may be adjudicated".to_string()),
575        None => return Err("adjudication refers to an unknown observation".to_string()),
576    }
577    if before.adjudicated_ids.contains(&observation_id) {
578        return Err("an observation may be adjudicated only once".to_string());
579    }
580    let (_, _, adjudications, _) = state_paths(state);
581    let row = serde_json::json!({
582        "format_version": FORMAT_VERSION,
583        "observation_id": observation_id,
584        "real_interaction": real_interaction,
585    });
586    append_row(&adjudications, &row)?;
587    let after = fold(state, command_hash)?;
588    write_receipt(state, &after.receipt)?;
589    Ok(after.receipt)
590}
591
592/// Rebuilds a receipt from the append-only source rows.
593///
594/// # Errors
595///
596/// The command snapshot does not match or any source row is invalid.
597pub fn refresh(state: &Path, command_hash: &str) -> Result<serde_json::Value, String> {
598    prepare_state(state, command_hash, None)?;
599    let folded = fold(state, command_hash)?;
600    write_receipt(state, &folded.receipt)?;
601    Ok(folded.receipt)
602}