Skip to main content

storage/checkpoint/
provenance.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Which session a commit came out of, as five git trailers.
7//!
8//! A person reading `git log` in a city this harness works in has to be
9//! able to answer "who wrote this line, on whose behalf, with which
10//! model" without opening the Ledger. That answer is a **projection**:
11//! the Ledger stays the authority for what happened, and the two sides
12//! reconcile on the commit oid, which is written into
13//! `checkpoint_committed` and into every `file_discarded` restoration.
14//! Where a trailer and the Ledger disagree, the trailer is the side that
15//! is wrong.
16//!
17//! The block is rendered as git's own trailer syntax rather than as a
18//! prefix language of this repository's invention, so
19//! `git interpret-trailers --parse` reads it with no help from us.
20//!
21//! Specified by `crates/storage/spec/Checkpoint/Provenance.lean` §8-17.
22
23use std::path::Path;
24
25use kernel::event::record::CommitAttribution;
26use kernel::{Address, B3Hash, Effort, RunId};
27
28use crate::error::StorageError;
29use crate::jsonl::{first_line, ledger_segments_at};
30use crate::real_fs::RealFs;
31
32/// The model a run was given, and the thinking budget it was asked for.
33///
34/// The two always travel together — a model id without the effort asked
35/// of it does not say what the call was — so they arrive as one value
36/// rather than as two parameters a caller can hand over half of.
37#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38pub struct ModelChoice {
39    /// The endpoint's own model id. Empty when the caller genuinely does
40    /// not know it: an invented id would be worse than an absent one.
41    pub id: String,
42    /// Absent leaves the choice to the provider, which is not the same
43    /// fact as `Effort::None` asking it not to think.
44    pub effort: Option<Effort>,
45}
46
47/// Who made a commit, and on whose behalf.
48///
49/// Private fields with one construction point, so a commit signed by a
50/// run that does not exist cannot be assembled a field at a time.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Provenance {
53    run: RunId,
54    actor: Address,
55    model: String,
56    effort: Option<Effort>,
57    city: B3Hash,
58    predecessor: Option<RunId>,
59}
60
61impl Provenance {
62    /// The one construction point.
63    pub fn new(run: RunId, actor: Address, city: B3Hash, chosen: ModelChoice) -> Provenance {
64        Provenance {
65            run,
66            actor,
67            model: chosen.id,
68            effort: chosen.effort,
69            city,
70            predecessor: None,
71        }
72    }
73
74    /// Names the run this one replaced, so a commit can be asked for
75    /// its lineage. A sixth trailer, present only when there is one.
76    #[must_use]
77    pub fn succeeding(mut self, predecessor: RunId) -> Provenance {
78        self.predecessor = Some(predecessor);
79        self
80    }
81
82    /// The run this one replaced, when it is a successor.
83    pub fn predecessor(&self) -> Option<RunId> {
84        self.predecessor
85    }
86
87    /// The city's identity: the chain hash of its genesis line.
88    ///
89    /// Only the first line of the first segment is read. A city's whole
90    /// history can be tens of megabytes, and reading it to learn one
91    /// hash would make every checkpoint pay for the length of the city's
92    /// life.
93    ///
94    /// # Errors
95    /// Propagates a ledger directory that cannot be listed or read, and
96    /// refuses a directory holding no ledger at all — a city with no
97    /// genesis line has no identity to sign with.
98    pub fn city_of(ledger_dir: &Path) -> Result<B3Hash, StorageError> {
99        let vfs = RealFs::new();
100        for segment in ledger_segments_at(ledger_dir)? {
101            let first = first_line(&vfs, &segment).map_err(|source| StorageError::Io {
102                op: "read the genesis line",
103                path: segment.clone(),
104                source,
105            })?;
106            match first {
107                Some(line) if !line.is_empty() => return Ok(kernel::ledger::chain_hash(&line)),
108                _ => continue,
109            }
110        }
111        Err(StorageError::Checkpoint {
112            op: "read the city's genesis line",
113            detail: format!("{} holds no ledger line", ledger_dir.display()),
114        })
115    }
116
117    /// Who this commit is signed as.
118    pub fn actor(&self) -> &Address {
119        &self.actor
120    }
121
122    /// The run this commit belongs to, which names the reference a wave
123    /// checkpoint is filed under.
124    pub fn run(&self) -> RunId {
125        self.run
126    }
127
128    /// The git author line: the resident's address, at a mailbox derived
129    /// from the city it lives in. The domain is unroutable on purpose —
130    /// it identifies a city rather than promising to deliver mail.
131    pub(crate) fn email(&self) -> String {
132        let city = self.city.to_string();
133        let short = city.get(..CITY_PREFIX_HEX).unwrap_or(city.as_str());
134        format!("{}@{short}.sprawling", self.actor.as_str())
135    }
136
137    /// Which session made this commit, in the shape every record that
138    /// names a commit carries it. Both `checkpoint_committed` and
139    /// `pr_merged` are stamped from here, so the two cannot describe
140    /// one commit differently.
141    pub fn attribution(&self) -> CommitAttribution {
142        CommitAttribution {
143            model: self.model.clone(),
144            effort: Some(recorded_effort(self.effort)),
145            predecessor: self.predecessor,
146        }
147    }
148
149    /// The trailers block, in this order and this spelling, one per line
150    /// and newline-terminated. The sixth line appears only for a run
151    /// that replaced another.
152    pub fn trailers(&self) -> String {
153        let mut out = format!(
154            "Sprawling-Run: {}\nSprawling-Actor: {}\nSprawling-Model: {}\nSprawling-Effort: {}\nSprawling-City: {}\n",
155            self.run,
156            self.actor.as_str(),
157            self.model,
158            effort_word(recorded_effort(self.effort)),
159            self.city,
160        );
161        if let Some(predecessor) = self.predecessor {
162            out.push_str(&format!("Sprawling-Predecessor: {predecessor}\n"));
163        }
164        out
165    }
166}
167
168/// How much of the city hash the author domain carries. Twelve hex
169/// digits distinguish every city a person will ever have on one machine
170/// while still fitting in a terminal beside the address.
171const CITY_PREFIX_HEX: usize = 12;
172
173/// How an effort nobody asked for is recorded: as [`Effort::None`].
174///
175/// The distinction `Option` draws — the provider chose, against we asked
176/// it not to think — has never reached the git trailer or the ledger,
177/// both of which have written `none` for either since the first checkpoint.
178/// Every output that has to pick a word goes through here, so one place
179/// decides it and a record written today still reads back the way a
180/// year-old build wrote it.
181#[must_use]
182pub fn recorded_effort(effort: Option<Effort>) -> Effort {
183    effort.unwrap_or(Effort::None)
184}
185
186/// How an effort is spelled, taken from `kernel::Effort`'s own serde
187/// names so this repository has one authority for the word rather than a
188/// second table that drifts. A `match` here would be that second table:
189/// it would compile while spelling one level differently from the wire.
190///
191/// Public because three readers show a person this word — the commit's
192/// trailers, the ledger payload beside them, and `sprawling whose` —
193/// and three spellings of one effort is how a reader starts doubting
194/// all three.
195#[must_use]
196pub fn effort_word(effort: Effort) -> String {
197    serde_json::to_value(effort)
198        .ok()
199        .and_then(|value| value.as_str().map(str::to_owned))
200        .unwrap_or_else(|| "none".to_owned())
201}
202
203#[cfg(test)]
204#[allow(
205    clippy::unwrap_used,
206    clippy::expect_used,
207    clippy::panic,
208    clippy::indexing_slicing,
209    reason = "test code"
210)]
211mod tests {
212    use super::*;
213
214    pub(crate) fn sample() -> Provenance {
215        Provenance::new(
216            RunId::CITY,
217            Address::parse("lab/parser").unwrap(),
218            B3Hash::digest(b"a city"),
219            ModelChoice {
220                id: "claude-sonnet-4-6".to_owned(),
221                effort: Some(Effort::High),
222            },
223        )
224    }
225
226    #[test]
227    fn the_five_trailers_come_in_the_order_a_reader_was_promised() {
228        let rendered = sample().trailers();
229        let lines: Vec<String> = rendered.lines().map(str::to_owned).collect();
230        let city = B3Hash::digest(b"a city");
231        assert_eq!(
232            lines,
233            vec![
234                format!("Sprawling-Run: {}", RunId::CITY),
235                "Sprawling-Actor: lab/parser".to_owned(),
236                "Sprawling-Model: claude-sonnet-4-6".to_owned(),
237                "Sprawling-Effort: high".to_owned(),
238                format!("Sprawling-City: {city}"),
239            ]
240        );
241    }
242
243    #[test]
244    fn a_successor_signs_a_sixth_trailer_and_a_first_run_signs_five() {
245        assert_eq!(sample().trailers().lines().count(), 5);
246        let predecessor = RunId::from_bytes([3u8; 16]);
247        let successor = sample().succeeding(predecessor);
248        let rendered = successor.trailers();
249        assert_eq!(rendered.lines().count(), 6);
250        assert!(
251            rendered.ends_with(&format!("Sprawling-Predecessor: {predecessor}\n")),
252            "{rendered}"
253        );
254        assert_eq!(successor.predecessor(), Some(predecessor));
255        assert_eq!(sample().predecessor(), None);
256    }
257
258    #[test]
259    fn an_unknown_model_and_an_unasked_effort_are_written_as_what_they_are() {
260        let bare = Provenance::new(
261            RunId::CITY,
262            Address::parse("lab/parser").unwrap(),
263            B3Hash::digest(b"a city"),
264            ModelChoice {
265                id: String::new(),
266                effort: None,
267            },
268        );
269        let rendered = bare.trailers();
270        assert!(rendered.contains("Sprawling-Model: \n"), "{rendered}");
271        assert!(rendered.contains("Sprawling-Effort: none\n"), "{rendered}");
272    }
273
274    #[test]
275    fn an_effort_nobody_asked_for_is_recorded_as_the_level_none() {
276        // What the ledger record and the git trailer have always said,
277        // now settled once instead of at each output.
278        assert_eq!(recorded_effort(None), Effort::None);
279        assert_eq!(recorded_effort(Some(Effort::High)), Effort::High);
280        assert_eq!(effort_word(recorded_effort(None)), "none");
281        assert_eq!(effort_word(Effort::XHigh), "xhigh");
282    }
283
284    #[test]
285    fn the_author_mailbox_names_the_city_rather_than_a_host() {
286        let city = B3Hash::digest(b"a city").to_string();
287        assert_eq!(
288            sample().email(),
289            format!("lab/parser@{}.sprawling", city.get(..12).unwrap())
290        );
291    }
292
293    #[test]
294    fn a_directory_with_no_ledger_line_has_no_identity_to_sign_with() {
295        let tmp = tempfile::tempdir().unwrap();
296        let err = Provenance::city_of(tmp.path()).unwrap_err();
297        assert!(err.to_string().contains("holds no ledger line"));
298    }
299}