Skip to main content

rite_runtime/
transcript_sink.rs

1//! Transcript sink, the durable consumer of [`StepFact`]s.
2//!
3//! The sink is an inline observer in the executor thread. It records each
4//! [`StepFact`] synchronously before the executor proceeds to its next action,
5//! and the on-disk implementation `fsync`s the line to the storage device
6//! before returning. This invariant, *the transcript is durable before the
7//! UI sees the fact* , is what makes the on-disk record the authoritative
8//! source of evidence and the live UI a tee of the same stream.
9//!
10//! # On-disk format (`transcript.jsonl`)
11//!
12//! Each line is a JSON object with two fields:
13//!
14//! ```jsonc
15//! {"prev_hash": "sha256:…", "fact": { "type": "step_started", … }}
16//! ```
17//!
18//! The chain is verified by recomputing each line's SHA-256, accumulating it
19//! as the expected `prev_hash` of the next line, starting from
20//! [`GENESIS_HASH`]. The final transcript fingerprint is the hash of the
21//! last line; the JSONL is self-identifying and no sidecar file is written.
22//!
23//! # Implementations
24//!
25//! - [`JsonlFileSink`], writes to disk, flushes after every line.
26//! - [`InMemorySink`], collects facts in a `Vec` for tests and tooling.
27
28use std::fs::{File, OpenOptions};
29use std::io::{self, BufRead, BufReader, BufWriter, Write};
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33use thiserror::Error;
34
35use crate::transcript::compute_fingerprint;
36use rite_model::StepFact;
37
38/// SHA-256 fingerprint produced by a [`TranscriptSink::finalize`] call.
39///
40/// Encoded as `sha256:<lowercase-hex>`, matching the convention used
41/// throughout the runtime for artifact and transcript fingerprints.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TranscriptFingerprint(String);
44
45impl TranscriptFingerprint {
46    /// Build a fingerprint from an already-formatted `sha256:<hex>` string.
47    #[must_use]
48    pub fn from_string(s: String) -> Self {
49        Self(s)
50    }
51
52    /// `sha256:<hex>` representation.
53    #[must_use]
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57}
58
59impl std::fmt::Display for TranscriptFingerprint {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.write_str(&self.0)
62    }
63}
64
65/// Genesis `prev_hash` value used on the very first line of every transcript.
66///
67/// `sha256:` followed by 64 zero hex digits.
68pub const GENESIS_HASH: &str =
69    "sha256:0000000000000000000000000000000000000000000000000000000000000000";
70
71/// Synchronous, durable observer of [`StepFact`]s.
72///
73/// Implementations must record each fact, including syncing it to the
74/// underlying storage where applicable, before returning. The executor
75/// relies on this to maintain the invariant that the UI never sees a fact
76/// that has not been durably persisted.
77pub trait TranscriptSink: Send {
78    /// Record a single fact. Must persist before returning, the file-backed
79    /// implementation calls `sync_data` so a power loss after `record`
80    /// returns cannot drop the fact.
81    ///
82    /// # Errors
83    ///
84    /// Returns the underlying I/O error if the sink cannot persist the fact.
85    fn record(&mut self, fact: &StepFact) -> io::Result<()>;
86
87    /// Finalize the transcript and return its fingerprint.
88    ///
89    /// Calling `finalize` more than once is implementation-defined; the
90    /// default expectation is that the second call returns the cached
91    /// fingerprint.
92    ///
93    /// # Errors
94    ///
95    /// Returns the underlying I/O error if any pending state cannot be
96    /// persisted.
97    fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
98}
99
100/// JSONL file sink with SHA-256 chain integrity.
101///
102/// Writes `transcript.jsonl` to a target directory. Every `record` call
103/// serializes the fact, prepends the current chain head as `prev_hash`,
104/// writes one line, flushes, and advances the chain head. The transcript
105/// is self-identifying: `SHA-256` of the last line *is* the cryptographic
106/// fingerprint; no sidecar file is written.
107#[derive(Debug)]
108pub struct JsonlFileSink {
109    jsonl_path: PathBuf,
110    writer: BufWriter<File>,
111    current_hash: String,
112    finalized: Option<TranscriptFingerprint>,
113}
114
115impl JsonlFileSink {
116    /// Create a new sink in `dir`. Writes `transcript.jsonl` next to it.
117    ///
118    /// # Errors
119    ///
120    /// Returns an I/O error if the JSONL file cannot be created.
121    pub fn create(dir: &Path) -> io::Result<Self> {
122        let jsonl_path = dir.join("transcript.jsonl");
123        let file = OpenOptions::new()
124            .create_new(true)
125            .write(true)
126            .open(&jsonl_path)?;
127        Ok(Self {
128            jsonl_path,
129            writer: BufWriter::new(file),
130            current_hash: GENESIS_HASH.to_string(),
131            finalized: None,
132        })
133    }
134
135    /// Path to the JSONL file this sink is writing to.
136    #[must_use]
137    pub fn jsonl_path(&self) -> &Path {
138        &self.jsonl_path
139    }
140}
141
142impl TranscriptSink for JsonlFileSink {
143    fn record(&mut self, fact: &StepFact) -> io::Result<()> {
144        if self.finalized.is_some() {
145            return Err(io::Error::other("transcript already finalized"));
146        }
147        let line = ChainedFact {
148            prev_hash: &self.current_hash,
149            fact,
150        };
151        let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
152        self.current_hash = compute_fingerprint(&bytes);
153        self.writer.write_all(&bytes)?;
154        self.writer.write_all(b"\n")?;
155        // `flush` drains the BufWriter into the File; `sync_data` then
156        // forces the kernel to push the page-cache pages to the storage
157        // device before we return. Without the sync, a power loss between
158        // `record` and the OS's next writeback would drop already-reported
159        // facts even though the executor moved on. Cost at ceremony pace
160        // (one record every few seconds of human pace) is negligible.
161        self.writer.flush()?;
162        self.writer.get_ref().sync_data()?;
163        Ok(())
164    }
165
166    fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
167        if let Some(existing) = &self.finalized {
168            return Ok(existing.clone());
169        }
170        let fingerprint = TranscriptFingerprint(self.current_hash.clone());
171        self.finalized = Some(fingerprint.clone());
172        Ok(fingerprint)
173    }
174}
175
176/// In-memory sink that collects every recorded [`StepFact`].
177///
178/// Useful for tests and for tooling that wants to inspect the fact stream
179/// without going through disk.
180#[derive(Debug, Default)]
181pub struct InMemorySink {
182    facts: Vec<StepFact>,
183    current_hash: String,
184    finalized: Option<TranscriptFingerprint>,
185}
186
187impl InMemorySink {
188    /// Create an empty sink.
189    #[must_use]
190    pub fn new() -> Self {
191        Self {
192            facts: Vec::new(),
193            current_hash: GENESIS_HASH.to_string(),
194            finalized: None,
195        }
196    }
197
198    /// Recorded facts in the order they arrived.
199    #[must_use]
200    pub fn facts(&self) -> &[StepFact] {
201        &self.facts
202    }
203
204    /// Number of facts recorded so far.
205    #[must_use]
206    pub fn len(&self) -> usize {
207        self.facts.len()
208    }
209
210    /// Whether any fact has been recorded.
211    #[must_use]
212    pub fn is_empty(&self) -> bool {
213        self.facts.is_empty()
214    }
215}
216
217impl TranscriptSink for InMemorySink {
218    fn record(&mut self, fact: &StepFact) -> io::Result<()> {
219        if self.finalized.is_some() {
220            return Err(io::Error::other("transcript already finalized"));
221        }
222        let line = ChainedFact {
223            prev_hash: &self.current_hash,
224            fact,
225        };
226        let bytes = serde_json::to_vec(&line).map_err(io::Error::other)?;
227        self.current_hash = compute_fingerprint(&bytes);
228        self.facts.push(fact.clone());
229        Ok(())
230    }
231
232    fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
233        if let Some(existing) = &self.finalized {
234            return Ok(existing.clone());
235        }
236        let fingerprint = TranscriptFingerprint(self.current_hash.clone());
237        self.finalized = Some(fingerprint.clone());
238        Ok(fingerprint)
239    }
240}
241
242#[derive(Serialize)]
243struct ChainedFact<'a> {
244    prev_hash: &'a str,
245    fact: &'a StepFact,
246}
247
248#[derive(Deserialize)]
249struct OwnedChainedFact {
250    prev_hash: String,
251    fact: StepFact,
252}
253
254/// Outcome of verifying a transcript on disk.
255#[derive(Debug, Clone)]
256pub struct TranscriptVerified {
257    /// Number of facts read and verified.
258    pub fact_count: usize,
259    /// Final transcript fingerprint (hash of the last line).
260    pub fingerprint: TranscriptFingerprint,
261    /// `true` if the last fact is a terminal one (`CeremonyCompleted` or
262    /// `CeremonyFailed`). `false` means the transcript was cut off
263    /// before the executor reached its finalize step, a truncated run.
264    pub terminated: bool,
265}
266
267/// Verification failure modes.
268#[derive(Debug, Error)]
269pub enum VerifyError {
270    /// Underlying I/O error reading the transcript.
271    #[error("io error: {0}")]
272    Io(#[from] io::Error),
273    /// A line did not parse as a [`ChainedFact`].
274    #[error("line {line} is not a valid chained fact: {reason}")]
275    InvalidLine {
276        /// 1-indexed line number.
277        line: usize,
278        /// Parse error.
279        reason: String,
280    },
281    /// The chain is broken: a line's `prev_hash` did not match the
282    /// expected value computed from the previous line.
283    #[error("broken chain at line {line}: expected prev_hash {expected}, got {actual}")]
284    BrokenChain {
285        /// 1-indexed line number where the break was detected.
286        line: usize,
287        /// The expected `prev_hash` (= hash of the previous line).
288        expected: String,
289        /// The `prev_hash` actually recorded on the line.
290        actual: String,
291    },
292    /// The transcript file has zero lines.
293    #[error("transcript is empty")]
294    Empty,
295}
296
297/// Verify a JSONL transcript file produced by [`JsonlFileSink`].
298///
299/// Reads the file line-by-line, recomputes each line's SHA-256, and
300/// checks that `prev_hash` matches the previous line's hash.
301///
302/// # Errors
303///
304/// Returns [`VerifyError`] for any I/O failure, malformed line, or
305/// chain break.
306pub fn verify_transcript(jsonl_path: &Path) -> Result<TranscriptVerified, VerifyError> {
307    let loaded = read_verified_transcript(jsonl_path)?;
308    Ok(TranscriptVerified {
309        fact_count: loaded.facts.len(),
310        fingerprint: loaded.fingerprint,
311        terminated: loaded.terminated,
312    })
313}
314
315/// Verified transcript contents, chain-walked facts plus the final
316/// fingerprint and a flag for whether the run reached a terminal fact.
317#[derive(Debug, Clone)]
318pub struct LoadedTranscript {
319    /// Facts in the order they were recorded.
320    pub facts: Vec<StepFact>,
321    /// Final transcript fingerprint (hash of the last line).
322    pub fingerprint: TranscriptFingerprint,
323    /// `true` if the last fact is `CeremonyCompleted` or `CeremonyFailed`.
324    pub terminated: bool,
325}
326
327/// Verify a JSONL transcript and return its facts.
328///
329/// Same chain check as [`verify_transcript`], plus returns the
330/// deserialized [`StepFact`] stream and whether the last line is a
331/// terminal fact.
332///
333/// # Errors
334///
335/// Same as [`verify_transcript`].
336pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
337    let file = File::open(jsonl_path)?;
338    let reader = BufReader::new(file);
339
340    let mut expected_prev = GENESIS_HASH.to_string();
341    let mut last_hash = expected_prev.clone();
342    let mut facts: Vec<StepFact> = Vec::new();
343
344    for (idx, line) in reader.lines().enumerate() {
345        let line = line?;
346        let line_no = idx.saturating_add(1);
347        let parsed: OwnedChainedFact =
348            serde_json::from_str(&line).map_err(|e| VerifyError::InvalidLine {
349                line: line_no,
350                reason: e.to_string(),
351            })?;
352        if parsed.prev_hash != expected_prev {
353            return Err(VerifyError::BrokenChain {
354                line: line_no,
355                expected: expected_prev,
356                actual: parsed.prev_hash,
357            });
358        }
359        last_hash = compute_fingerprint(line.as_bytes());
360        expected_prev.clone_from(&last_hash);
361        facts.push(parsed.fact);
362    }
363
364    if facts.is_empty() {
365        return Err(VerifyError::Empty);
366    }
367
368    let terminated = matches!(
369        facts.last(),
370        Some(StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }),
371    );
372
373    Ok(LoadedTranscript {
374        facts,
375        fingerprint: TranscriptFingerprint(last_hash),
376        terminated,
377    })
378}
379
380#[cfg(test)]
381mod tests {
382    use chrono::Utc;
383
384    use super::*;
385
386    fn sample_fact() -> StepFact {
387        StepFact::CeremonyStarted {
388            name: "Test".to_string(),
389            started_at: Utc::now(),
390        }
391    }
392
393    #[test]
394    fn in_memory_sink_records_and_finalizes() {
395        let mut sink = InMemorySink::new();
396        assert!(sink.is_empty());
397        sink.record(&sample_fact()).expect("record");
398        assert_eq!(sink.len(), 1);
399        let fp = sink.finalize().expect("finalize");
400        assert!(fp.as_str().starts_with("sha256:"));
401    }
402
403    #[test]
404    fn in_memory_sink_rejects_record_after_finalize() {
405        let mut sink = InMemorySink::new();
406        sink.record(&sample_fact()).expect("record");
407        sink.finalize().expect("finalize");
408        let err = sink.record(&sample_fact()).expect_err("should fail");
409        assert!(err.to_string().contains("already finalized"));
410    }
411
412    #[test]
413    fn in_memory_sink_chain_advances() {
414        let mut sink = InMemorySink::new();
415        let initial = sink.current_hash.clone();
416        sink.record(&sample_fact()).expect("record");
417        assert_ne!(sink.current_hash, initial);
418        let after_first = sink.current_hash.clone();
419        sink.record(&sample_fact()).expect("record");
420        assert_ne!(sink.current_hash, after_first);
421    }
422
423    #[test]
424    fn jsonl_file_sink_writes_and_chains() {
425        let tmp = tempfile::tempdir().expect("tempdir");
426        let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
427        sink.record(&sample_fact()).expect("record");
428        sink.record(&sample_fact()).expect("record");
429        let fp = sink.finalize().expect("finalize");
430
431        let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
432        let mut lines = jsonl.lines();
433        let raw_first = lines.next().expect("first line");
434        let raw_second = lines.next().expect("second line");
435        assert!(lines.next().is_none(), "expected exactly two lines");
436
437        let first: serde_json::Value = serde_json::from_str(raw_first).expect("parse first");
438        let second: serde_json::Value = serde_json::from_str(raw_second).expect("parse second");
439        assert_eq!(
440            first.get("prev_hash").and_then(serde_json::Value::as_str),
441            Some(GENESIS_HASH),
442        );
443        let first_hash = compute_fingerprint(raw_first.as_bytes());
444        assert_eq!(
445            second.get("prev_hash").and_then(serde_json::Value::as_str),
446            Some(first_hash.as_str()),
447        );
448        let second_hash = compute_fingerprint(raw_second.as_bytes());
449        assert_eq!(fp.as_str(), second_hash);
450    }
451
452    #[test]
453    fn jsonl_file_sink_refuses_existing_file() {
454        let tmp = tempfile::tempdir().expect("tempdir");
455        let _first = JsonlFileSink::create(tmp.path()).expect("first");
456        let err = JsonlFileSink::create(tmp.path()).expect_err("second should fail");
457        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
458    }
459
460    #[test]
461    fn verify_round_trip_succeeds_on_well_formed_transcript() {
462        let tmp = tempfile::tempdir().expect("tempdir");
463        let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
464        sink.record(&sample_fact()).expect("record");
465        sink.record(&sample_fact()).expect("record");
466        sink.record(&sample_fact()).expect("record");
467        let written = sink.finalize().expect("finalize");
468
469        let verified = verify_transcript(sink.jsonl_path()).expect("verify");
470        assert_eq!(verified.fact_count, 3);
471        assert_eq!(verified.fingerprint, written);
472        // Sample fact isn't a terminal one; the transcript is open-ended.
473        assert!(!verified.terminated);
474    }
475
476    #[test]
477    fn verify_detects_chain_break() {
478        let tmp = tempfile::tempdir().expect("tempdir");
479        let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
480        sink.record(&sample_fact()).expect("record");
481        sink.record(&sample_fact()).expect("record");
482        let _ = sink.finalize().expect("finalize");
483
484        let path = sink.jsonl_path().to_path_buf();
485        let content = std::fs::read_to_string(&path).expect("read");
486        let mut lines: Vec<String> = content.lines().map(String::from).collect();
487        if let Some(second) = lines.get_mut(1) {
488            *second = second.replace("prev_hash\":\"sha256:", "prev_hash\":\"sha256:ff");
489        }
490        std::fs::write(&path, lines.join("\n") + "\n").expect("write");
491
492        let err = verify_transcript(&path).expect_err("should detect tamper");
493        assert!(matches!(err, VerifyError::BrokenChain { .. }));
494    }
495
496    #[test]
497    fn verify_empty_transcript_returns_empty_error() {
498        let tmp = tempfile::tempdir().expect("tempdir");
499        let path = tmp.path().join("transcript.jsonl");
500        std::fs::write(&path, "").expect("write empty");
501        let err = verify_transcript(&path).expect_err("empty");
502        assert!(matches!(err, VerifyError::Empty));
503    }
504}