Skip to main content

agentplane/
audit.rs

1//! Checking a plane's history without trusting the plane.
2//!
3//! # Why this is a deliverable and not a property
4//!
5//! Every mechanism underneath — the hash chain, per-record signatures, the
6//! Merkle log — is only *checkable*. Somebody has to actually check it, and if
7//! the only code that can is inside the runtime being audited, the claim
8//! collapses: the party under examination is also the party running the
9//! examination.
10//!
11//! So this module is deliberately shaped to run **against a store it did not
12//! write**, with inputs an auditor holds rather than inputs the plane supplies:
13//!
14//! * a **prior checkpoint** they were given earlier — the one artifact that has
15//!   to have left the operator's control;
16//! * a **public key**, if they were told which workload should have signed.
17//!
18//! Neither is required, and what can be concluded shrinks accordingly. That
19//! shrinkage is reported rather than hidden, because an audit that says "fine"
20//! when it checked three things out of five is worse than one that checked
21//! nothing.
22//!
23//! # What each input buys
24//!
25//! | Given | Answers |
26//! |---|---|
27//! | nothing | Is each run's chain internally consistent? |
28//! | a public key | Who wrote each record? |
29//! | a prior checkpoint | Has anything been **removed** since it was issued? |
30//!
31//! Only the third detects deletion, and only because the checkpoint came from
32//! outside. That is the whole architecture of the thing in one row.
33
34use std::sync::Arc;
35
36use crate::core::{Digest, RunId, StoreError, Verifier, merkle};
37use crate::journal::{Checkpoint, JournalStore, Record};
38
39/// What an audit concluded, and what it could not look at.
40///
41/// `Serialize` is deliberate and load-bearing: the independent party this
42/// report exists for should not have to link this crate to read it. The
43/// findings render as the sentences they display as, because an auditor reads
44/// prose and a machine that wants structure has the run ids beside it.
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
46pub struct AuditReport {
47    /// The checkpoint the store reports now.
48    pub current: Checkpoint,
49    /// Runs whose chain, signatures and inclusion all checked out.
50    ///
51    /// An **open** run — one whose last conclusion does not seal, or which has
52    /// no conclusion yet — appears here on chain and signatures alone: it has
53    /// no Merkle leaf, so there is no inclusion to check, and [`not_checked`]
54    /// says so once rather than a finding saying it per run. A run whose own
55    /// records carry a *sealing* conclusion but which the log holds no leaf
56    /// for is the opposite case, and that one is a finding.
57    ///
58    /// [`not_checked`]: Self::not_checked
59    pub sound: Vec<RunId>,
60    /// What went wrong, in the order found.
61    ///
62    /// Serialised as the rendered sentence rather than as a tagged variant: the
63    /// consumer is a person or a SIEM, and a variant name is this crate's
64    /// internal vocabulary. The run id each finding names is in the text.
65    #[serde(serialize_with = "as_sentences")]
66    pub findings: Vec<Finding>,
67    /// Checks that were not performed, and why.
68    ///
69    /// Reported as loudly as failures. An audit that quietly skipped signature
70    /// verification because no key was supplied, and then said "verified", is
71    /// exactly the reassuring-but-empty artifact this crate exists to avoid.
72    pub not_checked: Vec<String>,
73    /// Every point at which a label was raised, in the order found.
74    ///
75    /// Not a finding — a release is a legitimate, authorized decision, and
76    /// flagging it as a problem would train a reader to ignore the list. It is
77    /// reported because it is the **only discretionary act in the system**: the
78    /// chain, the signatures and the inclusion proofs all verify that history is
79    /// intact, and none of them surfaces the moment somebody decided untrusted
80    /// data could be treated as trusted. An auditor verifying integrity while
81    /// never seeing that is checking the envelope and not the letter.
82    ///
83    /// Each entry answers the questions the decision was required to record:
84    /// who, on what basis, toward what destination, over which fields, on what
85    /// evidence.
86    pub releases: Vec<ReleaseRecord>,
87    /// What authorized each run: the declaration it ran under, and the policy
88    /// bundle that governed it.
89    ///
90    /// Not a finding, for the same reason `releases` is not: an authorized run
91    /// is the ordinary case. It is reported because **an audit that verifies
92    /// history is intact and never says what warranted it has checked the
93    /// letter and not the warrant** — the mirror of the argument one field up.
94    ///
95    /// The load-bearing entry is the one where `policy` is `None`. A run that
96    /// executed with **no policy engine configured at all** verifies exactly as
97    /// soundly as a governed one: its chain is intact, its signatures check, its
98    /// leaf is included. Nothing in an integrity report distinguishes them, so
99    /// an auditor reading `sound` would conclude a run was governed when the
100    /// deployment had no gate wired. *Was policy switched on for this run* is an
101    /// audit question the journal answers and this report did not surface.
102    ///
103    /// The digest is what makes the declaration half meaningful: a name and
104    /// version identify a file that may since have been edited, and only the
105    /// digest pins what it actually said — the system prompt included.
106    pub warrants: Vec<Warrant>,
107}
108
109/// What authorized one run.
110#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
111pub struct Warrant {
112    /// The run this describes.
113    pub run: RunId,
114    /// The agent declaration that governed it, if one did.
115    ///
116    /// `None` for a run started from code rather than from a manifest, which is
117    /// a legitimate shape and a different one from a manifest-governed run.
118    pub declaration: Option<crate::journal::AgentIdentity>,
119    /// The complete policy bundle that governed it.
120    ///
121    /// `None` means **no engine was configured**. That is the entry an auditor
122    /// most needs and the one an integrity-only report cannot show.
123    pub policy: Option<crate::core::PolicyBundleIdentity>,
124}
125
126/// One journaled decision to improve a label.
127#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
128pub struct ReleaseRecord {
129    pub run: RunId,
130    /// The agent or operator the decision was recorded against.
131    pub releaser: String,
132    /// Why, in the releaser's own words.
133    pub basis: String,
134    /// Where the value was being released *to*. A release is always toward
135    /// something; one with no destination is a permission with no boundary.
136    pub destination: String,
137    /// Which fields moved — `""` for the whole value.
138    pub fields: Vec<String>,
139    /// What was cited. An empty set is impossible: `Release::validate` refuses
140    /// it, and this being non-empty is that rule observed from the outside.
141    pub evidence: Vec<String>,
142    /// The digest of the value that was released, so a reader can tie the
143    /// decision to the bytes rather than to a description of them.
144    pub value: Digest,
145}
146
147impl AuditReport {
148    /// Whether every check that ran, passed.
149    ///
150    /// Note the qualifier. A report with findings is a failure; a report with
151    /// *no* findings and a long `not_checked` is not a pass, and callers are
152    /// expected to look. [`Self::assert_complete`] is the strict form.
153    #[must_use]
154    pub fn is_sound(&self) -> bool {
155        self.findings.is_empty()
156    }
157
158    /// Panic unless everything passed **and** everything was checkable.
159    ///
160    /// # Panics
161    ///
162    /// If anything failed, or if any check was skipped.
163    pub fn assert_complete(&self) {
164        assert!(
165            self.findings.is_empty(),
166            "the audit found {} problem(s):\n{}",
167            self.findings.len(),
168            self.findings
169                .iter()
170                .map(|f| format!("  • {f}"))
171                .collect::<Vec<_>>()
172                .join("\n")
173        );
174        assert!(
175            self.not_checked.is_empty(),
176            "the audit passed but could not check everything:\n{}",
177            self.not_checked
178                .iter()
179                .map(|s| format!("  • {s}"))
180                .collect::<Vec<_>>()
181                .join("\n")
182        );
183    }
184}
185
186/// Render findings as the sentences they display as.
187fn as_sentences<S: serde::Serializer>(f: &[Finding], s: S) -> Result<S::Ok, S::Error> {
188    use serde::ser::SerializeSeq;
189    let mut seq = s.serialize_seq(Some(f.len()))?;
190    for finding in f {
191        seq.serialize_element(&finding.to_string())?;
192    }
193    seq.end()
194}
195
196/// One thing wrong with a plane's history.
197#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
198pub enum Finding {
199    #[error("run {run}: {detail}")]
200    Chain { run: RunId, detail: String },
201
202    #[error("run {run} is sealed but is not in the log — no checkpoint covers it")]
203    NotInLog { run: RunId },
204
205    #[error("run {run} claims a position the log's own root does not support")]
206    BadInclusion { run: RunId },
207
208    /// The chain the store *served* is not the chain the log committed to.
209    ///
210    /// The one that catches a truncated-but-internally-consistent record set:
211    /// a prefix of a chain verifies on its own, and the leaf the log holds is
212    /// the terminal hash of the *whole* run — so an audit that verified the
213    /// records and then checked the store-supplied leaf against the tree,
214    /// without ever holding the two to each other, was verifying two halves of
215    /// two different claims.
216    #[error(
217        "run {run}: the log's leaf is not the verified chain's head — records were \
218         removed or replaced after sealing, and the served history is not the one \
219         the checkpoint commits to"
220    )]
221    LeafMismatch { run: RunId },
222
223    /// The sealing record's own claim disagrees with the chain it sits in.
224    ///
225    /// `RunSealed.chain_head` is the head the conclusion was drawn over — by
226    /// construction, the record's own `prev_hash`. A mismatch means the
227    /// conclusion was composed against a different history than the one it was
228    /// appended to, which no honest writer produces.
229    #[error(
230        "run {run}: the sealing record claims a chain head that is not the head it \
231         sits on — the conclusion was drawn over a different history"
232    )]
233    SealClaim { run: RunId },
234
235    /// The one that needs an outside artifact.
236    #[error(
237        "the log cannot prove it only grew since the checkpoint of size {old_size} — \
238         something committed to earlier is no longer committed to now"
239    )]
240    NotAppendOnly { old_size: u64 },
241
242    #[error("the prior checkpoint names log '{theirs}', this store is '{ours}'")]
243    WrongLog { theirs: String, ours: String },
244
245    #[error(
246        "the prior checkpoint is larger ({old_size}) than this log ({now}) — a log \
247         cannot shrink, so runs were removed or this is a different plane"
248    )]
249    Shrunk { old_size: u64, now: u64 },
250}
251
252/// What an auditor brought with them.
253///
254/// Hand-written `Debug` because a `Verifier` is a trait object with no useful
255/// rendering, and deriving would demand one.
256#[derive(Default)]
257pub struct Evidence<'a> {
258    /// A checkpoint issued earlier, from outside this store.
259    pub prior: Option<&'a Checkpoint>,
260    /// The key the records should carry.
261    pub verifier: Option<&'a dyn Verifier>,
262    /// Whether an unsigned record is a failure.
263    ///
264    /// Off by default: history written before signing was configured is
265    /// legitimately unsigned, and an auditor who does not know that would read a
266    /// wall of failures for a plane that is fine.
267    pub require_signatures: bool,
268}
269
270impl std::fmt::Debug for Evidence<'_> {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.debug_struct("Evidence")
273            .field("prior", &self.prior)
274            .field("verifier", &self.verifier.is_some())
275            .field("require_signatures", &self.require_signatures)
276            .finish()
277    }
278}
279
280/// Where one run stands relative to the Merkle log.
281enum Placement {
282    /// In the log, at a position the root supports.
283    Sound,
284    /// Not in the log because it has not concluded with a sealing outcome —
285    /// a state, not a defect. Chain and signatures were verified upstream.
286    Open,
287    /// The run's own records carry a sealing conclusion, and the log holds no
288    /// leaf for it: history the log no longer commits to.
289    NotInLog,
290    /// The log holds a leaf that is not the verified chain's head: the served
291    /// records are not the history the log committed to.
292    LeafMismatch,
293    /// In the log by its own claim, at a position the root does not support.
294    BadInclusion,
295    /// The log grew faster than the audit could catch its checkpoint up, so
296    /// nothing ties this run's proof to one root. Honest, and rare: it takes a
297    /// seal landing between two adjacent store calls, twice.
298    Unpinned,
299}
300
301/// Decide one run's [`Placement`], catching `current` up if the log grew.
302///
303/// The catch-up is the part that earns a comment: the log can grow while the
304/// audit walks it, and a proof computed against a larger tree than the
305/// checkpoint in hand fails for a reason that is time, not tampering — a false
306/// integrity alarm teaches the reader to ignore the true one. One refresh
307/// covers the realistic race; a plane sealing continuously lands in
308/// [`Placement::Unpinned`] rather than in a finding.
309async fn placement(
310    store: &Arc<dyn JournalStore>,
311    run: RunId,
312    records: &[Record],
313    head: Digest,
314    current: &mut Checkpoint,
315) -> Result<Placement, StoreError> {
316    let Some(inc) = store.inclusion_proof(run).await? else {
317        // The store holds no leaf, and whether that is a finding is decided by
318        // the run's own records rather than assumed. An **open** run — failed,
319        // exhausted, still executing — was never in the log; reporting it as a
320        // defect would flag every healthy resumable run and teach the reader
321        // to skim past the flag that matters. The outcome list is the
322        // library's own, not a re-spelling of it.
323        let sealed_conclusion = records
324            .iter()
325            .rev()
326            .find_map(|r| match r.kind() {
327                crate::journal::RecordKind::RunSealed { outcome, .. } => Some(outcome.as_str()),
328                _ => None,
329            })
330            .is_some_and(|o| crate::runtime::SEALED_OUTCOMES.contains(&o));
331        return Ok(if sealed_conclusion {
332            Placement::NotInLog
333        } else {
334            Placement::Open
335        });
336    };
337
338    // The leaf must be the verified chain's own head, checked **before** the
339    // tree math and independent of any checkpoint race. `inc.seal` is
340    // store-supplied; the head was recomputed from the served bytes. Verifying
341    // each without holding them to each other let a store serve a truncated —
342    // but internally consistent — prefix of a sealed run and have both halves
343    // pass: the prefix's chain verifies, and the log's genuine leaf verifies
344    // against the genuine tree.
345    if inc.seal != head {
346        return Ok(Placement::LeafMismatch);
347    }
348
349    if inc.size != current.size {
350        *current = store.checkpoint().await?;
351    }
352    if inc.size != current.size {
353        return Ok(Placement::Unpinned);
354    }
355    let leaf = merkle::leaf_hash(&inc.seal);
356    let ok = merkle::verify_inclusion(
357        leaf,
358        usize::try_from(inc.index).unwrap_or(usize::MAX),
359        usize::try_from(inc.size).unwrap_or(0),
360        &inc.proof,
361        &current.root,
362    );
363    Ok(if ok {
364        Placement::Sound
365    } else {
366        Placement::BadInclusion
367    })
368}
369
370/// Every label-raising decision in one run's history.
371fn releases_in(run: RunId, records: &[Record]) -> Vec<ReleaseRecord> {
372    records
373        .iter()
374        .filter_map(|record| match record.kind() {
375            crate::journal::RecordKind::Released {
376                releaser,
377                release,
378                value,
379                ..
380            } => Some(ReleaseRecord {
381                run,
382                releaser: releaser.clone(),
383                basis: release.basis().to_owned(),
384                destination: release.destination().to_owned(),
385                fields: release.fields_scope().iter().cloned().collect(),
386                evidence: release.evidence().iter().cloned().collect(),
387                value: *value,
388            }),
389            _ => None,
390        })
391        .collect()
392}
393
394/// What authorized one run, from the record that opened it.
395///
396/// `RunAdmitted` carries both, and carries them once: the declaration because
397/// *which manifest governed this* must be answerable years later, and the bundle
398/// because *was policy switched on* must be too. Reading them here rather than
399/// re-deriving from today's wiring is the whole point — an audit runs against a
400/// store it did not write, on a machine that may have no engine configured at
401/// all.
402fn warrant_in(run: RunId, records: &[Record]) -> Option<Warrant> {
403    records.iter().find_map(|record| match record.kind() {
404        crate::journal::RecordKind::RunAdmitted {
405            governed_by,
406            policy_bundle,
407            ..
408        } => Some(Warrant {
409            run,
410            declaration: governed_by.clone(),
411            policy: policy_bundle.clone(),
412        }),
413        _ => None,
414    })
415}
416
417/// What an audit cannot conclude from the evidence it was given.
418///
419/// Reported up front and as loudly as failures: an audit that quietly skipped
420/// a check it had no inputs for, and then said "verified", is the
421/// reassuring-but-empty artifact this module exists to avoid.
422fn missing_evidence(evidence: &Evidence<'_>) -> Vec<String> {
423    let mut out = Vec::new();
424    if evidence.verifier.is_none() {
425        out.push(
426            "signatures — no public key was supplied, so this audit cannot say who \
427             wrote anything"
428                .to_owned(),
429        );
430    }
431    if evidence.prior.is_none() {
432        out.push(
433            "deletion — no earlier checkpoint was supplied, so this audit cannot \
434             detect a run that was removed. Every check below passes over a store \
435             somebody emptied"
436                .to_owned(),
437        );
438    }
439    out
440}
441
442/// The sealing record's own claim, held to the chain it sits in.
443///
444/// `RunSealed.chain_head` is the head the conclusion was drawn over, which is
445/// by construction its own record's `prev_hash` — checkable only after the
446/// chain has verified, so `prev_hash` is evidence rather than input. A run
447/// with no conclusion has made no claim, and holds vacuously.
448fn seal_claim_holds(records: &[Record]) -> bool {
449    records
450        .iter()
451        .rev()
452        .find_map(|r| match r.kind() {
453            crate::journal::RecordKind::RunSealed { chain_head, .. } => {
454                Some(*chain_head == r.prev_hash)
455            }
456            _ => None,
457        })
458        .unwrap_or(true)
459}
460
461/// The deletion check, against the checkpoint the auditor brought.
462///
463/// The proof must be paired with the checkpoint it is verified against, and
464/// the log can grow between the two store calls — the same race `placement`
465/// refreshes for, handled the same way, because two halves of one audit
466/// reporting one race differently teaches the reader that findings are
467/// weather. A proof computed over a log larger than the checkpoint in hand
468/// fails for a reason that is time, not tampering, and a false `NotAppendOnly`
469/// is the alarm this whole module exists to make believable. One refresh
470/// covers the realistic race; a plane sealing continuously lands in
471/// `not_checked` rather than in a finding.
472///
473/// What this does NOT cover: it decides nothing about tampering on the
474/// unpinned path — a store that really did rewrite history and also keeps
475/// growing is only caught by re-running against a quiesced store, which the
476/// entry says in words.
477async fn check_append_only(
478    store: &Arc<dyn JournalStore>,
479    prior: &Checkpoint,
480    current: &mut Checkpoint,
481    findings: &mut Vec<Finding>,
482    not_checked: &mut Vec<String>,
483) -> Result<(), StoreError> {
484    if prior.origin != current.origin {
485        findings.push(Finding::WrongLog {
486            theirs: prior.origin.clone(),
487            ours: current.origin.clone(),
488        });
489        return Ok(());
490    }
491    if prior.size > current.size {
492        findings.push(Finding::Shrunk {
493            old_size: prior.size,
494            now: current.size,
495        });
496        return Ok(());
497    }
498    let mut proof = store.consistency_proof(prior.size).await?;
499    let mut latest = store.checkpoint().await?;
500    if latest.size != current.size {
501        *current = latest;
502        proof = store.consistency_proof(prior.size).await?;
503        latest = store.checkpoint().await?;
504    }
505    if latest.size == current.size {
506        let ok = merkle::verify_consistency(
507            usize::try_from(prior.size).unwrap_or(0),
508            &prior.root,
509            usize::try_from(current.size).unwrap_or(0),
510            &current.root,
511            &proof,
512        );
513        if !ok {
514            findings.push(Finding::NotAppendOnly {
515                old_size: prior.size,
516            });
517        }
518    } else {
519        *current = latest;
520        not_checked.push(
521            "append-only consistency: the log grew throughout the audit, so the proof \
522             could not be pinned to one checkpoint — re-run against a quiesced store"
523                .to_owned(),
524        );
525    }
526    Ok(())
527}
528
529/// Check a plane's history.
530///
531/// `runs` is what to look at — an auditor sampling, or everything they were
532/// given. The log-level checks do not depend on it.
533///
534/// # Errors
535///
536/// [`StoreError`] only when the store cannot be read at all. A *finding* is a
537/// result, not an error: an audit that stopped at the first problem would report
538/// one defect in a store with forty.
539pub async fn audit(
540    store: &Arc<dyn JournalStore>,
541    runs: &[RunId],
542    evidence: &Evidence<'_>,
543) -> Result<AuditReport, StoreError> {
544    let mut current = store.checkpoint().await?;
545    let mut findings = Vec::new();
546    let mut not_checked = missing_evidence(evidence);
547    let mut sound = Vec::new();
548    let mut releases = Vec::new();
549    let mut warrants = Vec::new();
550    let mut open_runs = 0usize;
551
552    // ── Per run ────────────────────────────────────────────────────────────
553    for &run in runs {
554        let records = store.read(run, 1).await?;
555        // A run the store returns nothing for is unchecked, never sound.
556        // Both backends answer an unknown run with an empty read rather than
557        // an error, and every check downstream holds vacuously over nothing:
558        // `verify_chain(&[])` passes, the seal claim is absent, and a missing
559        // leaf reads as an ordinary open run — so a mistyped or deleted run id
560        // audited as "chain and signatures verified" without one record ever
561        // being looked at. Not a finding either: an unknown id and a run whose
562        // records are genuinely gone cannot be told apart from an empty read,
563        // and deletion is the prior-checkpoint check's question, which answers
564        // it with evidence rather than a guess.
565        if records.is_empty() {
566            not_checked.push(format!(
567                "run {run}: the store returned no records, so nothing about it was \
568                 verified — an empty history holds every check vacuously, which is a \
569                 different statement from sound"
570            ));
571            continue;
572        }
573        let chain = match evidence.verifier {
574            Some(v) => {
575                Record::verify_attested(&records, Digest::ZERO, v, evidence.require_signatures)
576            }
577            None => Record::verify_chain(&records, Digest::ZERO),
578        };
579        // The head is kept, not merely the verdict: it is the one value that
580        // ties the verified bytes to the log's leaf below, and discarding it
581        // was what let the two halves of this audit verify two different
582        // histories.
583        let head = match chain {
584            Ok(head) => head,
585            Err(e) => {
586                findings.push(Finding::Chain {
587                    run,
588                    detail: e.to_string(),
589                });
590                continue;
591            }
592        };
593
594        if !seal_claim_holds(&records) {
595            findings.push(Finding::SealClaim { run });
596            continue;
597        }
598
599        // Collected after the chain verified, so a reader is never shown a
600        // decision — or a warrant — drawn from records whose integrity did not
601        // hold. A forged `RunAdmitted` naming a policy bundle nobody configured
602        // is exactly the claim an auditor must not be handed.
603        releases.extend(releases_in(run, &records));
604        warrants.extend(warrant_in(run, &records));
605
606        match placement(store, run, &records, head, &mut current).await? {
607            Placement::Sound => sound.push(run),
608            Placement::Open => {
609                open_runs += 1;
610                sound.push(run);
611            }
612            Placement::NotInLog => findings.push(Finding::NotInLog { run }),
613            Placement::LeafMismatch => findings.push(Finding::LeafMismatch { run }),
614            Placement::BadInclusion => findings.push(Finding::BadInclusion { run }),
615            Placement::Unpinned => not_checked.push(format!(
616                "run {run}: the log grew throughout the audit, so this run's inclusion \
617                 could not be pinned to one checkpoint — re-run against a quiesced store"
618            )),
619        }
620    }
621
622    // Said once rather than per run, and in `not_checked` rather than as a
623    // finding: an open run's tail has no leaf to pin it, so truncating it is
624    // undetectable until it seals — a limit of what an open run *is*, reported
625    // so a clean audit over open runs is not read as more than it proved.
626    if open_runs > 0 {
627        not_checked.push(format!(
628            "{open_runs} open run(s): an open run has no Merkle leaf, so nothing pins its \
629             tail — chain and signatures verified, and a truncated tail is undetectable \
630             until the run seals"
631        ));
632    }
633
634    // The audit's scope, stated rather than implied. `runs` is whatever the
635    // caller sampled, and the log commits to `current.size` sealed runs — a
636    // clean report over three runs of a three-thousand-run log is a true
637    // statement about three runs, and nothing in the findings list would ever
638    // say so. Stated as unchecked coverage, because that is what it is. What
639    // this does NOT cover: it counts the runs *named*, not the runs verified —
640    // duplicates in the sample, open runs, and runs the store returned nothing
641    // for all inflate the count, so it is an upper bound on coverage and the
642    // per-run entries above are the exact record.
643    let examined = u64::try_from(runs.len()).unwrap_or(u64::MAX);
644    if examined < current.size {
645        not_checked.push(format!(
646            "scope — this audit examined {examined} named run(s) and the log commits to \
647             {} sealed run(s); the remainder was not looked at, and a clean report speaks \
648             only for the runs it names",
649            current.size
650        ));
651    }
652
653    // ── Against what the auditor brought ───────────────────────────────────
654    if let Some(prior) = evidence.prior {
655        check_append_only(store, prior, &mut current, &mut findings, &mut not_checked).await?;
656    }
657
658    Ok(AuditReport {
659        current,
660        sound,
661        findings,
662        not_checked,
663        releases,
664        warrants,
665    })
666}