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}