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 ¤t.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 ¤t.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}