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//! The first line is the header, which says what the file is before any fact
13//! is read:
14//!
15//! ```jsonc
16//! {"$schema": "https://ritely.io/schemas/…/transcript.schema.json", "header": {"dry_run": false, "levels": {"public": 10, …}, …}, "chain": "sha256:…"}
17//! ```
18//!
19//! `$schema` names the JSON Schema of the release that wrote the file, for
20//! editors and other tools. It is outside the header, so the chain does not
21//! commit to it, and a reader ignores it.
22//!
23//! Every following line is one fact: its time, its confidentiality level and
24//! two checkpoints, which every line has, then a fresh random salt and the
25//! fact itself:
26//!
27//! ```jsonc
28//! {"at": "2026-06-01T20:34:51.123456Z", "level": 10, "leaf": "sha256:…", "chain": "sha256:…", "salt": "…", "fact": { "type": "step_started", … }}
29//! ```
30//!
31//! A line whose fact is withheld from a disclosure keeps `at`, `level`,
32//! `leaf` and `chain`, and drops `salt` and `fact`.
33//!
34//! The chain rule is in [`rite_model::commitment`]. `leaf` and `chain` are
35//! derivable, and stored so a reader can name the first line where its own
36//! computation disagrees: a `leaf` mismatch means the fact or its canonical
37//! form differs, a `chain` mismatch with a matching leaf means the time, the
38//! level or an earlier line differs. They do not stop a forger, who rewrites
39//! them; what ties a transcript to a ceremony is the fingerprint, the `chain`
40//! of the last line, compared against the value written down at the end of
41//! the run.
42//!
43//! `at` is the event's wall-clock time, supplied by the executor's clock when
44//! it emits the fact (the sink records it rather than choosing it, so the time
45//! is independent of the storage backend). It is committed in the chain as
46//! the exact string on the line.
47//!
48//! # Implementations
49//!
50//! - [`JsonlFileSink`], writes to disk, flushes after every line.
51//! - [`InMemorySink`], collects facts in a `Vec` for tests and tooling.
52
53use std::collections::HashSet;
54use std::fs::{File, OpenOptions};
55use std::io::{self, BufRead, BufReader, BufWriter, Write};
56use std::path::{Path, PathBuf};
57
58use chrono::{DateTime, SecondsFormat, Utc};
59use serde::Deserialize;
60use thiserror::Error;
61
62use rite_model::bundle::TRANSCRIPT_FILE;
63use rite_model::commitment::{SALT_LEN, chain_node, fact_leaf, header_node};
64use rite_model::{
65    FACT_TYPES, Level, Sha256Digest, StepFact, TRANSCRIPT_FORMAT, TRANSCRIPT_SCHEMA,
66    TranscriptHeader, canonical_json,
67};
68
69/// A transcript's fingerprint: the `chain` of its last line.
70pub type TranscriptFingerprint = Sha256Digest;
71
72/// Synchronous, durable observer of [`StepFact`]s.
73///
74/// Implementations must record each fact, including syncing it to the
75/// underlying storage where applicable, before returning. The executor
76/// relies on this to maintain the invariant that the UI never sees a fact
77/// that has not been durably persisted.
78pub trait TranscriptSink: Send {
79    /// Write the header line. Must be called once, before any fact.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if the header was already written, or the underlying
84    /// I/O error if the sink cannot persist it.
85    fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()>;
86
87    /// Record a single fact at `level`, stamped with the caller-supplied
88    /// event time `at`. Must persist before returning, the file-backed
89    /// implementation calls `sync_data` so a power loss after `record` returns
90    /// cannot drop the fact.
91    ///
92    /// `at` and `level` are supplied by the executor, not chosen here, so the
93    /// sink stays a serializer with no policy of its own.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if the header has not been written or the transcript
98    /// is finalized, if the header does not declare `level`, if the fact holds
99    /// a value the canonical form does not cover, or the underlying I/O error
100    /// if the sink cannot persist the fact.
101    fn record(&mut self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<()>;
102
103    /// Finalize the transcript and return its fingerprint.
104    ///
105    /// Calling `finalize` more than once is implementation-defined; the
106    /// default expectation is that the second call returns the cached
107    /// fingerprint.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the header was never written, or the underlying
112    /// I/O error if any pending state cannot be persisted.
113    fn finalize(&mut self) -> io::Result<TranscriptFingerprint>;
114}
115
116/// The chain as a sink builds it: the last node, the levels the header
117/// declares, and whether the run is over.
118///
119/// Committing to a line does not advance the chain. The sink advances it once
120/// the line is persisted, so a failed write leaves the chain where the file is.
121#[derive(Debug, Default)]
122struct Chain {
123    node: Option<[u8; 32]>,
124    levels: Vec<Level>,
125    finalized: bool,
126}
127
128/// A line committed to but not yet persisted: its canonical content and the
129/// values the line records beside it.
130enum Committed {
131    Header {
132        canonical: String,
133        node: [u8; 32],
134        levels: Vec<Level>,
135    },
136    Fact {
137        at: String,
138        level: Level,
139        salt: [u8; SALT_LEN],
140        canonical: String,
141        leaf: [u8; 32],
142        node: [u8; 32],
143    },
144}
145
146impl Committed {
147    fn node(&self) -> [u8; 32] {
148        match self {
149            Committed::Header { node, .. } | Committed::Fact { node, .. } => *node,
150        }
151    }
152
153    /// The line as written to `transcript.jsonl`.
154    fn line(&self) -> String {
155        match self {
156            Committed::Header {
157                canonical, node, ..
158            } => format!(
159                "{{\"$schema\":\"{TRANSCRIPT_SCHEMA}\",\"header\":{canonical},\"chain\":\"{}\"}}",
160                Sha256Digest::from_bytes(node),
161            ),
162            Committed::Fact {
163                at,
164                level,
165                salt,
166                canonical,
167                leaf,
168                node,
169            } => format!(
170                "{{\"at\":\"{at}\",\"level\":{},\"leaf\":\"{}\",\"chain\":\"{}\",\"salt\":\"{}\",\"fact\":{canonical}}}",
171                level.value(),
172                Sha256Digest::from_bytes(leaf),
173                Sha256Digest::from_bytes(node),
174                base16ct::lower::encode_string(salt),
175            ),
176        }
177    }
178}
179
180impl Chain {
181    fn header(&self, header: &TranscriptHeader) -> io::Result<Committed> {
182        if self.node.is_some() {
183            return Err(io::Error::other("transcript header already written"));
184        }
185        let value = serde_json::to_value(header).map_err(io::Error::other)?;
186        let canonical = canonical_json(&value).map_err(io::Error::other)?;
187        let node = header_node(&canonical);
188        Ok(Committed::Header {
189            canonical,
190            node,
191            levels: header.levels.values().copied().collect(),
192        })
193    }
194
195    fn fact(&self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<Committed> {
196        let previous = self
197            .node
198            .ok_or_else(|| io::Error::other("transcript header not written"))?;
199        if self.finalized {
200            return Err(io::Error::other("transcript already finalized"));
201        }
202        // A reader refuses a line at a level the header does not name.
203        if !self.levels.contains(&level) {
204            return Err(io::Error::other(format!(
205                "level {} is not declared in the transcript header",
206                level.value()
207            )));
208        }
209        let value = serde_json::to_value(fact).map_err(io::Error::other)?;
210        let canonical = canonical_json(&value).map_err(io::Error::other)?;
211        let salt: [u8; SALT_LEN] = crate::os_random::os_random()?;
212        let leaf = fact_leaf(&salt, &canonical);
213        let at = at.to_rfc3339_opts(SecondsFormat::Micros, true);
214        let node = chain_node(&previous, &at, level, &leaf);
215        Ok(Committed::Fact {
216            at,
217            level,
218            salt,
219            canonical,
220            leaf,
221            node,
222        })
223    }
224
225    fn advance(&mut self, committed: &Committed) {
226        self.node = Some(committed.node());
227        if let Committed::Header { levels, .. } = committed {
228            self.levels.clone_from(levels);
229        }
230    }
231
232    fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
233        let node = self
234            .node
235            .ok_or_else(|| io::Error::other("transcript header not written"))?;
236        self.finalized = true;
237        Ok(Sha256Digest::from_bytes(&node))
238    }
239}
240
241/// JSONL file sink with a salted commitment chain.
242///
243/// Writes `transcript.jsonl` to a target directory, one line per call,
244/// flushed and synced before returning. The transcript is self-identifying:
245/// the `chain` of its last line is the fingerprint, and no sidecar file is
246/// written.
247#[derive(Debug)]
248pub struct JsonlFileSink {
249    jsonl_path: PathBuf,
250    writer: BufWriter<File>,
251    chain: Chain,
252}
253
254impl JsonlFileSink {
255    /// Create a new sink in `dir`. Writes `transcript.jsonl` next to it.
256    ///
257    /// # Errors
258    ///
259    /// Returns an I/O error if the JSONL file cannot be created.
260    pub fn create(dir: &Path) -> io::Result<Self> {
261        let jsonl_path = dir.join(TRANSCRIPT_FILE);
262        let file = OpenOptions::new()
263            .create_new(true)
264            .write(true)
265            .open(&jsonl_path)?;
266        Ok(Self {
267            jsonl_path,
268            writer: BufWriter::new(file),
269            chain: Chain::default(),
270        })
271    }
272
273    /// Append one line and sync it to storage.
274    fn append(&mut self, line: &str) -> io::Result<()> {
275        self.writer.write_all(line.as_bytes())?;
276        self.writer.write_all(b"\n")?;
277        // `flush` drains the BufWriter into the File; `sync_data` then
278        // forces the kernel to push the page-cache pages to the storage
279        // device before we return. Without the sync, a power loss between
280        // `record` and the OS's next writeback would drop already-reported
281        // facts even though the executor moved on. Cost at ceremony pace
282        // (one record every few seconds of human pace) is negligible.
283        self.writer.flush()?;
284        self.writer.get_ref().sync_data()
285    }
286
287    /// Path to the JSONL file this sink is writing to.
288    #[must_use]
289    pub fn jsonl_path(&self) -> &Path {
290        &self.jsonl_path
291    }
292}
293
294impl TranscriptSink for JsonlFileSink {
295    fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()> {
296        let committed = self.chain.header(header)?;
297        self.append(&committed.line())?;
298        self.chain.advance(&committed);
299        Ok(())
300    }
301
302    fn record(&mut self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<()> {
303        let committed = self.chain.fact(at, level, fact)?;
304        self.append(&committed.line())?;
305        self.chain.advance(&committed);
306        Ok(())
307    }
308
309    fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
310        self.chain.finalize()
311    }
312}
313
314/// In-memory sink that collects every recorded [`StepFact`].
315///
316/// Useful for tests and for tooling that wants to inspect the fact stream
317/// without going through disk. It builds the same chain as the file sink, so
318/// its fingerprint is the one the file would have.
319#[derive(Debug, Default)]
320pub struct InMemorySink {
321    header: Option<TranscriptHeader>,
322    facts: Vec<StepFact>,
323    chain: Chain,
324}
325
326impl InMemorySink {
327    /// Create an empty sink.
328    #[must_use]
329    pub fn new() -> Self {
330        Self::default()
331    }
332
333    /// The header, once written.
334    #[must_use]
335    pub fn header(&self) -> Option<&TranscriptHeader> {
336        self.header.as_ref()
337    }
338
339    /// Recorded facts in the order they arrived.
340    #[must_use]
341    pub fn facts(&self) -> &[StepFact] {
342        &self.facts
343    }
344
345    /// Number of facts recorded so far.
346    #[must_use]
347    pub fn len(&self) -> usize {
348        self.facts.len()
349    }
350
351    /// Whether any fact has been recorded.
352    #[must_use]
353    pub fn is_empty(&self) -> bool {
354        self.facts.is_empty()
355    }
356}
357
358impl TranscriptSink for InMemorySink {
359    fn begin(&mut self, header: &TranscriptHeader) -> io::Result<()> {
360        let committed = self.chain.header(header)?;
361        self.chain.advance(&committed);
362        self.header = Some(header.clone());
363        Ok(())
364    }
365
366    fn record(&mut self, at: DateTime<Utc>, level: Level, fact: &StepFact) -> io::Result<()> {
367        let committed = self.chain.fact(at, level, fact)?;
368        self.chain.advance(&committed);
369        self.facts.push(fact.clone());
370        Ok(())
371    }
372
373    fn finalize(&mut self) -> io::Result<TranscriptFingerprint> {
374        self.chain.finalize()
375    }
376}
377
378/// The header line as read.
379#[derive(Deserialize)]
380#[serde(deny_unknown_fields)]
381struct HeaderLine {
382    /// Read so a line that carries it parses; nothing depends on it.
383    #[serde(rename = "$schema", default)]
384    _schema: Option<String>,
385    header: serde_json::Value,
386    chain: Sha256Digest,
387}
388
389/// A fact line as read, complete or withheld.
390#[derive(Deserialize)]
391#[serde(deny_unknown_fields)]
392struct FactLine {
393    at: String,
394    level: Level,
395    #[serde(default, deserialize_with = "present")]
396    salt: Option<String>,
397    #[serde(default, deserialize_with = "present")]
398    fact: Option<serde_json::Value>,
399    leaf: Sha256Digest,
400    chain: Sha256Digest,
401}
402
403/// A member that is on the line is `Some`, `null` included, so only an absent
404/// `salt` and `fact` make a withheld line, as the schema has it.
405fn present<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
406where
407    D: serde::Deserializer<'de>,
408    T: Deserialize<'de>,
409{
410    T::deserialize(deserializer).map(Some)
411}
412
413/// A recorded fact with its line's time and level.
414///
415/// The event time lives on the line rather than on individual facts, so
416/// consumers read it uniformly.
417#[derive(Debug, Clone)]
418pub struct TimedFact {
419    /// 1-based line number in the transcript.
420    pub line: usize,
421    /// Wall-clock time the fact was recorded.
422    pub at: DateTime<Utc>,
423    /// Confidentiality level the line was recorded at.
424    pub level: Level,
425    /// The recorded fact.
426    pub fact: StepFact,
427}
428
429/// A line whose fact is withheld: the transcript was disclosed without it.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct WithheldLine {
432    /// 1-based line number.
433    pub line: usize,
434    /// Level the withheld fact was recorded at.
435    pub level: Level,
436}
437
438/// A line whose fact has a type this reader does not know, from a newer
439/// vocabulary. Its commitment is checked; its content is not read.
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct UnknownFact {
442    /// 1-based line number.
443    pub line: usize,
444    /// Level the line was recorded at.
445    pub level: Level,
446    /// The fact's `type` tag.
447    pub type_name: String,
448}
449
450/// Outcome of verifying a transcript on disk.
451#[derive(Debug, Clone)]
452pub struct TranscriptVerified {
453    /// The header line.
454    pub header: TranscriptHeader,
455    /// Number of facts read and verified, the header not included.
456    pub fact_count: usize,
457    /// Final transcript fingerprint (the `chain` of the last line).
458    pub fingerprint: TranscriptFingerprint,
459    /// `true` if the last fact is a terminal one (`CeremonyCompleted` or
460    /// `CeremonyFailed`). `false` means the transcript was cut off
461    /// before the executor reached its finalize step, a truncated run.
462    pub terminated: bool,
463}
464
465/// Verification failure modes.
466#[derive(Debug, Error)]
467pub enum VerifyError {
468    /// Underlying I/O error reading the transcript.
469    #[error("io error: {0}")]
470    Io(#[from] io::Error),
471    /// A line did not parse as a transcript line.
472    #[error("line {line} is not a valid transcript line: {reason}")]
473    InvalidLine {
474        /// 1-indexed line number.
475        line: usize,
476        /// Parse error.
477        reason: String,
478    },
479    /// A fact does not match the leaf recorded beside it: the fact, its
480    /// salt, or the way it was canonicalised differs from what was committed.
481    #[error(
482        "line {line}: the fact does not match its leaf (computed {computed}, recorded {recorded})"
483    )]
484    LeafMismatch {
485        /// 1-indexed line number.
486        line: usize,
487        /// Leaf computed from the salt and fact on the line.
488        computed: String,
489        /// Leaf recorded on the line.
490        recorded: String,
491    },
492    /// A line's chain value is not the one its time, level, leaf and the
493    /// line before it produce.
494    #[error("line {line}: the chain breaks here (computed {computed}, recorded {recorded})")]
495    ChainMismatch {
496        /// 1-indexed line number where the break was detected.
497        line: usize,
498        /// Chain value computed from the line and the one before.
499        computed: String,
500        /// Chain value recorded on the line.
501        recorded: String,
502    },
503    /// The transcript file has zero lines.
504    #[error("transcript is empty")]
505    Empty,
506    /// The first line is not a transcript header.
507    #[error("line 1 is not a transcript header: {0}")]
508    MissingHeader(String),
509    /// The header's level table is unusable: a built-in level is missing or
510    /// moved, or two names share a value.
511    #[error("the header's levels are invalid: {0}")]
512    InvalidLevels(String),
513    /// The header declares a transcript format this verifier does not know.
514    /// The format fixes how every line is read, so nothing else is attempted.
515    #[error("unknown transcript format {0} (this verifier reads format {TRANSCRIPT_FORMAT})")]
516    UnknownFormat(u64),
517    /// A value was drawn (or a contribution folded) before any
518    /// `EntropySeeded` fact established the source.
519    #[error("entropy source used before it was seeded")]
520    SeedMissing,
521    /// A second `EntropySeeded` fact appeared. The source is seeded exactly
522    /// once at ceremony start; accepting a re-seed would let a crafted
523    /// transcript swap the seed mid-stream and have later draws "verify"
524    /// against a seed of the forger's choosing.
525    #[error("entropy source seeded more than once")]
526    DuplicateSeed,
527    /// The seed fact declares a derivation scheme the verifier does not
528    /// recognise. Never trust an unknown scheme (the JWT-`alg` lesson).
529    #[error("unknown entropy derivation scheme: {0}")]
530    UnknownDerivation(String),
531    /// A recorded hex value (the seed `m` or a drawn value) did not decode.
532    #[error("malformed entropy value: {0}")]
533    MalformedEntropy(String),
534    /// A drawn value does not match what re-derivation from the recorded
535    /// seed and path produces: the source was tampered with.
536    #[error("entropy draw at path '{path}' does not match re-derived value")]
537    DrawMismatch {
538        /// Derivation path of the mismatched draw.
539        path: String,
540    },
541    /// A recorded draw is longer than `HKDF-Expand` can produce
542    /// ([`MAX_DRAW_LEN`](crate::entropy::MAX_DRAW_LEN) bytes), so it cannot
543    /// have come from the real source. Rejected up front: attempting the
544    /// re-derivation would abort the verifier instead of failing the check.
545    #[error(
546        "entropy draw at path '{path}' records {len} bytes, beyond the {max}-byte HKDF-Expand limit"
547    )]
548    DrawTooLong {
549        /// Derivation path of the oversized draw.
550        path: String,
551        /// Recorded value length in bytes.
552        len: usize,
553        /// The `HKDF-Expand` output limit.
554        max: usize,
555    },
556    /// A draw's recorded path does not start with the
557    /// `<epoch>/<step>/` prefix the verifier rebuilds from its own fold
558    /// count and the step named on the fact, so the draw is attributed to
559    /// the wrong epoch or step.
560    #[error("entropy draw path '{path}' does not match expected '{expected_prefix}<purpose>'")]
561    PathMismatch {
562        /// Recorded derivation path.
563        path: String,
564        /// The `<epoch>/<step>/` prefix the verifier expected.
565        expected_prefix: String,
566    },
567    /// A contribution's recorded epoch does not match its position in the
568    /// fold sequence (first contribution is epoch 1, and so on).
569    #[error("entropy contribution at step '{step}' records epoch {recorded}, expected {expected}")]
570    EpochMismatch {
571        /// Step that recorded the contribution.
572        step: String,
573        /// Epoch index recorded on the fact.
574        recorded: u32,
575        /// Epoch index implied by the fold count.
576        expected: u32,
577    },
578    /// The same derivation path was drawn twice. The runtime never reuses a
579    /// path, so a duplicate means the record was edited or replayed.
580    #[error("entropy draw path '{path}' recorded more than once")]
581    DuplicateDrawPath {
582        /// The repeated derivation path.
583        path: String,
584    },
585}
586
587/// Verify a JSONL transcript file produced by [`JsonlFileSink`].
588///
589/// Reads the file line by line, recomputes every leaf and chain value, and
590/// checks each against the checkpoints recorded on the line.
591///
592/// # Errors
593///
594/// Returns [`VerifyError`] for any I/O failure, malformed line, or
595/// commitment mismatch.
596pub fn verify_transcript(jsonl_path: &Path) -> Result<TranscriptVerified, VerifyError> {
597    let loaded = read_verified_transcript(jsonl_path)?;
598    Ok(TranscriptVerified {
599        header: loaded.header,
600        fact_count: loaded.facts.len(),
601        fingerprint: loaded.fingerprint,
602        terminated: loaded.terminated,
603    })
604}
605
606/// Verified transcript contents: the facts, what could not be read, the
607/// fingerprint, and whether the run reached a terminal fact.
608#[derive(Debug, Clone)]
609pub struct LoadedTranscript {
610    /// The header line.
611    pub header: TranscriptHeader,
612    /// Facts in the order they were recorded, each with its line's time and
613    /// level. Withheld and unknown facts are not in this list.
614    pub facts: Vec<TimedFact>,
615    /// Lines whose fact is withheld.
616    pub withheld: Vec<WithheldLine>,
617    /// Lines whose fact has a type this reader does not know.
618    pub unknown: Vec<UnknownFact>,
619    /// Final transcript fingerprint (the `chain` of the last line).
620    pub fingerprint: TranscriptFingerprint,
621    /// `true` if the last line is a disclosed `CeremonyCompleted` or
622    /// `CeremonyFailed`.
623    pub terminated: bool,
624}
625
626/// Verify a JSONL transcript and return its facts.
627///
628/// Same check as [`verify_transcript`], plus returns the deserialized
629/// [`StepFact`] stream, the withheld and unknown lines, and whether the last
630/// line is a terminal fact.
631///
632/// # Errors
633///
634/// Same as [`verify_transcript`].
635pub fn read_verified_transcript(jsonl_path: &Path) -> Result<LoadedTranscript, VerifyError> {
636    let file = File::open(jsonl_path)?;
637    verify_lines(BufReader::new(file).lines())
638}
639
640/// [`read_verified_transcript`] over a transcript already in memory.
641///
642/// # Errors
643///
644/// Same as [`verify_transcript`].
645pub fn verify_jsonl(jsonl: &str) -> Result<LoadedTranscript, VerifyError> {
646    verify_lines(jsonl.lines().map(|line| Ok(line.to_string())))
647}
648
649fn verify_lines(
650    mut lines: impl Iterator<Item = io::Result<String>>,
651) -> Result<LoadedTranscript, VerifyError> {
652    let first = lines.next().ok_or(VerifyError::Empty)??;
653    let (header, mut node) = read_header(&first)?;
654
655    let mut loaded = LoadedTranscript {
656        header,
657        facts: Vec::new(),
658        withheld: Vec::new(),
659        unknown: Vec::new(),
660        fingerprint: Sha256Digest::from_bytes(&node),
661        terminated: false,
662    };
663
664    for (idx, line) in lines.enumerate() {
665        let line = line?;
666        // Line 1 is the header; facts start on line 2.
667        let line_no = idx.saturating_add(2);
668        node = read_fact_line(&line, line_no, &node, &mut loaded)?;
669    }
670
671    loaded.fingerprint = Sha256Digest::from_bytes(&node);
672    Ok(loaded)
673}
674
675/// Withhold every fact recorded above `threshold`, keeping each line's
676/// commitments, so the result folds to the same fingerprint.
677///
678/// The header and every line at or below the threshold are copied unchanged.
679/// A withheld line keeps its `at`, `level`, `leaf` and `chain` and loses its
680/// `salt` and `fact`. Lines already withheld stay withheld. The input is not
681/// verified here; verify it before, and the output after.
682///
683/// # Errors
684///
685/// Returns [`VerifyError::Empty`] for an empty transcript, or
686/// [`VerifyError::InvalidLine`] for a fact line that does not parse.
687pub fn disclose_transcript(jsonl: &str, threshold: Level) -> Result<String, VerifyError> {
688    let mut lines = jsonl.lines();
689    let header = lines.next().ok_or(VerifyError::Empty)?;
690    let mut out = String::with_capacity(jsonl.len());
691    out.push_str(header);
692    out.push('\n');
693    for (idx, line) in lines.enumerate() {
694        // Line 1 is the header; facts start on line 2.
695        let line_no = idx.saturating_add(2);
696        let parsed: FactLine = serde_json::from_str(line).map_err(|e| invalid(line_no, &e))?;
697        if parsed.level > threshold && parsed.fact.is_some() {
698            out.push_str(&withheld_line(&parsed)?);
699        } else {
700            out.push_str(line);
701        }
702        out.push('\n');
703    }
704    Ok(out)
705}
706
707/// The withheld form of a line: its commitments without its content.
708fn withheld_line(line: &FactLine) -> Result<String, VerifyError> {
709    let at = serde_json::to_string(&line.at).map_err(|e| invalid(0, &e))?;
710    Ok(format!(
711        "{{\"at\":{at},\"level\":{},\"leaf\":\"{}\",\"chain\":\"{}\"}}",
712        line.level.value(),
713        line.leaf,
714        line.chain,
715    ))
716}
717
718/// Read and check the header line, returning the header and `node_0`.
719fn read_header(line: &str) -> Result<(TranscriptHeader, [u8; 32]), VerifyError> {
720    let parsed: HeaderLine =
721        serde_json::from_str(line).map_err(|e| VerifyError::MissingHeader(e.to_string()))?;
722    // The format decides how everything else is read, so it is checked
723    // before the header's own commitment.
724    let format = parsed
725        .header
726        .get("rite_transcript")
727        .and_then(serde_json::Value::as_u64)
728        .ok_or_else(|| VerifyError::MissingHeader("no rite_transcript version".to_string()))?;
729    if format != u64::from(TRANSCRIPT_FORMAT) {
730        return Err(VerifyError::UnknownFormat(format));
731    }
732    let canonical = canonical_json(&parsed.header).map_err(|e| invalid(1, &e))?;
733    let node = header_node(&canonical);
734    check_checkpoint(&parsed.chain, &node, |computed, recorded| {
735        VerifyError::ChainMismatch {
736            line: 1,
737            computed,
738            recorded,
739        }
740    })?;
741    let header: TranscriptHeader = serde_json::from_value(parsed.header)
742        .map_err(|e| VerifyError::MissingHeader(e.to_string()))?;
743    check_levels(&header)?;
744    Ok((header, node))
745}
746
747/// The built-in levels must appear at their fixed values, and no two names may
748/// share a value, so every line's level has exactly one name.
749fn check_levels(header: &TranscriptHeader) -> Result<(), VerifyError> {
750    for (name, level) in Level::BUILT_IN {
751        if header.levels.get(name) != Some(&level) {
752            return Err(VerifyError::InvalidLevels(format!(
753                "'{name}' must be {}",
754                level.value()
755            )));
756        }
757    }
758    let mut seen = HashSet::new();
759    for level in header.levels.values() {
760        if !seen.insert(*level) {
761            return Err(VerifyError::InvalidLevels(format!(
762                "more than one name for {}",
763                level.value()
764            )));
765        }
766    }
767    Ok(())
768}
769
770/// Read and check one fact line, adding its fact to `loaded`, and return the
771/// chain value after it.
772fn read_fact_line(
773    line: &str,
774    line_no: usize,
775    previous: &[u8; 32],
776    loaded: &mut LoadedTranscript,
777) -> Result<[u8; 32], VerifyError> {
778    let parsed: FactLine = serde_json::from_str(line).map_err(|e| invalid(line_no, &e))?;
779    if !loaded.header.declares(parsed.level) {
780        return Err(invalid(
781            line_no,
782            &format!(
783                "level {} is not declared in the header",
784                parsed.level.value()
785            ),
786        ));
787    }
788    let at: DateTime<Utc> = DateTime::parse_from_rfc3339(&parsed.at)
789        .map_err(|e| invalid(line_no, &e))?
790        .with_timezone(&Utc);
791
792    let leaf = match (parsed.salt, parsed.fact) {
793        (Some(salt), Some(fact)) => {
794            let salt = decode_salt(&salt).ok_or_else(|| {
795                invalid(
796                    line_no,
797                    &format!("salt is not {SALT_LEN} bytes of lowercase hex"),
798                )
799            })?;
800            let canonical = canonical_json(&fact).map_err(|e| invalid(line_no, &e))?;
801            let leaf = fact_leaf(&salt, &canonical);
802            check_checkpoint(&parsed.leaf, &leaf, |computed, recorded| {
803                VerifyError::LeafMismatch {
804                    line: line_no,
805                    computed,
806                    recorded,
807                }
808            })?;
809            loaded.terminated = add_fact(fact, at, parsed.level, line_no, loaded)?;
810            leaf
811        }
812        (None, None) => {
813            loaded.withheld.push(WithheldLine {
814                line: line_no,
815                level: parsed.level,
816            });
817            loaded.terminated = false;
818            parsed.leaf.to_bytes()
819        }
820        _ => {
821            return Err(invalid(
822                line_no,
823                &"a line carries both `salt` and `fact`, or neither",
824            ));
825        }
826    };
827
828    let node = chain_node(previous, &parsed.at, parsed.level, &leaf);
829    check_checkpoint(&parsed.chain, &node, |computed, recorded| {
830        VerifyError::ChainMismatch {
831            line: line_no,
832            computed,
833            recorded,
834        }
835    })?;
836    Ok(node)
837}
838
839/// Parse a disclosed fact into `loaded`, returning whether it is terminal.
840///
841/// A fact whose type this vocabulary does not know is counted rather than
842/// read; a known type that does not parse is an error.
843fn add_fact(
844    fact: serde_json::Value,
845    at: DateTime<Utc>,
846    level: Level,
847    line_no: usize,
848    loaded: &mut LoadedTranscript,
849) -> Result<bool, VerifyError> {
850    let type_name = fact
851        .get("type")
852        .and_then(serde_json::Value::as_str)
853        .ok_or_else(|| invalid(line_no, &"the fact has no `type`"))?
854        .to_string();
855    if !FACT_TYPES.contains(&type_name.as_str()) {
856        loaded.unknown.push(UnknownFact {
857            line: line_no,
858            level,
859            type_name,
860        });
861        return Ok(false);
862    }
863    let fact: StepFact = serde_json::from_value(fact).map_err(|e| invalid(line_no, &e))?;
864    let terminal = matches!(
865        fact,
866        StepFact::CeremonyCompleted { .. } | StepFact::CeremonyFailed { .. }
867    );
868    loaded.facts.push(TimedFact {
869        line: line_no,
870        at,
871        level,
872        fact,
873    });
874    Ok(terminal)
875}
876
877fn decode_salt(hex: &str) -> Option<[u8; SALT_LEN]> {
878    base16ct::lower::decode_vec(hex).ok()?.try_into().ok()
879}
880
881/// Compare a computed value against the checkpoint recorded on a line.
882fn check_checkpoint(
883    recorded: &Sha256Digest,
884    computed: &[u8; 32],
885    mismatch: impl FnOnce(String, String) -> VerifyError,
886) -> Result<(), VerifyError> {
887    if recorded.to_bytes() == *computed {
888        Ok(())
889    } else {
890        Err(mismatch(
891            Sha256Digest::from_bytes(computed).to_string(),
892            recorded.to_string(),
893        ))
894    }
895}
896
897fn invalid(line: usize, reason: &impl std::fmt::Display) -> VerifyError {
898    VerifyError::InvalidLine {
899        line,
900        reason: reason.to_string(),
901    }
902}
903
904/// Outcome of re-deriving a transcript's entropy source.
905#[derive(Debug, Clone, Default)]
906pub struct EntropyVerified {
907    /// Derivation-scheme tag declared by the seed fact, if the ceremony used
908    /// the source at all.
909    pub derivation: Option<String>,
910    /// Provenance of the machine seed as recorded by the seed fact (e.g.
911    /// `os`, or the `dry-run` sentinel). Consumers surface this so a
912    /// dry-run transcript, which re-derives just as cleanly as a real one,
913    /// is never presented as real evidence.
914    pub source: Option<String>,
915    /// Number of human contributions folded into the ratchet.
916    pub contributions: usize,
917    /// Number of drawn values that re-derived to their recorded value.
918    pub values_verified: usize,
919}
920
921/// Re-derive a transcript's entropy source and confirm every drawn value.
922///
923/// Replays the `rite-kdf/v1` ratchet over the recorded facts: it rebuilds
924/// `seed_0` from the recorded machine entropy, folds each human contribution
925/// in chain order, and for every [`StepFact::EntropyDrawn`] re-derives the
926/// value from the recorded path and checks it against the recorded value. A
927/// tampered seed, contribution, path, or value fails the check.
928///
929/// Beyond the value re-derivation, the replay enforces the source's own
930/// invariants, so a transcript cannot record a fact stream the runtime could
931/// never have produced:
932///
933/// - the source is seeded exactly once;
934/// - each contribution's recorded `epoch` matches its position in the fold
935///   sequence;
936/// - each draw's recorded path carries the `<epoch>/<step>/` prefix rebuilt
937///   from the verifier's own fold count and the step named on the fact;
938/// - no derivation path is drawn twice;
939/// - no recorded value exceeds what `HKDF-Expand` can produce
940///   ([`MAX_DRAW_LEN`](crate::entropy::MAX_DRAW_LEN) bytes), which would
941///   otherwise abort the verifier instead of failing the check.
942///
943/// The caller is expected to have chain-verified the facts first (via
944/// [`read_verified_transcript`]); this function only re-derives.
945///
946/// # Errors
947///
948/// Returns a [`VerifyError`] variant describing the first inconsistency:
949/// [`VerifyError::SeedMissing`], [`VerifyError::DuplicateSeed`],
950/// [`VerifyError::UnknownDerivation`], [`VerifyError::MalformedEntropy`],
951/// [`VerifyError::EpochMismatch`], [`VerifyError::PathMismatch`],
952/// [`VerifyError::DuplicateDrawPath`], [`VerifyError::DrawTooLong`], or
953/// [`VerifyError::DrawMismatch`].
954pub fn verify_entropy<'a>(
955    facts: impl IntoIterator<Item = &'a StepFact>,
956) -> Result<EntropyVerified, VerifyError> {
957    let mut seed: Option<[u8; 32]> = None;
958    let mut epoch: u32 = 0;
959    let mut drawn_paths: HashSet<String> = HashSet::new();
960    let mut result = EntropyVerified::default();
961
962    for fact in facts {
963        match fact {
964            StepFact::EntropySeeded {
965                m,
966                source,
967                derivation,
968                ..
969            } => {
970                if seed.is_some() {
971                    return Err(VerifyError::DuplicateSeed);
972                }
973                if derivation != crate::entropy::DERIVATION_V1 {
974                    return Err(VerifyError::UnknownDerivation(derivation.clone()));
975                }
976                let m_bytes = base16ct::lower::decode_vec(m)
977                    .map_err(|e| VerifyError::MalformedEntropy(format!("seed m: {e}")))?;
978                seed = Some(crate::entropy::initial_seed(&m_bytes));
979                result.derivation = Some(derivation.clone());
980                result.source = Some(source.clone());
981            }
982            StepFact::EntropyContributed {
983                step,
984                epoch: recorded_epoch,
985                contribution,
986                ..
987            } => {
988                let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?;
989                let expected = epoch.saturating_add(1);
990                if *recorded_epoch != expected {
991                    return Err(VerifyError::EpochMismatch {
992                        step: step.as_str().to_string(),
993                        recorded: *recorded_epoch,
994                        expected,
995                    });
996                }
997                seed = Some(crate::entropy::fold_seed(current, contribution.as_bytes()));
998                epoch = expected;
999                result.contributions = result.contributions.saturating_add(1);
1000            }
1001            StepFact::EntropyDrawn {
1002                step, path, value, ..
1003            } => {
1004                let current = seed.as_ref().ok_or(VerifyError::SeedMissing)?;
1005                // Rebuild the path prefix from the verifier's own fold count
1006                // and the step named on the fact; only the purpose segment is
1007                // taken from the record. A draw attributed to the wrong epoch
1008                // or step fails here even if its value re-derives.
1009                let expected_prefix = crate::entropy::build_path(epoch, step, "");
1010                if !path.starts_with(&expected_prefix) || path.len() == expected_prefix.len() {
1011                    return Err(VerifyError::PathMismatch {
1012                        path: path.clone(),
1013                        expected_prefix,
1014                    });
1015                }
1016                if !drawn_paths.insert(path.clone()) {
1017                    return Err(VerifyError::DuplicateDrawPath { path: path.clone() });
1018                }
1019                // The recorded value's own length fixes how many bytes to
1020                // re-derive; decoding it also rejects a malformed hex record.
1021                let recorded = base16ct::lower::decode_vec(value)
1022                    .map_err(|e| VerifyError::MalformedEntropy(format!("draw {path}: {e}")))?;
1023                if recorded.len() > crate::entropy::MAX_DRAW_LEN {
1024                    return Err(VerifyError::DrawTooLong {
1025                        path: path.clone(),
1026                        len: recorded.len(),
1027                        max: crate::entropy::MAX_DRAW_LEN,
1028                    });
1029                }
1030                let expected = crate::entropy::derive_value(current, path, recorded.len());
1031                if expected != recorded {
1032                    return Err(VerifyError::DrawMismatch { path: path.clone() });
1033                }
1034                result.values_verified = result.values_verified.saturating_add(1);
1035            }
1036            _ => {}
1037        }
1038    }
1039
1040    Ok(result)
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046    use crate::test_support::fixed_test_time as test_at;
1047
1048    fn sample_fact() -> StepFact {
1049        crate::test_support::ceremony_started("Test")
1050    }
1051
1052    fn begun_file_sink(dir: &Path) -> JsonlFileSink {
1053        let mut sink = JsonlFileSink::create(dir).expect("create");
1054        sink.begin(&crate::test_support::test_header())
1055            .expect("header");
1056        sink
1057    }
1058
1059    #[test]
1060    fn in_memory_sink_records_and_finalizes() {
1061        let mut sink = crate::test_support::begun_sink();
1062        assert!(sink.is_empty());
1063        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1064            .expect("record");
1065        assert_eq!(sink.len(), 1);
1066        let fp = sink.finalize().expect("finalize");
1067        assert!(fp.as_str().starts_with("sha256:"));
1068    }
1069
1070    #[test]
1071    fn in_memory_sink_rejects_record_after_finalize() {
1072        let mut sink = crate::test_support::begun_sink();
1073        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1074            .expect("record");
1075        sink.finalize().expect("finalize");
1076        let err = sink
1077            .record(test_at(), Level::PUBLIC, &sample_fact())
1078            .expect_err("should fail");
1079        assert!(err.to_string().contains("already finalized"));
1080    }
1081
1082    #[test]
1083    fn in_memory_sink_chain_advances() {
1084        let mut sink = crate::test_support::begun_sink();
1085        let initial = sink.chain.node;
1086        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1087            .expect("record");
1088        assert_ne!(sink.chain.node, initial);
1089        let after_first = sink.chain.node;
1090        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1091            .expect("record");
1092        assert_ne!(sink.chain.node, after_first);
1093    }
1094
1095    #[test]
1096    fn record_persists_the_supplied_time_and_reader_recovers_it() {
1097        // The sink records the `at` it is given (it does not read a clock), and
1098        // the reader recovers exactly that instant from the envelope.
1099        let tmp = tempfile::tempdir().expect("tempdir");
1100        let mut sink = begun_file_sink(tmp.path());
1101        let at = test_at();
1102        sink.record(at, Level::PUBLIC, &sample_fact())
1103            .expect("record");
1104        sink.finalize().expect("finalize");
1105
1106        let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
1107        assert_eq!(loaded.facts.first().expect("one fact").at, at);
1108    }
1109
1110    #[test]
1111    fn jsonl_file_sink_writes_checkpoints_that_fold_to_the_fingerprint() {
1112        let tmp = tempfile::tempdir().expect("tempdir");
1113        let mut sink = begun_file_sink(tmp.path());
1114        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1115            .expect("record");
1116        sink.record(test_at(), Level::RESTRICTED, &sample_fact())
1117            .expect("record");
1118        let fp = sink.finalize().expect("finalize");
1119
1120        let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
1121        let lines: Vec<serde_json::Value> = jsonl
1122            .lines()
1123            .map(|l| serde_json::from_str(l).expect("parse line"))
1124            .collect();
1125        assert_eq!(lines.len(), 3, "expected a header and two facts");
1126        assert!(lines.first().and_then(|l| l.get("header")).is_some());
1127        let second = lines.get(2).expect("second fact");
1128        assert_eq!(
1129            second.get("level").and_then(serde_json::Value::as_u64),
1130            Some(20)
1131        );
1132        assert_eq!(
1133            lines
1134                .last()
1135                .and_then(|l| l.get("chain"))
1136                .and_then(serde_json::Value::as_str),
1137            Some(fp.as_str()),
1138            "the last chain value is the fingerprint"
1139        );
1140    }
1141
1142    #[test]
1143    fn two_records_of_the_same_fact_get_different_salts() {
1144        let tmp = tempfile::tempdir().expect("tempdir");
1145        let mut sink = begun_file_sink(tmp.path());
1146        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1147            .expect("record");
1148        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1149            .expect("record");
1150        let jsonl = std::fs::read_to_string(sink.jsonl_path()).expect("read jsonl");
1151        let salts: Vec<String> = jsonl
1152            .lines()
1153            .skip(1)
1154            .map(|l| {
1155                let v: serde_json::Value = serde_json::from_str(l).expect("parse");
1156                v.get("salt")
1157                    .and_then(serde_json::Value::as_str)
1158                    .expect("salt")
1159                    .to_string()
1160            })
1161            .collect();
1162        assert_eq!(salts.len(), 2);
1163        assert_ne!(salts.first(), salts.last());
1164    }
1165
1166    #[test]
1167    fn jsonl_file_sink_refuses_existing_file() {
1168        let tmp = tempfile::tempdir().expect("tempdir");
1169        let _first = JsonlFileSink::create(tmp.path()).expect("first");
1170        let err = JsonlFileSink::create(tmp.path()).expect_err("second should fail");
1171        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
1172    }
1173
1174    #[test]
1175    fn verify_round_trip_succeeds_on_well_formed_transcript() {
1176        let tmp = tempfile::tempdir().expect("tempdir");
1177        let mut sink = begun_file_sink(tmp.path());
1178        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1179            .expect("record");
1180        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1181            .expect("record");
1182        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1183            .expect("record");
1184        let written = sink.finalize().expect("finalize");
1185
1186        let verified = verify_transcript(sink.jsonl_path()).expect("verify");
1187        assert_eq!(verified.fact_count, 3);
1188        assert_eq!(verified.fingerprint, written);
1189        // Sample fact isn't a terminal one; the transcript is open-ended.
1190        assert!(!verified.terminated);
1191    }
1192
1193    /// Write a transcript of a header and three facts, then return its
1194    /// path and lines for a test to edit.
1195    fn three_fact_transcript(dir: &Path) -> (PathBuf, Vec<String>) {
1196        let mut sink = begun_file_sink(dir);
1197        for _ in 0..3 {
1198            sink.record(test_at(), Level::PUBLIC, &sample_fact())
1199                .expect("record");
1200        }
1201        let path = sink.jsonl_path().to_path_buf();
1202        let lines = std::fs::read_to_string(&path)
1203            .expect("read")
1204            .lines()
1205            .map(String::from)
1206            .collect();
1207        (path, lines)
1208    }
1209
1210    fn rewrite(path: &Path, lines: &[String]) {
1211        std::fs::write(path, lines.join("\n") + "\n").expect("write");
1212    }
1213
1214    fn edit_line(lines: &mut [String], index: usize, edit: impl FnOnce(&mut serde_json::Value)) {
1215        let line = lines.get_mut(index).expect("line");
1216        let mut value: serde_json::Value = serde_json::from_str(line).expect("parse");
1217        edit(&mut value);
1218        *line = serde_json::to_string(&value).expect("serialize");
1219    }
1220
1221    #[test]
1222    fn an_edited_fact_is_caught_at_its_leaf() {
1223        let tmp = tempfile::tempdir().expect("tempdir");
1224        let (path, mut lines) = three_fact_transcript(tmp.path());
1225        edit_line(&mut lines, 2, |v| {
1226            if let Some(fact) = v.get_mut("fact").and_then(serde_json::Value::as_object_mut) {
1227                fact.insert("name".to_string(), serde_json::json!("Other"));
1228            }
1229        });
1230        rewrite(&path, &lines);
1231        let err = verify_transcript(&path).expect_err("tampered fact");
1232        assert!(matches!(err, VerifyError::LeafMismatch { line: 3, .. }));
1233    }
1234
1235    #[test]
1236    fn an_edited_level_is_caught_at_its_chain_value() {
1237        let tmp = tempfile::tempdir().expect("tempdir");
1238        let (path, mut lines) = three_fact_transcript(tmp.path());
1239        edit_line(&mut lines, 2, |v| {
1240            if let Some(obj) = v.as_object_mut() {
1241                obj.insert("level".to_string(), serde_json::json!(30));
1242            }
1243        });
1244        rewrite(&path, &lines);
1245        let err = verify_transcript(&path).expect_err("tampered level");
1246        assert!(matches!(err, VerifyError::ChainMismatch { line: 3, .. }));
1247    }
1248
1249    #[test]
1250    fn a_deleted_line_breaks_the_chain_at_the_next_one() {
1251        let tmp = tempfile::tempdir().expect("tempdir");
1252        let (path, mut lines) = three_fact_transcript(tmp.path());
1253        lines.remove(2);
1254        rewrite(&path, &lines);
1255        let err = verify_transcript(&path).expect_err("deleted line");
1256        assert!(matches!(err, VerifyError::ChainMismatch { line: 3, .. }));
1257    }
1258
1259    #[test]
1260    fn a_withheld_line_folds_to_the_same_fingerprint() {
1261        let tmp = tempfile::tempdir().expect("tempdir");
1262        let (path, mut lines) = three_fact_transcript(tmp.path());
1263        let complete = verify_transcript(&path).expect("complete").fingerprint;
1264        edit_line(&mut lines, 2, |v| {
1265            if let Some(obj) = v.as_object_mut() {
1266                obj.remove("salt");
1267                obj.remove("fact");
1268            }
1269        });
1270        rewrite(&path, &lines);
1271        let loaded = read_verified_transcript(&path).expect("withheld");
1272        assert_eq!(loaded.fingerprint, complete);
1273        assert_eq!(
1274            loaded.withheld,
1275            vec![WithheldLine {
1276                line: 3,
1277                level: Level::PUBLIC
1278            }]
1279        );
1280        assert_eq!(loaded.facts.len(), 2);
1281    }
1282
1283    #[test]
1284    fn disclosing_withholds_above_the_threshold_and_keeps_the_fingerprint() {
1285        let tmp = tempfile::tempdir().expect("tempdir");
1286        let mut sink = begun_file_sink(tmp.path());
1287        for level in [Level::PUBLIC, Level::CONFIDENTIAL, Level::RESTRICTED] {
1288            sink.record(test_at(), level, &sample_fact())
1289                .expect("record");
1290        }
1291        let complete = sink.finalize().expect("finalize");
1292        let text = std::fs::read_to_string(sink.jsonl_path()).expect("read");
1293
1294        let disclosed = disclose_transcript(&text, Level::RESTRICTED).expect("disclose");
1295        let path = tmp.path().join("disclosed.jsonl");
1296        std::fs::write(&path, &disclosed).expect("write");
1297        let loaded = read_verified_transcript(&path).expect("verify");
1298
1299        assert_eq!(loaded.fingerprint, complete);
1300        assert_eq!(
1301            loaded.withheld,
1302            vec![WithheldLine {
1303                line: 3,
1304                level: Level::CONFIDENTIAL
1305            }]
1306        );
1307        assert_eq!(loaded.facts.len(), 2);
1308        // Disclosing again at the same threshold changes nothing.
1309        assert_eq!(
1310            disclose_transcript(&disclosed, Level::RESTRICTED).expect("again"),
1311            disclosed
1312        );
1313    }
1314
1315    #[test]
1316    fn a_line_with_a_fact_and_no_salt_is_invalid() {
1317        let tmp = tempfile::tempdir().expect("tempdir");
1318        let (path, mut lines) = three_fact_transcript(tmp.path());
1319        edit_line(&mut lines, 2, |v| {
1320            if let Some(obj) = v.as_object_mut() {
1321                obj.remove("salt");
1322            }
1323        });
1324        rewrite(&path, &lines);
1325        let err = verify_transcript(&path).expect_err("half-withheld line");
1326        assert!(matches!(err, VerifyError::InvalidLine { line: 3, .. }));
1327    }
1328
1329    #[test]
1330    fn a_line_with_null_salt_and_fact_is_not_withheld() {
1331        let tmp = tempfile::tempdir().expect("tempdir");
1332        let (path, mut lines) = three_fact_transcript(tmp.path());
1333        edit_line(&mut lines, 2, |v| {
1334            if let Some(obj) = v.as_object_mut() {
1335                obj.insert("salt".to_string(), serde_json::Value::Null);
1336                obj.insert("fact".to_string(), serde_json::Value::Null);
1337            }
1338        });
1339        rewrite(&path, &lines);
1340        let err = verify_transcript(&path).expect_err("null members");
1341        assert!(matches!(err, VerifyError::InvalidLine { line: 3, .. }));
1342    }
1343
1344    #[test]
1345    fn an_unknown_fact_type_is_counted_not_refused() {
1346        let tmp = tempfile::tempdir().expect("tempdir");
1347        let mut sink = begun_file_sink(tmp.path());
1348        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1349            .expect("record");
1350        let path = sink.jsonl_path().to_path_buf();
1351        let mut lines: Vec<String> = std::fs::read_to_string(&path)
1352            .expect("read")
1353            .lines()
1354            .map(String::from)
1355            .collect();
1356        // A fact from a newer vocabulary, committed correctly.
1357        let previous: serde_json::Value =
1358            serde_json::from_str(lines.last().expect("last")).expect("parse");
1359        let previous_node = Sha256Digest::parse(
1360            previous
1361                .get("chain")
1362                .and_then(serde_json::Value::as_str)
1363                .expect("chain"),
1364        )
1365        .expect("digest")
1366        .to_bytes();
1367        let fact = serde_json::json!({ "type": "future_fact", "detail": 1 });
1368        let salt = [7u8; SALT_LEN];
1369        let leaf = fact_leaf(&salt, &canonical_json(&fact).expect("canonical"));
1370        let at = "2026-09-22T10:00:00.000000Z";
1371        let node = chain_node(&previous_node, at, Level::PUBLIC, &leaf);
1372        lines.push(
1373            serde_json::json!({
1374                "at": at,
1375                "level": 10,
1376                "salt": base16ct::lower::encode_string(&salt),
1377                "fact": fact,
1378                "leaf": Sha256Digest::from_bytes(&leaf).as_str(),
1379                "chain": Sha256Digest::from_bytes(&node).as_str(),
1380            })
1381            .to_string(),
1382        );
1383        rewrite(&path, &lines);
1384
1385        let loaded = read_verified_transcript(&path).expect("read");
1386        assert_eq!(
1387            loaded.unknown,
1388            vec![UnknownFact {
1389                line: 3,
1390                level: Level::PUBLIC,
1391                type_name: "future_fact".to_string()
1392            }]
1393        );
1394        assert_eq!(loaded.facts.len(), 1);
1395    }
1396
1397    #[test]
1398    fn verify_empty_transcript_returns_empty_error() {
1399        let tmp = tempfile::tempdir().expect("tempdir");
1400        let path = tmp.path().join("transcript.jsonl");
1401        std::fs::write(&path, "").expect("write empty");
1402        let err = verify_transcript(&path).expect_err("empty");
1403        assert!(matches!(err, VerifyError::Empty));
1404    }
1405
1406    #[test]
1407    fn a_fact_before_the_header_is_refused() {
1408        let mut sink = InMemorySink::new();
1409        let err = sink
1410            .record(test_at(), Level::PUBLIC, &sample_fact())
1411            .expect_err("no header yet");
1412        assert!(err.to_string().contains("header not written"));
1413    }
1414
1415    #[test]
1416    fn a_second_header_is_refused() {
1417        let mut sink = crate::test_support::begun_sink();
1418        let err = sink
1419            .begin(&crate::test_support::test_header())
1420            .expect_err("second header");
1421        assert!(err.to_string().contains("already written"));
1422    }
1423
1424    #[test]
1425    fn the_reader_returns_the_header() {
1426        let tmp = tempfile::tempdir().expect("tempdir");
1427        let mut sink = begun_file_sink(tmp.path());
1428        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1429            .expect("record");
1430        let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
1431        assert_eq!(loaded.header, crate::test_support::test_header());
1432        assert_eq!(loaded.facts.len(), 1);
1433    }
1434
1435    /// Write a transcript whose header is `header`, with a correct header
1436    /// commitment and no facts.
1437    fn transcript_with_header(dir: &Path, header: &TranscriptHeader) -> PathBuf {
1438        let value = serde_json::to_value(header).expect("header json");
1439        let node = header_node(&canonical_json(&value).expect("canonical"));
1440        let line = serde_json::json!({
1441            "header": value,
1442            "chain": Sha256Digest::from_bytes(&node).as_str(),
1443        })
1444        .to_string();
1445        let path = dir.join("transcript.jsonl");
1446        std::fs::write(&path, format!("{line}\n")).expect("write");
1447        path
1448    }
1449
1450    #[test]
1451    fn an_unknown_format_is_refused() {
1452        let tmp = tempfile::tempdir().expect("tempdir");
1453        let mut header = crate::test_support::test_header();
1454        header.rite_transcript = 2;
1455        let path = transcript_with_header(tmp.path(), &header);
1456        let err = verify_transcript(&path).expect_err("unknown format");
1457        assert!(matches!(err, VerifyError::UnknownFormat(2)));
1458    }
1459
1460    #[test]
1461    fn a_newer_vocabulary_is_read() {
1462        let tmp = tempfile::tempdir().expect("tempdir");
1463        let mut header = crate::test_support::test_header();
1464        header.vocabulary = 9;
1465        let path = transcript_with_header(tmp.path(), &header);
1466        let loaded = read_verified_transcript(&path).expect("newer vocabulary");
1467        assert_eq!(loaded.header.vocabulary, 9);
1468    }
1469
1470    #[test]
1471    fn a_moved_built_in_level_is_refused() {
1472        let tmp = tempfile::tempdir().expect("tempdir");
1473        let mut header = crate::test_support::test_header();
1474        header.levels.insert("public".to_string(), Level::new(5));
1475        let path = transcript_with_header(tmp.path(), &header);
1476        let err = verify_transcript(&path).expect_err("moved built-in");
1477        assert!(matches!(err, VerifyError::InvalidLevels(_)));
1478    }
1479
1480    #[test]
1481    fn a_line_at_an_undeclared_level_is_refused() {
1482        let tmp = tempfile::tempdir().expect("tempdir");
1483        let (path, mut lines) = three_fact_transcript(tmp.path());
1484        edit_line(&mut lines, 2, |v| {
1485            if let Some(obj) = v.as_object_mut() {
1486                obj.insert("level".to_string(), 15.into());
1487            }
1488        });
1489        rewrite(&path, &lines);
1490        let err = verify_transcript(&path).expect_err("undeclared level");
1491        assert!(
1492            matches!(err, VerifyError::InvalidLine { line: 3, .. }),
1493            "{err}"
1494        );
1495    }
1496
1497    #[test]
1498    fn the_sink_refuses_a_level_the_header_does_not_declare() {
1499        let tmp = tempfile::tempdir().expect("tempdir");
1500        let mut sink = begun_file_sink(tmp.path());
1501        let err = sink
1502            .record(test_at(), Level::new(15), &sample_fact())
1503            .expect_err("undeclared level");
1504        assert!(err.to_string().contains("not declared"), "{err}");
1505        // Nothing was written, and the chain did not move.
1506        sink.record(test_at(), Level::PUBLIC, &sample_fact())
1507            .expect("record");
1508        let loaded = read_verified_transcript(sink.jsonl_path()).expect("verify");
1509        assert_eq!(loaded.facts.len(), 1);
1510    }
1511
1512    #[test]
1513    fn a_declared_organisation_level_is_accepted() {
1514        let tmp = tempfile::tempdir().expect("tempdir");
1515        let mut header = crate::test_support::test_header();
1516        header.levels.insert("partners".to_string(), Level::new(15));
1517        let mut sink = JsonlFileSink::create(tmp.path()).expect("create");
1518        sink.begin(&header).expect("header");
1519        sink.record(test_at(), Level::new(15), &sample_fact())
1520            .expect("record");
1521        let loaded = read_verified_transcript(sink.jsonl_path()).expect("read");
1522        assert_eq!(loaded.facts.first().map(|t| t.level), Some(Level::new(15)));
1523    }
1524
1525    #[test]
1526    fn an_edited_header_is_caught() {
1527        let tmp = tempfile::tempdir().expect("tempdir");
1528        let path = transcript_with_header(tmp.path(), &crate::test_support::test_header());
1529        let text = std::fs::read_to_string(&path).expect("read");
1530        std::fs::write(&path, text.replace("\"dry_run\":false", "\"dry_run\":true"))
1531            .expect("write");
1532        let err = verify_transcript(&path).expect_err("edited header");
1533        assert!(matches!(err, VerifyError::ChainMismatch { line: 1, .. }));
1534    }
1535
1536    #[test]
1537    fn a_transcript_starting_with_a_fact_is_refused() {
1538        let tmp = tempfile::tempdir().expect("tempdir");
1539        let (path, lines) = three_fact_transcript(tmp.path());
1540        rewrite(&path, lines.get(1..).expect("facts"));
1541        let err = verify_transcript(&path).expect_err("no header");
1542        assert!(matches!(err, VerifyError::MissingHeader(_)));
1543    }
1544
1545    use rite_model::StepId;
1546
1547    fn seeded_fact(m: &[u8]) -> StepFact {
1548        StepFact::EntropySeeded {
1549            m: base16ct::lower::encode_string(m),
1550            source: "os".to_string(),
1551            derivation: crate::entropy::DERIVATION_V1.to_string(),
1552        }
1553    }
1554
1555    fn drawn_fact(seed: &[u8; 32], step: &str, path: &str, len: usize) -> StepFact {
1556        StepFact::EntropyDrawn {
1557            step: StepId::new(step),
1558            path: path.to_string(),
1559            value: base16ct::lower::encode_string(&crate::entropy::derive_value(seed, path, len)),
1560        }
1561    }
1562
1563    #[test]
1564    fn verify_entropy_re_derives_a_clean_draw() {
1565        let m = b"machine entropy bytes";
1566        let seed = crate::entropy::initial_seed(m);
1567        let facts = [
1568            seeded_fact(m),
1569            drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
1570        ];
1571        let v = verify_entropy(facts.iter()).expect("verify");
1572        assert_eq!(v.values_verified, 1);
1573        assert_eq!(v.contributions, 0);
1574        assert_eq!(v.derivation.as_deref(), Some("rite-kdf/v1"));
1575    }
1576
1577    #[test]
1578    fn verify_entropy_follows_the_ratchet_through_a_contribution() {
1579        let m = b"machine entropy bytes";
1580        let seed0 = crate::entropy::initial_seed(m);
1581        let seed1 = crate::entropy::fold_seed(&seed0, b"3 1 6 4 2 5");
1582        let facts = [
1583            seeded_fact(m),
1584            StepFact::EntropyContributed {
1585                step: StepId::new("roll"),
1586                epoch: 1,
1587                contribution: "3 1 6 4 2 5".to_string(),
1588            },
1589            // Drawn after the fold, so it must derive from the epoch-1 seed.
1590            drawn_fact(&seed1, "issue", "1/issue/cert-serial", 9),
1591        ];
1592        let v = verify_entropy(facts.iter()).expect("verify");
1593        assert_eq!(v.contributions, 1);
1594        assert_eq!(v.values_verified, 1);
1595    }
1596
1597    #[test]
1598    fn verify_entropy_detects_a_tampered_value() {
1599        let m = b"machine entropy bytes";
1600        let seed = crate::entropy::initial_seed(m);
1601        let mut drawn = drawn_fact(&seed, "issue", "0/issue/cert-serial", 9);
1602        if let StepFact::EntropyDrawn { value, .. } = &mut drawn {
1603            *value = "deadbeefdeadbeefdead".to_string();
1604        }
1605        let facts = [seeded_fact(m), drawn];
1606        let err = verify_entropy(facts.iter()).expect_err("tamper");
1607        assert!(matches!(err, VerifyError::DrawMismatch { .. }));
1608    }
1609
1610    #[test]
1611    fn verify_entropy_rejects_unknown_derivation() {
1612        let facts = [StepFact::EntropySeeded {
1613            m: "00".to_string(),
1614            source: "os".to_string(),
1615            derivation: "rite-kdf/v99".to_string(),
1616        }];
1617        let err = verify_entropy(facts.iter()).expect_err("unknown scheme");
1618        assert!(matches!(err, VerifyError::UnknownDerivation(_)));
1619    }
1620
1621    #[test]
1622    fn verify_entropy_rejects_draw_before_seed() {
1623        let seed = crate::entropy::initial_seed(b"x");
1624        let facts = [drawn_fact(&seed, "issue", "0/issue/cert-serial", 9)];
1625        let err = verify_entropy(facts.iter()).expect_err("unseeded");
1626        assert!(matches!(err, VerifyError::SeedMissing));
1627    }
1628
1629    #[test]
1630    fn verify_entropy_surfaces_the_seed_source() {
1631        let m = b"machine entropy bytes";
1632        let v = verify_entropy([seeded_fact(m)].iter()).expect("verify");
1633        assert_eq!(v.source.as_deref(), Some("os"));
1634    }
1635
1636    #[test]
1637    fn verify_entropy_rejects_a_second_seed() {
1638        // A mid-stream re-seed would let a forged transcript swap the seed
1639        // under later draws; the replay refuses rather than resetting.
1640        let facts = [seeded_fact(b"first"), seeded_fact(b"second")];
1641        let err = verify_entropy(facts.iter()).expect_err("re-seed");
1642        assert!(matches!(err, VerifyError::DuplicateSeed));
1643    }
1644
1645    #[test]
1646    fn verify_entropy_rejects_an_oversized_draw_without_panicking() {
1647        // 8161 bytes is one past the HKDF-Expand limit (255 * 32). Re-deriving
1648        // it would panic inside `expand`; the verifier must reject it as a
1649        // malformed record instead of aborting.
1650        let m = b"machine entropy bytes";
1651        let oversized = "ab".repeat(crate::entropy::MAX_DRAW_LEN.saturating_add(1));
1652        let facts = [
1653            seeded_fact(m),
1654            StepFact::EntropyDrawn {
1655                step: StepId::new("issue"),
1656                path: "0/issue/cert-serial".to_string(),
1657                value: oversized,
1658            },
1659        ];
1660        let err = verify_entropy(facts.iter()).expect_err("oversized draw");
1661        assert!(matches!(err, VerifyError::DrawTooLong { len, .. } if len == 8161));
1662    }
1663
1664    #[test]
1665    fn verify_entropy_rejects_a_draw_path_with_the_wrong_epoch() {
1666        // No contribution was folded, so the draw must come from epoch 0; a
1667        // recorded epoch-1 path is a stream the runtime could not produce.
1668        let m = b"machine entropy bytes";
1669        let seed = crate::entropy::initial_seed(m);
1670        let facts = [
1671            seeded_fact(m),
1672            drawn_fact(&seed, "issue", "1/issue/cert-serial", 9),
1673        ];
1674        let err = verify_entropy(facts.iter()).expect_err("wrong epoch");
1675        assert!(matches!(err, VerifyError::PathMismatch { .. }));
1676    }
1677
1678    #[test]
1679    fn verify_entropy_rejects_a_draw_path_with_the_wrong_step() {
1680        let m = b"machine entropy bytes";
1681        let seed = crate::entropy::initial_seed(m);
1682        let facts = [
1683            seeded_fact(m),
1684            // Path names step `other` but the fact was recorded under `issue`.
1685            drawn_fact(&seed, "issue", "0/other/cert-serial", 9),
1686        ];
1687        let err = verify_entropy(facts.iter()).expect_err("wrong step");
1688        assert!(matches!(err, VerifyError::PathMismatch { .. }));
1689    }
1690
1691    #[test]
1692    fn verify_entropy_rejects_an_empty_purpose() {
1693        let m = b"machine entropy bytes";
1694        let seed = crate::entropy::initial_seed(m);
1695        let facts = [seeded_fact(m), drawn_fact(&seed, "issue", "0/issue/", 9)];
1696        let err = verify_entropy(facts.iter()).expect_err("empty purpose");
1697        assert!(matches!(err, VerifyError::PathMismatch { .. }));
1698    }
1699
1700    #[test]
1701    fn verify_entropy_rejects_a_contribution_epoch_out_of_sequence() {
1702        let m = b"machine entropy bytes";
1703        let facts = [
1704            seeded_fact(m),
1705            StepFact::EntropyContributed {
1706                step: StepId::new("roll"),
1707                // First fold must record epoch 1.
1708                epoch: 2,
1709                contribution: "3 1 6 4 2 5".to_string(),
1710            },
1711        ];
1712        let err = verify_entropy(facts.iter()).expect_err("epoch skip");
1713        assert!(matches!(
1714            err,
1715            VerifyError::EpochMismatch {
1716                recorded: 2,
1717                expected: 1,
1718                ..
1719            }
1720        ));
1721    }
1722
1723    #[test]
1724    fn verify_entropy_rejects_a_duplicate_draw_path() {
1725        // The runtime never reuses a path; a repeat means the record was
1726        // edited or replayed.
1727        let m = b"machine entropy bytes";
1728        let seed = crate::entropy::initial_seed(m);
1729        let facts = [
1730            seeded_fact(m),
1731            drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
1732            drawn_fact(&seed, "issue", "0/issue/cert-serial", 9),
1733        ];
1734        let err = verify_entropy(facts.iter()).expect_err("duplicate path");
1735        assert!(matches!(err, VerifyError::DuplicateDrawPath { .. }));
1736    }
1737}