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 held_to: Vec<Anchor>,
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>,
pub unadmitted: Vec<Unadmitted>,
pub unwitnessed: Vec<UnwitnessedWindow>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct UnwitnessedWindow {
pub key_id: crate::core::KeyId,
pub since: u64,
pub seconds: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Freshness {
pub now: crate::core::Timestamp,
pub max_age: std::time::Duration,
}
#[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 Unadmitted {
pub run: RunId,
pub outcome: Option<String>,
}
#[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(
"run {run} is sealed but effect {effect} (step {step}, {doubt}) never reached a known \
outcome — nothing may resume a sealed run, so whether the call changed the outside \
world is permanently undecided"
)]
EffectUndecided {
run: RunId,
step: crate::core::StepId,
effect: crate::core::EffectKey,
doubt: &'static str,
},
#[error(
"the log cannot prove it only grew since the checkpoint of size {old_size} held by \
{obtained_from} — something committed to earlier is no longer committed to now"
)]
NotAppendOnly {
old_size: u64,
obtained_from: String,
},
#[error("the checkpoint held by {obtained_from} names log '{theirs}', this store is '{ours}'")]
WrongLog {
theirs: String,
ours: String,
obtained_from: String,
},
#[error(
"the checkpoint held by {obtained_from} 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,
obtained_from: String,
},
#[error(
"witness key {key_id} ({obtained_from}) last saw this log at {timestamp}, \
{age_secs}s ago — older than the maximum age, so anything appended and \
removed since is invisible to it"
)]
StaleWitness {
key_id: crate::core::KeyId,
obtained_from: String,
timestamp: u64,
age_secs: u64,
},
#[error(
"witness key {key_id} ({obtained_from}) signed time {timestamp}, {ahead_secs}s \
ahead of this audit's clock — more than the maximum age, so it is not read as fresh"
)]
WitnessTimeAhead {
key_id: crate::core::KeyId,
obtained_from: String,
timestamp: u64,
ahead_secs: u64,
},
}
pub use crate::journal::{Anchor, WitnessTime};
#[derive(Default)]
pub struct Evidence<'a> {
pub anchors: &'a [Anchor],
pub verifier: Option<&'a dyn Verifier>,
pub require_signatures: bool,
pub freshness: Option<Freshness>,
}
impl std::fmt::Debug for Evidence<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Evidence")
.field("anchors", &self.anchors)
.field("verifier", &self.verifier.is_some())
.field("require_signatures", &self.require_signatures)
.field("freshness", &self.freshness)
.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
})
}
pub(crate) fn has_sealing_conclusion(records: &[Record]) -> bool {
concluded_outcome(records).is_some_and(|o| crate::runtime::SEALED_OUTCOMES.contains(&o))
}
fn concluded_outcome(records: &[Record]) -> Option<&str> {
records.iter().rev().find_map(|r| match r.kind() {
crate::journal::RecordKind::RunConcluded { outcome, .. } => Some(outcome.as_str()),
_ => None,
})
}
fn permanently_undecided(run: RunId, records: &[Record]) -> Vec<Finding> {
if !has_sealing_conclusion(records) {
return Vec::new();
}
unsettled_groups(records)
.into_iter()
.map(|group| Finding::GroupUnsettled { run, group })
.chain(
crate::journal::undecided_effects(records)
.into_iter()
.map(|u| Finding::EffectUndecided {
run,
step: u.step,
effect: u.effect,
doubt: u.doubt.as_str(),
}),
)
.collect()
}
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 judge_freshness(
evidence: &Evidence<'_>,
findings: &mut Vec<Finding>,
not_checked: &mut Vec<String>,
) -> Vec<UnwitnessedWindow> {
let mut latest: std::collections::BTreeMap<&str, (u64, &str)> =
std::collections::BTreeMap::default();
for held in evidence.anchors {
for time in &held.witnessed {
let entry = latest
.entry(time.key_id.as_str())
.or_insert((time.timestamp, held.obtained_from.as_str()));
if time.timestamp > entry.0 {
*entry = (time.timestamp, held.obtained_from.as_str());
}
}
}
let Some(freshness) = evidence.freshness else {
if !latest.is_empty() {
not_checked.push(
"freshness — the anchors carry witness times and no maximum age was \
supplied, so how long each witness has gone without seeing this log \
was not judged"
.to_owned(),
);
}
return Vec::new();
};
if latest.is_empty() {
not_checked.push(
"freshness — a maximum age was supplied and no anchor came from a witness, \
so there is no signed time to judge"
.to_owned(),
);
return Vec::new();
}
let now = u64::try_from(freshness.now.unix_timestamp()).unwrap_or(0);
let max_age = freshness.max_age.as_secs();
let mut windows = Vec::new();
for (key_id, (timestamp, obtained_from)) in latest {
let age = now.saturating_sub(timestamp);
let ahead = timestamp.saturating_sub(now);
if ahead > max_age {
findings.push(Finding::WitnessTimeAhead {
key_id: key_id.to_owned(),
obtained_from: obtained_from.to_owned(),
timestamp,
ahead_secs: ahead,
});
} else if age > max_age {
findings.push(Finding::StaleWitness {
key_id: key_id.to_owned(),
obtained_from: obtained_from.to_owned(),
timestamp,
age_secs: age,
});
}
windows.push(UnwitnessedWindow {
key_id: key_id.to_owned(),
since: timestamp,
seconds: age,
});
}
not_checked.push(
"truncation inside the unwitnessed window — a suffix appended and removed after \
each witness key's latest time is undetectable; the window bounds when \
truncation could have happened, not whether"
.to_owned(),
);
windows
}
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.anchors.is_empty() {
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, because the records, the leaves and the root all \
come from the party being audited. Pass the checkpoint an earlier \
audit printed, or the one this plane's witnesses hold"
.to_owned(),
);
} else if evidence.anchors.len() == 1 {
out.push(format!(
"equivocation — one anchor was supplied ({}), so this audit says the store \
extends that observer's history and nothing about whether a second observer \
holds a different one. A fork is visible only from an observer that saw the \
history it diverged from, so bring every checkpoint you can obtain",
evidence.anchors[0].obtained_from
));
}
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>,
anchor: &Anchor,
current: &mut Checkpoint,
findings: &mut Vec<Finding>,
not_checked: &mut Vec<String>,
) -> Result<(), StoreError> {
let prior = &anchor.checkpoint;
if prior.origin != current.origin {
findings.push(Finding::WrongLog {
theirs: prior.origin.clone(),
ours: current.origin.clone(),
obtained_from: anchor.obtained_from.clone(),
});
return Ok(());
}
if prior.size > current.size {
findings.push(Finding::Shrunk {
old_size: prior.size,
now: current.size,
obtained_from: anchor.obtained_from.clone(),
});
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,
obtained_from: anchor.obtained_from.clone(),
});
}
} else {
*current = latest;
not_checked.push(format!(
"append-only consistency against {}: the log grew throughout the audit, so \
the proof could not be pinned to one checkpoint — re-run against a quiesced \
store",
anchor.obtained_from
));
}
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 unadmitted = 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_signed(&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;
}
};
releases.extend(releases_in(run, &records));
match warrant_in(run, &records) {
Some(warrant) => warrants.push(warrant),
None => unadmitted.push(Unadmitted {
run,
outcome: concluded_outcome(&records).map(str::to_owned),
}),
}
let mut faults = Vec::new();
if !seal_claim_holds(&records) {
faults.push(Finding::SealClaim { run });
}
faults.extend(permanently_undecided(run, &records));
match placement(store, run, &records, head, &mut current).await? {
Placement::Sound => {}
Placement::Open => open_runs += 1,
Placement::NotInLog => faults.push(Finding::NotInLog { run }),
Placement::LeafMismatch => faults.push(Finding::LeafMismatch { run }),
Placement::BadInclusion => faults.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 faults.is_empty() {
sound.push(run);
} else {
findings.extend(faults);
}
}
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
));
}
for anchor in evidence.anchors {
check_append_only(store, anchor, &mut current, &mut findings, &mut not_checked).await?;
}
let unwitnessed = judge_freshness(evidence, &mut findings, &mut not_checked);
Ok(AuditReport {
current,
held_to: evidence.anchors.to_vec(),
sound,
findings,
not_checked,
releases,
warrants,
unadmitted,
unwitnessed,
})
}