Files
wanderbooks/crates/ledger-store/tests/audit.rs
T
swaitsandClaude Fable 5.1 6b7dccd1ee feat(audit): audit runs and attestations are journaled (P8)
A claim that someone checked the books is evidence the CPA handoff rests on,
so it is a journal fact and replays: audit.run (a programme run through a
day at the books' figure head, with the hashes that make it reproducible and
the two filed workpapers), receipt.attested, entry.reviewed and
period.attested. Behind the journal feature audits, schema 20; no figure
moves. The figure head is the last line that bears on a figure: filing a
workpaper, a run or an attestation never makes an audit stale, an entry,
document, close, settings or role change does, so a period is attested only
on an audit that still describes the books. A receipt reading that differs
from the entry is recorded as disagreeing and never counts; whether a claim
counts is derived against the bar when asked, never stored.

The store also reads what a ledger cannot: the chain, the database against
a replay, stored and extracted files, the access log, duplicate files, and
the backups' standing handed in by the server. Fixture old-books/p8_before
was written by the version before this one and opens to its figures.
period.attested was the tests' placeholder for an unknown event type; they
use ledger.not_yet_written now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:54:08 -06:00

613 lines
22 KiB
Rust

//! Audits and attestations in the store (plan P8; rules.md B-45): the
//! `audits` feature, the figure head, idempotent runs, attestations that
//! rest on a head, and the integrity findings only the store can see.
use chrono::NaiveDate;
use ledger_core::audit::{Effort, Method, Scope};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::audit::{self, Attested, BackupStanding, Claim, NewAuditRun, backup_findings};
use ledger_store::format::{AUDITS, JournalFormat};
use ledger_store::{BlobStore, JOURNAL_FILE, JournalWriter, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::PathBuf;
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("wb-audit-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(s.parse().unwrap(), Currency::USD)
}
struct Fx {
bank: u64,
capital: u64,
supplies: u64,
/// A posted 40.00 purchase.
entry: u64,
}
/// A bank, capital and an expense account, and a posted 40.00 purchase on
/// the 5th.
fn books(dir: &std::path::Path, format: Option<&JournalFormat>) -> (Store, Fx) {
let mut s = match format {
Some(f) => Store::create_in_format(dir, Currency::USD, f).unwrap(),
None => Store::create(dir, Currency::USD).unwrap(),
};
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let supplies = s
.add_account("6000", "Supplies", AccountType::Expense, None, false)
.unwrap();
let e = s.create_entry(d(1, 1), "Opening").unwrap();
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
s.post_entry(e).unwrap();
let entry = s.create_entry(d(1, 5), "Printer paper").unwrap();
s.add_line(entry, supplies, Side::Debit, usd("40.00"))
.unwrap();
s.add_line(entry, bank, Side::Credit, usd("40.00")).unwrap();
s.post_entry(entry).unwrap();
(
s,
Fx {
bank,
capital,
supplies,
entry,
},
)
}
fn run_of(s: &Store, through: NaiveDate, scope: Scope) -> NewAuditRun {
NewAuditRun {
through,
through_seq: s.figure_head().unwrap(),
scope,
tb_hash: "tb".into(),
findings_sha256: "fs".into(),
workpaper_attachment: None,
workpaper_pdf: None,
}
}
fn claim(effort: &str) -> Claim {
Claim {
by: "verifier-1".into(),
method: Method {
model: Some("opus".into()),
reasoning_effort: Effort::parse(effort),
adversarial: Some(true),
verifier_agents: Some(2),
},
note: None,
}
}
fn journal_bytes(dir: &std::path::Path) -> Vec<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
/// R-A: every write of the feature waits for the owner's upgrade,
/// journaling nothing; afterwards it is taken and the books replay to
/// what was live.
#[test]
fn audit_writes_wait_for_audits() {
let dir = test_dir("waits");
let (mut s, fx) = books(&dir, Some(&JournalFormat::default()));
assert!(!s.journal_format().has_feature(AUDITS));
let (att, _, _) = s
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
.unwrap();
let before = journal_bytes(&dir);
let new = run_of(&s, d(1, 31), Scope::Month);
for r in [
s.record_audit_run(new.clone()).map(|_| ()),
s.attest_receipt(
att,
fx.entry,
"40.00".parse().unwrap(),
d(1, 5),
claim("high"),
)
.map(|_| ()),
s.review_entry(fx.entry, claim("high")).map(|_| ()),
] {
match r {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature audits")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
}
assert_eq!(journal_bytes(&dir), before, "a refusal journals nothing");
s.upgrade_format().unwrap();
let run = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
.unwrap();
s.review_entry(fx.entry, claim("high")).unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.audit_runs().unwrap(), vec![run]);
assert_eq!(s.attestations().unwrap().len(), 1);
}
/// INVARIANT 1 and 2: filing a workpaper and journaling the run do not
/// move the figure head; an entry does; the same audit asked again
/// returns the first run and journals nothing.
#[test]
fn a_run_is_idempotent_on_the_figure_head() {
let dir = test_dir("idem");
let (mut s, fx) = books(&dir, None);
let head = s.figure_head().unwrap();
let (wp, _, _) = s
.file_document("workpaper", "w.json", "application/json", b"{}", None)
.unwrap();
assert_eq!(s.figure_head().unwrap(), head, "a filing is not a figure");
let mut new = run_of(&s, d(1, 31), Scope::Month);
new.workpaper_attachment = Some(wp);
let first = s.record_audit_run(new.clone()).unwrap();
assert_eq!(first.id, 1);
assert_eq!(first.through_seq, head);
assert_eq!(s.figure_head().unwrap(), head, "a run is not a figure");
let bytes = journal_bytes(&dir);
let again = s.record_audit_run(new).unwrap();
assert_eq!(again, first);
assert_eq!(journal_bytes(&dir), bytes, "asking again journals nothing");
assert!(s.audit_is_current(&first).unwrap());
// Another scope is another run.
let other = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Full))
.unwrap();
assert_eq!(other.id, 2);
// An entry moves the head: the run no longer stands.
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
.unwrap();
s.add_line(e, fx.bank, Side::Credit, usd("3.00")).unwrap();
s.post_entry(e).unwrap();
assert!(s.figure_head().unwrap() > head);
assert!(!s.audit_is_current(&first).unwrap());
s.verify().unwrap();
}
/// A receipt reading that differs from the entry's is recorded, marked,
/// and never counts; one that agrees is not marked. The receipt must be on
/// the entry.
#[test]
fn amount_mismatch_never_counts() {
let dir = test_dir("mismatch");
let (mut s, fx) = books(&dir, None);
let (att, _, _) = s
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper", None)
.unwrap();
let other = s
.attach_receipt(None, "x.txt", "text/plain", b"stray", None)
.unwrap()
.0;
let amount = |t: &str| -> Decimal { t.parse().unwrap() };
let disagrees = |a: &ledger_store::audit::Attestation| match a.what {
Attested::Receipt { disagrees, .. } => disagrees,
_ => panic!("not a receipt"),
};
let ok = s
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 5), claim("high"))
.unwrap();
assert!(!disagrees(&ok));
let wrong_amount = s
.attest_receipt(att, fx.entry, amount("41.00"), d(1, 5), claim("high"))
.unwrap();
assert!(disagrees(&wrong_amount));
let wrong_date = s
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 6), claim("high"))
.unwrap();
assert!(disagrees(&wrong_date));
// Refused: a receipt not on the entry, a draft, an unknown entry.
assert!(matches!(
s.attest_receipt(other, fx.entry, amount("40.00"), d(1, 5), claim("high")),
Err(StoreError::AuditRefused(_))
));
let draft = s.create_entry(d(2, 2), "Draft").unwrap();
assert!(matches!(
s.review_entry(draft, claim("high")),
Err(StoreError::AuditRefused(_))
));
assert!(s.review_entry(999, claim("high")).is_err());
// All three kinds replay, in order.
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
let got = s.attestations().unwrap();
assert_eq!(got.len(), 3);
assert!(got.windows(2).all(|w| w[0].seq < w[1].seq));
assert_eq!(got[0].method.reasoning_effort, Some(Effort::High));
assert_eq!(got[0].by, "verifier-1");
}
/// INVARIANT 3: a period is attested only on a run at the current head
/// that covers it; an entry since makes the run stale and the attestation
/// is refused until the audit is run again.
#[test]
fn stale_head_period_attestation_refused() {
let dir = test_dir("stale");
let (mut s, fx) = books(&dir, None);
let jan = s.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
// No audit yet.
assert!(matches!(
s.attest_period(jan, claim("high")),
Err(StoreError::AuditRefused(_))
));
// An audit through before the period's end does not cover it.
s.record_audit_run(run_of(&s, d(1, 15), Scope::Month))
.unwrap();
assert!(matches!(
s.attest_period(jan, claim("high")),
Err(StoreError::AuditRefused(_))
));
let run = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
.unwrap();
let (a, on) = s.attest_period(jan, claim("high")).unwrap();
assert_eq!(on.id, run.id);
assert_eq!(
a.what,
Attested::Period {
period_id: jan,
audit_run_id: run.id
}
);
// An entry since: the run is stale.
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
.unwrap();
s.add_line(e, fx.capital, Side::Credit, usd("3.00"))
.unwrap();
s.post_entry(e).unwrap();
let head = s.head();
match s.attest_period(jan, claim("high")) {
Err(StoreError::AuditRefused(why)) => assert!(why.contains("books.audit"), "{why}"),
other => panic!("expected a refusal, got {other:?}"),
}
assert_eq!(s.head(), head, "a refusal journals nothing");
s.verify().unwrap();
}
/// The integrity table: each code seeded alone.
#[test]
fn integrity_detects_each_seeded_defect() {
// A whole book finds nothing.
{
let dir = test_dir("whole");
let (s, _) = books(&dir, None);
assert_eq!(s.integrity_findings(), vec![]);
}
let seed = |code: &str| -> Vec<&'static str> {
let dir = test_dir(&format!("seed-{code}"));
let (mut s, fx) = books(&dir, None);
let found = match code {
"blob_missing" | "blob_damaged" | "extraction_blob_missing" => {
let (_, sha, _) = s
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
.unwrap();
let target = if code == "extraction_blob_missing" {
// The text the extractor read, content-addressed too.
let x = s.extractions().unwrap().pop().expect("an extraction");
x.text_sha256.expect("text")
} else {
sha
};
let path = dir.join("blobs").join(&target[..2]).join(&target);
let path = if path.exists() {
path
} else {
// The store's own layout.
walk(&dir.join("blobs"), &target).expect("the blob file")
};
if code == "blob_damaged" {
std::fs::write(&path, b"tampered").unwrap();
} else {
std::fs::remove_file(&path).unwrap();
}
s.integrity_findings()
}
"duplicate_blob" => {
drop(s);
let bytes = b"filed twice by an older version";
let (sha, _) = BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": null,
"category": "receipts", "file_name": name,
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
drop(w);
Store::open(&dir).unwrap().integrity_findings()
}
"access_log_damaged" => {
std::fs::write(
dir.join("access.jsonl"),
b"{\"not\": \"a chained record\"}\n",
)
.unwrap();
s.integrity_findings()
}
"projection_differs" => {
let db = ledger_store::database_in_use(&dir);
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute("DELETE FROM lines", []).unwrap();
drop(conn);
s.integrity_findings()
}
"journal_damaged" => {
let path = dir.join(JOURNAL_FILE);
let text = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, text.replacen("Printer paper", "Printer papers", 1)).unwrap();
s.integrity_findings()
}
"backup_stale" => backup_findings(&[BackupStanding {
target: "folder".into(),
stale: true,
last_error: None,
drill_error: None,
}]),
"drill_failed" => backup_findings(&[BackupStanding {
target: "folder".into(),
stale: false,
last_error: None,
drill_error: Some("restore did not reach the head".into()),
}]),
other => panic!("no seed for {other}"),
};
found.iter().map(|f| f.code).collect()
};
for row in audit::CODES {
let got = seed(row.code);
assert!(got.contains(&row.code), "{}: found {got:?}", row.code);
// The seed implies nothing else but its own consequences: a
// missing or damaged file is found once; a damaged journal can
// also differ from its replay.
for c in &got {
assert!(
*c == row.code
|| (row.code == "journal_damaged" && *c == "projection_differs")
// verify reads a file's text too, so a damaged or missing
// stored file is also a replay that cannot be made.
|| (row.code.starts_with("blob_") || row.code == "extraction_blob_missing")
&& [
"blob_missing",
"blob_damaged",
"extraction_blob_missing",
"projection_differs",
]
.contains(c),
"{}: also found {c}",
row.code
);
}
}
// No backups set up is the owner's standing choice, not a finding.
assert!(backup_findings(&[]).is_empty());
}
fn walk(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
for e in std::fs::read_dir(dir).ok()? {
let p = e.ok()?.path();
if p.is_dir() {
if let Some(f) = walk(&p, name) {
return Some(f);
}
} else if p.file_name().is_some_and(|n| n == name) {
return Some(p);
}
}
None
}
/// 8.0.6 item 2: the books the version before P8 wrote
/// (`old-books/p8_before`) open to the same journal, have no audit run or
/// claim, are whole (nothing for the integrity checks to find), refuse
/// the new writes until the owner's upgrade, and take them after it.
#[test]
fn books_the_version_before_p8_wrote_wait_for_the_upgrade() {
let from =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p8_before");
let dir = test_dir("p8-before");
std::fs::create_dir_all(dir.join("blobs")).unwrap();
std::fs::copy(from.join(JOURNAL_FILE), dir.join(JOURNAL_FILE)).unwrap();
fn copy(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap() {
let e = e.unwrap();
if e.file_type().unwrap().is_dir() {
copy(&e.path(), &to.join(e.file_name()));
} else {
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
}
}
}
copy(&from.join("blobs"), &dir.join("blobs"));
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert!(!s.journal_format().has_feature(AUDITS));
assert!(s.audit_runs().unwrap().is_empty());
assert!(s.attestations().unwrap().is_empty());
assert_eq!(s.integrity_findings(), vec![]);
// The head is the last figure line; the filings after it don't move it.
let head = s.figure_head().unwrap();
assert!(head > 0 && head <= s.head().0);
let jan = s.ledger().list_periods()[0].id;
let before = journal_bytes(&dir);
assert!(matches!(
s.record_audit_run(run_of(&s, d(1, 31), Scope::Month)),
Err(StoreError::FormatUpgradePending { .. })
));
assert!(matches!(
s.attest_period(jan, claim("high")),
Err(StoreError::FormatUpgradePending { .. })
));
assert_eq!(journal_bytes(&dir), before, "refused: nothing journaled");
s.upgrade_format().unwrap();
let run = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
.unwrap();
s.attest_period(jan, claim("high")).unwrap();
assert_eq!(s.figure_head().unwrap(), head, "claims are not figures");
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.audit_runs().unwrap(), vec![run]);
}
/// Done-when 2: books with an unfooted cash flow, a posted entry with no
/// receipt, an open draft and a duplicate file give exactly those four
/// findings: the statements' and the store's, together.
#[test]
fn four_defects_give_exactly_four_findings() {
use ledger_core::audit::{AuditInput, statements};
let dir = test_dir("four");
let (mut s, fx) = {
let mut s = Store::create(&dir, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let supplies = s
.add_account_with_tax(
"6000",
"Supplies",
AccountType::Expense,
None,
false,
Some(("supplies", None)),
)
.unwrap();
let e = s.create_entry(d(1, 1), "Opening").unwrap();
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
s.post_entry(e).unwrap();
s.attach_receipt(Some(e), "open.txt", "text/plain", b"deposit 500.00", None)
.unwrap();
// A purchase over the threshold with no receipt: posted through the
// primitive, as a hand entry made before the rule was tightened.
let bare = s.create_entry(d(1, 5), "Printer paper").unwrap();
s.add_line(bare, supplies, Side::Debit, usd("40.00"))
.unwrap();
s.add_line(bare, bank, Side::Credit, usd("40.00")).unwrap();
s.post_entry(bare).unwrap();
// An open draft.
let draft = s.create_entry(d(1, 9), "Stamps").unwrap();
s.add_line(draft, supplies, Side::Debit, usd("5.00"))
.unwrap();
s.add_line(draft, bank, Side::Credit, usd("5.00")).unwrap();
(s, (bank, card, bare))
};
// A duplicate: two records an older version filed for one file.
drop(s);
let bytes = b"filed twice by an older version";
let (sha, _) = BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (id, name) in [(10, "one.pdf"), (11, "two.pdf")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": null,
"category": "receipts", "file_name": name,
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
drop(w);
s = Store::open(&dir).unwrap();
let (bank, card, _) = fx;
// The statement of cash flows with a liability named as cash: the
// engine refuses it, and that refusal is the finding.
let params = ledger_core::CashFlowParams {
start: d(1, 1),
end: d(1, 31),
cash_accounts: vec![bank, card],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
};
let made = ledger_core::cash_flow(s.ledger(), &params)
.map(|c| c.reconciles)
.map_err(|e| e.to_string());
assert!(made.is_err(), "a liability is not a cash account");
let receipts = s.entries_with_receipts().unwrap();
let mut found = statements(
s.ledger(),
&AuditInput {
settings: s.ledger().settings(),
receipts: &receipts,
cash_flow: Some(&made),
settings_problems: &[],
undecided_1099: &[],
},
d(1, 31),
);
found.extend(s.integrity_findings());
ledger_core::audit::sort(&mut found);
let mut codes: Vec<&str> = found.iter().map(|f| f.code).collect();
codes.sort_unstable();
assert_eq!(
codes,
[
"cash_flow_unfooted",
"draft_open",
"duplicate_blob",
"evidence_missing"
],
"{found:#?}"
);
}