Skip to main content

runtime/
replay.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//! Offline replay: re-verifies a Ledger without re-executing anything.
7//! The second and last minting point for EventRef.
8//!
9//! Refusals are fail-closed and name the 1-based line: a higher `v` and
10//! an unknown kind without `ig:true` speak direction
11//! (`E_LOG_VERSION_UNSUPPORTED`, written by a newer sprawling); a broken
12//! chain, a seq gap or non-canonical bytes are storage integrity
13//! (`E_CAS_CORRUPT`). Lines with `ig:true` and an unknown kind skip the
14//! typed parse but still count in the chain โ€” the chain covers raw bytes,
15//! not meanings.
16
17use std::path::Path;
18
19use kernel::{Address, AxCode, AxError, B3Hash, EventRecord, EventRef, Seq};
20use storage::{CheckedLine, LedgerIndex, LineCheck, Located};
21
22/// One verified line: a typed record with its ref echo, or an explicitly
23/// ignorable line from a future vocabulary.
24#[derive(Debug, Clone, PartialEq)]
25pub enum VerifiedLine {
26    Known { record: EventRecord, echo: EventRef },
27    IgnoredUnknown { seq: Seq },
28}
29
30/// A verified sequence: raw bytes plus their verified reading. Forking
31/// consumes this type, never raw lines โ€” a fork of an unverified
32/// sequence is unrepresentable (`crates/runtime/Spec.lean` ยง10).
33#[derive(Debug)]
34pub struct VerifiedLedger {
35    raw: Vec<Vec<u8>>,
36    verified: Vec<VerifiedLine>,
37}
38
39impl VerifiedLedger {
40    pub fn raw_lines(&self) -> &[Vec<u8>] {
41        &self.raw
42    }
43
44    pub fn lines(&self) -> &[VerifiedLine] {
45        &self.verified
46    }
47
48    pub fn tail_seq(&self) -> Option<Seq> {
49        let count = u64::try_from(self.verified.len()).unwrap_or(u64::MAX);
50        count.checked_sub(1).map(Seq::new)
51    }
52}
53
54/// A2: offline chain verification over raw lines, through the one
55/// per-line check `storage::LineCheck` owns.
56pub fn verify_lines(lines: Vec<Vec<u8>>) -> Result<VerifiedLedger, AxError> {
57    let mut check = LineCheck::at_genesis();
58    let verified = lines
59        .iter()
60        .zip(1u64..)
61        .map(|(raw, line_no)| {
62            check
63                .advance(raw)
64                .map(|checked| match checked {
65                    CheckedLine::Known(record) => VerifiedLine::Known {
66                        echo: record.to_ref(),
67                        record,
68                    },
69                    CheckedLine::IgnoredUnknown(seq) => VerifiedLine::IgnoredUnknown { seq },
70                })
71                .map_err(|fault| fault.into_ax(line_no))
72        })
73        .collect::<Result<Vec<_>, _>>()?;
74    Ok(VerifiedLedger {
75        raw: lines,
76        verified,
77    })
78}
79
80/// A2 over a durable ledger directory; strictly read-only.
81///
82/// A directory holding no segment yields an empty `VerifiedLedger`, the
83/// same as a ledger holding no events. That is deliberate here: every
84/// caller of this function computes the path from a city root it already
85/// holds, and a city that has been opened but never written to has a
86/// ledger directory and no segment in it. Telling the two apart is the
87/// job of whoever took the path from a person - `sprawling replay` does
88/// it with `storage::ledger_segments_at` (`crates/sprawling/Spec.lean` section 12).
89pub fn verify_ledger_dir(dir: &Path) -> Result<VerifiedLedger, AxError> {
90    let lines = storage::read_raw_lines_at(dir).map_err(storage::StorageError::into_ax)?;
91    verify_lines(lines)
92}
93
94/// A2 folded instead of kept: each record the per-line check reads is
95/// lent to `each` and dropped, so what stays resident is one segment's
96/// bytes and one record rather than every raw line and every record.
97/// The same pass builds the `LedgerIndex` it returns, which covers
98/// exactly the history `each` saw.
99///
100/// # Errors
101/// The first line that does not verify, named as `verify_ledger_dir`
102/// names it, or the first error `each` returns. The records before that
103/// line were already shown to `each`, so the caller discards everything
104/// it folded.
105pub fn fold_ledger_dir(
106    dir: &Path,
107    mut each: impl FnMut(&EventRecord) -> Result<(), AxError>,
108) -> Result<LedgerIndex, AxError> {
109    let mut check = LineCheck::at_genesis();
110    let mut line_no = 0u64;
111    LedgerIndex::folding(dir, |raw| {
112        line_no = line_no.saturating_add(1);
113        match check.advance(raw).map_err(|fault| fault.into_ax(line_no))? {
114            CheckedLine::Known(record) => {
115                each(&record)?;
116                Ok(Some(Located {
117                    seq: record.seq(),
118                    run: Some(record.run()),
119                }))
120            }
121            CheckedLine::IgnoredUnknown(_) => Ok(None),
122        }
123    })
124}
125
126/// A15: recompute the four segment hashes from a `prompt_assembled`
127/// payload plus the same source documents, and check them against the
128/// recorded ones. The concatenation rule (join separator, truncation
129/// marker) is reused from `prefix` โ€” one authority, no second copy.
130pub fn rebuild_prefix(
131    data: &serde_json::Value,
132    resolver: &dyn Fn(&Address) -> Option<Vec<u8>>,
133) -> Result<[B3Hash; 4], AxError> {
134    let segments = data
135        .get("segments")
136        .and_then(serde_json::Value::as_array)
137        .ok_or_else(|| {
138            AxError::failure(
139                AxCode::InvalidArgs,
140                "rebuild prefix",
141                "payload has no segments",
142            )
143            .with_recovery(
144                "replay a run whose `prompt_assembled` line carries `segments`; a \
145                 hand-written line cannot be rebuilt",
146            )
147        })?;
148    if segments.len() != 4 {
149        return Err(AxError::failure(
150            AxCode::InvalidArgs,
151            "rebuild prefix",
152            format!("expected 4 segments, found {}", segments.len()),
153        )
154        .with_recovery(
155            "replay a run recorded by this build: a prefix is four segments, city, \
156             building, resident and run",
157        ));
158    }
159    let mut hashes = Vec::with_capacity(4);
160    for segment in segments {
161        let slot = segment
162            .get("slot")
163            .and_then(serde_json::Value::as_str)
164            .unwrap_or("?");
165        let sources = segment
166            .get("sources")
167            .and_then(serde_json::Value::as_array)
168            .ok_or_else(|| {
169                AxError::failure(
170                    AxCode::InvalidArgs,
171                    "rebuild prefix",
172                    format!("{slot}: payload lacks source notes (hand-assembled prefix?)"),
173                )
174                .with_recovery(format!(
175                    "replay a run whose {slot} segment lists the documents it was \
176                     built from; a prefix assembled by hand names none"
177                ))
178            })?;
179        let mut text = String::new();
180        for source in sources {
181            let addr_raw = source
182                .get("addr")
183                .and_then(serde_json::Value::as_str)
184                .ok_or_else(|| {
185                    AxError::failure(AxCode::InvalidArgs, "rebuild prefix", "source without addr")
186                        .with_recovery(
187                            "replay a run whose every source note carries `addr`; \
188                             without it the document cannot be found again",
189                        )
190                })?;
191            let addr = Address::parse(addr_raw)?;
192            let bytes = resolver(&addr).ok_or_else(|| {
193                AxError::failure(AxCode::PathNotFound, "rebuild prefix", addr_raw)
194                    .with_recovery("supply the source document at its recorded address")
195            })?;
196            let body = std::str::from_utf8(&bytes).map_err(|_| {
197                AxError::failure(
198                    AxCode::InvalidArgs,
199                    "rebuild prefix",
200                    format!("{addr_raw}: not utf-8"),
201                )
202                .with_recovery(format!(
203                    "restore {addr_raw} to the UTF-8 text it held when the run \
204                     recorded it; the bytes on disk today are something else"
205                ))
206            })?;
207            let kept = usize::try_from(
208                source
209                    .get("kept")
210                    .and_then(serde_json::Value::as_u64)
211                    .unwrap_or(0),
212            )
213            .map_err(|_| {
214                AxError::failure(AxCode::InvalidArgs, "rebuild prefix", "kept exceeds usize")
215                    .with_recovery(
216                        "replay this run on a 64-bit machine: the recorded kept span \
217                         is longer than this one can address",
218                    )
219            })?;
220            let marker = source
221                .get("marker")
222                .and_then(serde_json::Value::as_bool)
223                .unwrap_or(false);
224            let dropped = source
225                .get("dropped")
226                .and_then(serde_json::Value::as_u64)
227                .unwrap_or(0);
228            let piece = body.get(..kept).ok_or_else(|| {
229                AxError::failure(
230                    AxCode::InvalidArgs,
231                    "rebuild prefix",
232                    format!("{addr_raw}: kept span exceeds the document"),
233                )
234                .with_recovery(format!(
235                    "restore {addr_raw} to the text it held when the run recorded it; \
236                     the file on disk today is shorter than the span the run kept"
237                ))
238            })?;
239            if !text.is_empty() {
240                text.push_str(crate::prefix::DOC_JOIN);
241            }
242            text.push_str(piece);
243            if marker {
244                text.push_str(&crate::elision::marker(kernel::ByteLen::new(dropped)));
245            }
246        }
247        let rebuilt = B3Hash::digest(text.as_bytes());
248        if let Some(recorded) = segment.get("hash").and_then(serde_json::Value::as_str)
249            && recorded != rebuilt.to_string()
250        {
251            return Err(AxError::failure(
252                AxCode::CasCorrupt,
253                "rebuild prefix",
254                format!("{slot}: rebuilt hash differs from the recorded one"),
255            )
256            .with_recovery("the source documents no longer match the recorded assembly"));
257        }
258        hashes.push(rebuilt);
259    }
260    let four: [B3Hash; 4] = match hashes.try_into() {
261        Ok(array) => array,
262        Err(_) => {
263            return Err(AxError::failure(
264                AxCode::InvalidArgs,
265                "rebuild prefix",
266                "segment count drifted during rebuild",
267            )
268            .with_recovery(
269                "report this against runtime::replay: four segments went into the \
270                 rebuild and a different number came out",
271            ));
272        }
273    };
274    Ok(four)
275}
276
277/// Crash recovery - detecting a call with no outcome, and closing it -
278/// is the other half of replay and has its own file.
279mod resume;
280
281pub use resume::{DanglingCalls, outcome_unknown_draft};
282
283/// The cache shape a replay rebuilds from two records and then judges
284/// for itself.
285mod shape;
286
287pub use shape::rebuild_shape;
288
289#[cfg(test)]
290#[allow(
291    clippy::unwrap_used,
292    clippy::expect_used,
293    clippy::panic,
294    clippy::indexing_slicing,
295    reason = "test code"
296)]
297mod tests;