use std::sync::Arc;
use crate::core::{Digest, RunId, StoreError, Verifier, merkle};
use crate::journal::{Checkpoint, JournalStore, Record};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AuditReport {
pub current: Checkpoint,
pub sound: Vec<RunId>,
#[serde(serialize_with = "as_sentences")]
pub findings: Vec<Finding>,
pub not_checked: Vec<String>,
pub releases: Vec<ReleaseRecord>,
pub warrants: Vec<Warrant>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct Warrant {
pub run: RunId,
pub declaration: Option<crate::journal::AgentIdentity>,
pub policy: Option<crate::core::PolicyBundleIdentity>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ReleaseRecord {
pub run: RunId,
pub releaser: String,
pub basis: String,
pub destination: String,
pub fields: Vec<String>,
pub evidence: Vec<String>,
pub value: Digest,
}
impl AuditReport {
#[must_use]
pub fn is_sound(&self) -> bool {
self.findings.is_empty()
}
pub fn assert_complete(&self) {
assert!(
self.findings.is_empty(),
"the audit found {} problem(s):\n{}",
self.findings.len(),
self.findings
.iter()
.map(|f| format!(" • {f}"))
.collect::<Vec<_>>()
.join("\n")
);
assert!(
self.not_checked.is_empty(),
"the audit passed but could not check everything:\n{}",
self.not_checked
.iter()
.map(|s| format!(" • {s}"))
.collect::<Vec<_>>()
.join("\n")
);
}
}
fn as_sentences<S: serde::Serializer>(f: &[Finding], s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = s.serialize_seq(Some(f.len()))?;
for finding in f {
seq.serialize_element(&finding.to_string())?;
}
seq.end()
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Finding {
#[error("run {run}: {detail}")]
Chain { run: RunId, detail: String },
#[error("run {run} is sealed but is not in the log — no checkpoint covers it")]
NotInLog { run: RunId },
#[error("run {run} claims a position the log's own root does not support")]
BadInclusion { run: RunId },
#[error(
"run {run}: the log's leaf is not the verified chain's head — records were \
removed or replaced after sealing, and the served history is not the one \
the checkpoint commits to"
)]
LeafMismatch { run: RunId },
#[error(
"run {run}: the sealing record claims a chain head that is not the head it \
sits on — the conclusion was drawn over a different history"
)]
SealClaim { run: RunId },
#[error(
"run {run} is sealed but group '{group}' was opened and never settled — \
nothing may resume a sealed run, so whether the group's members were \
taken or taken back is permanently undecided"
)]
GroupUnsettled { run: RunId, group: String },
#[error(
"the log cannot prove it only grew since the checkpoint of size {old_size} — \
something committed to earlier is no longer committed to now"
)]
NotAppendOnly { old_size: u64 },
#[error("the prior checkpoint names log '{theirs}', this store is '{ours}'")]
WrongLog { theirs: String, ours: String },
#[error(
"the prior checkpoint is larger ({old_size}) than this log ({now}) — a log \
cannot shrink, so runs were removed or this is a different plane"
)]
Shrunk { old_size: u64, now: u64 },
}
#[derive(Default)]
pub struct Evidence<'a> {
pub prior: Option<&'a Checkpoint>,
pub verifier: Option<&'a dyn Verifier>,
pub require_signatures: bool,
}
impl std::fmt::Debug for Evidence<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Evidence")
.field("prior", &self.prior)
.field("verifier", &self.verifier.is_some())
.field("require_signatures", &self.require_signatures)
.finish()
}
}
enum Placement {
Sound,
Open,
NotInLog,
LeafMismatch,
BadInclusion,
Unpinned,
}
async fn placement(
store: &Arc<dyn JournalStore>,
run: RunId,
records: &[Record],
head: Digest,
current: &mut Checkpoint,
) -> Result<Placement, StoreError> {
let Some(inc) = store.inclusion_proof(run).await? else {
return Ok(if has_sealing_conclusion(records) {
Placement::NotInLog
} else {
Placement::Open
});
};
if inc.seal != head {
return Ok(Placement::LeafMismatch);
}
if inc.size != current.size {
*current = store.checkpoint().await?;
}
if inc.size != current.size {
return Ok(Placement::Unpinned);
}
let leaf = merkle::leaf_hash(&inc.seal);
let ok = merkle::verify_inclusion(
leaf,
usize::try_from(inc.index).unwrap_or(usize::MAX),
usize::try_from(inc.size).unwrap_or(0),
&inc.proof,
¤t.root,
);
Ok(if ok {
Placement::Sound
} else {
Placement::BadInclusion
})
}
fn has_sealing_conclusion(records: &[Record]) -> bool {
records
.iter()
.rev()
.find_map(|r| match r.kind() {
crate::journal::RecordKind::RunConcluded { outcome, .. } => Some(outcome.as_str()),
_ => None,
})
.is_some_and(|o| crate::runtime::SEALED_OUTCOMES.contains(&o))
}
fn unsettled_groups(records: &[Record]) -> Vec<String> {
use std::collections::BTreeMap;
type Key<'a> = (Option<crate::core::StepId>, crate::core::Phase, &'a str);
let mut open: BTreeMap<Key<'_>, u64> = BTreeMap::new();
let mut order: Vec<Key<'_>> = Vec::new();
for r in records {
match r.kind() {
crate::journal::RecordKind::GroupOpened { group, .. } => {
let key = (r.body.step, r.body.phase, group.as_str());
*open.entry(key).or_insert(0) += 1;
order.push(key);
}
crate::journal::RecordKind::GroupSettled { group, .. } => {
if let Some(n) = open.get_mut(&(r.body.step, r.body.phase, group.as_str())) {
*n = n.saturating_sub(1);
}
}
_ => {}
}
}
let mut out = Vec::new();
for key in order.into_iter().rev() {
if let Some(n) = open.get_mut(&key)
&& *n > 0
{
*n -= 1;
out.push(key.2.to_owned());
}
}
out.reverse();
out
}
fn releases_in(run: RunId, records: &[Record]) -> Vec<ReleaseRecord> {
records
.iter()
.filter_map(|record| match record.kind() {
crate::journal::RecordKind::Released {
releaser,
release,
value,
..
} => Some(ReleaseRecord {
run,
releaser: releaser.clone(),
basis: release.basis().to_owned(),
destination: release.destination().to_owned(),
fields: release.fields_scope().iter().cloned().collect(),
evidence: release.evidence().iter().cloned().collect(),
value: *value,
}),
_ => None,
})
.collect()
}
fn warrant_in(run: RunId, records: &[Record]) -> Option<Warrant> {
records.iter().find_map(|record| match record.kind() {
crate::journal::RecordKind::RunAdmitted {
governed_by,
policy_bundle,
..
} => Some(Warrant {
run,
declaration: governed_by.as_deref().cloned(),
policy: policy_bundle.as_deref().cloned(),
}),
_ => None,
})
}
fn missing_evidence(evidence: &Evidence<'_>) -> Vec<String> {
let mut out = Vec::new();
if evidence.verifier.is_none() {
out.push(
"signatures — no public key was supplied, so this audit cannot say who \
wrote anything"
.to_owned(),
);
}
if evidence.prior.is_none() {
out.push(
"deletion — no earlier checkpoint was supplied, so this audit cannot \
detect a run that was removed. Every check below passes over a store \
somebody emptied"
.to_owned(),
);
}
out
}
fn seal_claim_holds(records: &[Record]) -> bool {
records
.iter()
.rev()
.find_map(|r| match r.kind() {
crate::journal::RecordKind::RunConcluded { chain_head, .. } => {
Some(*chain_head == r.prev_hash)
}
_ => None,
})
.unwrap_or(true)
}
async fn check_append_only(
store: &Arc<dyn JournalStore>,
prior: &Checkpoint,
current: &mut Checkpoint,
findings: &mut Vec<Finding>,
not_checked: &mut Vec<String>,
) -> Result<(), StoreError> {
if prior.origin != current.origin {
findings.push(Finding::WrongLog {
theirs: prior.origin.clone(),
ours: current.origin.clone(),
});
return Ok(());
}
if prior.size > current.size {
findings.push(Finding::Shrunk {
old_size: prior.size,
now: current.size,
});
return Ok(());
}
let mut proof = store.consistency_proof(prior.size).await?;
let mut latest = store.checkpoint().await?;
if latest.size != current.size {
*current = latest;
proof = store.consistency_proof(prior.size).await?;
latest = store.checkpoint().await?;
}
if latest.size == current.size {
let ok = merkle::verify_consistency(
usize::try_from(prior.size).unwrap_or(0),
&prior.root,
usize::try_from(current.size).unwrap_or(0),
¤t.root,
&proof,
);
if !ok {
findings.push(Finding::NotAppendOnly {
old_size: prior.size,
});
}
} else {
*current = latest;
not_checked.push(
"append-only consistency: the log grew throughout the audit, so the proof \
could not be pinned to one checkpoint — re-run against a quiesced store"
.to_owned(),
);
}
Ok(())
}
pub async fn audit(
store: &Arc<dyn JournalStore>,
runs: &[RunId],
evidence: &Evidence<'_>,
) -> Result<AuditReport, StoreError> {
let mut current = store.checkpoint().await?;
let mut findings = Vec::new();
let mut not_checked = missing_evidence(evidence);
let mut sound = Vec::new();
let mut releases = Vec::new();
let mut warrants = Vec::new();
let mut open_runs = 0usize;
for &run in runs {
let records = store.read(run, 1).await?;
if records.is_empty() {
not_checked.push(format!(
"run {run}: the store returned no records, so nothing about it was \
verified — an empty history holds every check vacuously, which is a \
different statement from sound"
));
continue;
}
let chain = match evidence.verifier {
Some(v) => {
Record::verify_attested(&records, Digest::ZERO, v, evidence.require_signatures)
}
None => Record::verify_chain(&records, Digest::ZERO),
};
let head = match chain {
Ok(head) => head,
Err(e) => {
findings.push(Finding::Chain {
run,
detail: e.to_string(),
});
continue;
}
};
if !seal_claim_holds(&records) {
findings.push(Finding::SealClaim { run });
continue;
}
if has_sealing_conclusion(&records) {
let mut undecided = false;
for group in unsettled_groups(&records) {
undecided = true;
findings.push(Finding::GroupUnsettled { run, group });
}
if undecided {
continue;
}
}
releases.extend(releases_in(run, &records));
warrants.extend(warrant_in(run, &records));
match placement(store, run, &records, head, &mut current).await? {
Placement::Sound => sound.push(run),
Placement::Open => {
open_runs += 1;
sound.push(run);
}
Placement::NotInLog => findings.push(Finding::NotInLog { run }),
Placement::LeafMismatch => findings.push(Finding::LeafMismatch { run }),
Placement::BadInclusion => findings.push(Finding::BadInclusion { run }),
Placement::Unpinned => not_checked.push(format!(
"run {run}: the log grew throughout the audit, so this run's inclusion \
could not be pinned to one checkpoint — re-run against a quiesced store"
)),
}
}
if open_runs > 0 {
not_checked.push(format!(
"{open_runs} open run(s): an open run has no Merkle leaf, so nothing pins its \
tail — chain and signatures verified, and a truncated tail is undetectable \
until the run seals"
));
}
let examined = u64::try_from(runs.len()).unwrap_or(u64::MAX);
if examined < current.size {
not_checked.push(format!(
"scope — this audit examined {examined} named run(s) and the log commits to \
{} sealed run(s); the remainder was not looked at, and a clean report speaks \
only for the runs it names",
current.size
));
}
if let Some(prior) = evidence.prior {
check_append_only(store, prior, &mut current, &mut findings, &mut not_checked).await?;
}
Ok(AuditReport {
current,
sound,
findings,
not_checked,
releases,
warrants,
})
}