feat(audit): the statements checks, the bar and the audit settings (P8)

An audit is the books read as an auditor reads them, through a day. The
pure checks over a ledger are one function per finding code in
ledger_core::audit: the trial balance balances, assets equal liabilities
plus equity, the P&L articulates to equity (net income since the first
entry is current earnings plus what the closes moved), the cash flow
foots, the control accounts tie to their documents, a members close stays
within a minor unit of each member's exact share, schedules and
depreciation are posted, the register holds the cost accounts' cost,
every posted entry meets evidence::requirement (a reversal resting on its
original's receipt is listed as a note, not a failure), no draft is open,
every revenue and expense account has a tax category, and the 1099 and
settings problems the caller reads are findings. A check that cannot be
made is itself a finding. Findings come in one fixed order, so the same
books give the same list. The caller hands in what a ledger cannot know:
the receipts filed, the cash flow's footing, the settings problems.

The attestation bar (D-9) is a value, not a table: reasoning effort high,
two verifier agents and an adversarial check, with counts() deciding
whether a claim meets it. Every claim needs the effort; one on a
year-level audit needs the whole bar; a reading that disagrees with the
books never counts. policy.attestation_bar (a new Kind::AttestationBar,
one canonical text) and policy.cpa_package_requires_audit are the two
settings; both wait for the journal feature audits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:53:35 -06:00
1 parent d9d63b16dc
commit 843bcc421f
4 files changed
+1350

No files matched your search

+871
View File
@@ -0,0 +1,871 @@
//! The audit's statements checks (plan P8; rules.md B-45): the books,
//! read as an auditor would, through a date, as a list of findings.
//!
//! ```text
//! GOAL: one deterministic list of what is wrong or unfinished in the
//! books through a date, from the ledger alone, so an audit run is
//! reproducible and its workpaper hashes the same twice.
//! SOURCE: IRS Pub. 583 (records that support entries); Rev. Proc. 98-25
//! §5.01 (the relationship between the records and the books must
//! be demonstrable); docs/spec/rules.md B-6 to B-14 (the
//! statements), B-38 (control accounts), B-43 (evidence);
//! DECISION D-9 (the audit and its bar), D-53 (a run, not a job).
//! INVARIANTS:
//! 1. Pure: reads the ledger and the facts [`AuditInput`] hands in,
//! changes nothing, reads no clock: the same books and input give
//! the same findings in the same order.
//! 2. One function per check, one finding code per check ([`CODES`]);
//! a check that finds nothing says nothing.
//! 3. Only entries dated on or before `through` count, drafts and
//! posted alike.
//! 4. A check that cannot be made is itself a finding, never a silent
//! pass (`cash_flow_unfooted` for a statement the engine refuses).
//! SCENARIOS:
//! 1. IF the books are whole: SHOULD give no Failure or Warning.
//! 2. IF a draft is dated on or before `through`: SHOULD give exactly
//! `draft_open`.
//! ```
use std::collections::BTreeSet;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Serialize;
use crate::evidence::{Policy, Requirement};
use crate::settings::Settings;
use crate::{AccountType, DocStatus, EntryId, EntryStatus, Ledger, PeriodStatus, Side};
/// How much a finding matters.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
/// The books are wrong or cannot be shown right: an audit with one is
/// not clean.
Failure,
/// Unfinished work or a policy not met.
Warning,
/// Listed so the workpaper is whole; not a defect.
Info,
}
impl Severity {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
Severity::Failure => "failure",
Severity::Warning => "warning",
Severity::Info => "info",
}
}
}
/// What a finding is about.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum Subject {
/// The books as a whole.
Books,
/// An account.
Account(u64),
/// An entry.
Entry(u64),
/// An invoice.
Invoice(u64),
/// A bill.
Bill(u64),
/// An accounting period.
Period(u64),
/// An amortization schedule.
Schedule(u64),
/// A filed attachment.
Attachment(u64),
/// A vendor, by name.
Vendor(String),
/// A stored file or the access log, by name.
Named(String),
}
impl Subject {
/// In words: `entry 7`, `books`.
pub fn describe(&self) -> String {
match self {
Subject::Books => "books".into(),
Subject::Account(i) => format!("account {i}"),
Subject::Entry(i) => format!("entry {i}"),
Subject::Invoice(i) => format!("invoice {i}"),
Subject::Bill(i) => format!("bill {i}"),
Subject::Period(i) => format!("period {i}"),
Subject::Schedule(i) => format!("schedule {i}"),
Subject::Attachment(i) => format!("attachment {i}"),
Subject::Vendor(n) => format!("vendor {n}"),
Subject::Named(n) => n.clone(),
}
}
}
/// One thing an audit found.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Finding {
/// Its stable code ([`CODES`]).
pub code: &'static str,
/// How much it matters.
pub severity: Severity,
/// What it is about.
pub subject: Subject,
/// In words, with the exact figures.
pub message: String,
}
impl Finding {
/// A finding of the severity its code's row says.
pub fn new(code: &'static str, subject: Subject, message: impl Into<String>) -> Finding {
let severity = CODES
.iter()
.find(|r| r.code == code)
.map_or(Severity::Failure, |r| r.severity);
Finding {
code,
severity,
subject,
message: message.into(),
}
}
}
/// One row of the code table: a check's finding code, how much it matters
/// and the predicate it states.
#[derive(Clone, Copy, Debug)]
pub struct CodeRow {
/// The finding code.
pub code: &'static str,
/// Its severity.
pub severity: Severity,
/// The predicate: what is true when the code is reported.
pub predicate: &'static str,
}
/// Every statements check, by code (the integrity checks of the store have
/// their own table, `ledger_store::audit::CODES`). `audit_detects_each_
/// seeded_defect` seeds each row.
pub const CODES: &[CodeRow] = &[
CodeRow {
code: "trial_balance_unbalanced",
severity: Severity::Failure,
predicate: "total debits differ from total credits in the trial balance through the date",
},
CodeRow {
code: "equation_broken",
severity: Severity::Failure,
predicate: "assets differ from liabilities plus equity (current earnings included)",
},
CodeRow {
code: "pnl_not_articulated",
severity: Severity::Failure,
predicate: "net income since the first entry differs from current earnings plus what closes moved to equity",
},
CodeRow {
code: "cash_flow_unfooted",
severity: Severity::Failure,
predicate: "the statement of cash flows cannot be made, or its sections do not sum to the cash accounts' change",
},
CodeRow {
code: "control_difference",
severity: Severity::Failure,
predicate: "a control account's balance differs from its documents plus its unattached lines",
},
CodeRow {
code: "unattached_control_line",
severity: Severity::Warning,
predicate: "a line on a control account carries no document",
},
CodeRow {
code: "document_overpaid",
severity: Severity::Warning,
predicate: "a document has more paid against it than it is for",
},
CodeRow {
code: "close_split_drifts",
severity: Severity::Failure,
predicate: "a member's running share of the closes is more than a minor unit from its exact share",
},
CodeRow {
code: "schedule_behind",
severity: Severity::Warning,
predicate: "an amortization schedule has a month due through the date that is not posted",
},
CodeRow {
code: "depreciation_behind",
severity: Severity::Warning,
predicate: "the asset schedules say more depreciation is due than the books carry",
},
CodeRow {
code: "register_differs",
severity: Severity::Failure,
predicate: "the fixed asset register's cost differs from the cost accounts' balances",
},
CodeRow {
code: "evidence_missing",
severity: Severity::Failure,
predicate: "a posted entry the receipt policy covers has no receipt",
},
CodeRow {
code: "legacy_reversal_exempt",
severity: Severity::Info,
predicate: "a reversal the policy would cover has no receipt of its own; its original's evidence stands for it (D-47)",
},
CodeRow {
code: "draft_open",
severity: Severity::Warning,
predicate: "a draft is dated on or before the date",
},
CodeRow {
code: "tax_category_missing",
severity: Severity::Warning,
predicate: "a revenue or expense account has no tax category",
},
CodeRow {
code: "vendor_1099_undecided",
severity: Severity::Warning,
predicate: "a vendor paid in a way that could need a 1099 has no decision",
},
CodeRow {
code: "settings_problem",
severity: Severity::Warning,
predicate: "the settings name an account or role the chart cannot honor",
},
];
/// What the ledger cannot know by itself and the caller reads from the
/// books around it.
#[derive(Clone, Copy, Debug)]
pub struct AuditInput<'a> {
/// The books' settings (the receipt policy).
pub settings: &'a Settings,
/// The entries that have at least one receipt filed against them, not
/// withdrawn.
pub receipts: &'a BTreeSet<EntryId>,
/// The statement of cash flows tried from the first entry through the
/// date with the books' role settings: its footing, or why it could
/// not be made; `None` when the settings name no cash accounts or
/// there is nothing to state.
pub cash_flow: Option<&'a Result<crate::cashflow::CashFlowFooting, String>>,
/// What the settings check already says is wrong, in words.
pub settings_problems: &'a [String],
/// Vendors paid in a way that could need a 1099 with no decision, and
/// what they were paid.
pub undecided_1099: &'a [(String, Decimal)],
}
/// All the statements checks through `through`, in a fixed order:
/// failures first, then by code and subject.
pub fn statements(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec<Finding> {
let mut out = Vec::new();
out.extend(trial_balance(ledger, through));
out.extend(equation(ledger, through));
out.extend(articulation(ledger, through));
out.extend(check_cash_flow(input.cash_flow));
out.extend(control(ledger, through));
out.extend(closes(ledger, through));
out.extend(schedules(ledger, through));
out.extend(assets(ledger, through));
out.extend(evidence(ledger, input, through));
out.extend(drafts(ledger, through));
out.extend(tax_categories(ledger));
out.extend(vendors_1099(input.undecided_1099));
out.extend(settings_problems(input.settings_problems));
sort(&mut out);
out
}
/// Findings in their reporting order.
pub fn sort(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
(a.severity, a.code, &a.subject, &a.message)
.cmp(&(b.severity, b.code, &b.subject, &b.message))
});
}
fn trial_balance(ledger: &Ledger, through: NaiveDate) -> Option<Finding> {
let tb = crate::reports::trial_balance_as_of(ledger, through);
check_trial_balance(&tb)
}
/// The trial balance check on a built trial balance.
pub fn check_trial_balance(tb: &crate::reports::TrialBalance) -> Option<Finding> {
(!tb.balanced()).then(|| {
Finding::new(
"trial_balance_unbalanced",
Subject::Books,
format!(
"debits {} differ from credits {}",
tb.total_debits, tb.total_credits
),
)
})
}
fn equation(ledger: &Ledger, through: NaiveDate) -> Option<Finding> {
match crate::reports::balance_sheet_as_of(ledger, through) {
Ok(bs) => check_equation(&bs),
Err(e) => Some(Finding::new(
"equation_broken",
Subject::Books,
format!("the balance sheet cannot be made: {e}"),
)),
}
}
/// The accounting equation on a built balance sheet.
pub fn check_equation(bs: &crate::reports::BalanceSheetStatement) -> Option<Finding> {
(!bs.balanced()).then(|| {
Finding::new(
"equation_broken",
Subject::Books,
format!(
"assets {} differ from liabilities {} plus equity {}",
bs.total_assets, bs.total_liabilities, bs.total_equity
),
)
})
}
/// Net income moved to equity by the closes dated through `through`:
/// revenue closed less expenses closed, read off the closing entries'
/// lines.
fn closed_net(ledger: &Ledger, through: NaiveDate) -> Decimal {
let mut net = Decimal::ZERO;
for id in ledger.closing_entry_ids() {
let Some(e) = ledger.get_entry(id) else {
continue;
};
if e.status() != EntryStatus::Posted || e.date() > through {
continue;
}
for l in e.lines() {
let Some(a) = ledger.chart().get(l.account) else {
continue;
};
let signed = match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
};
if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) {
net += signed;
}
}
}
net
}
fn articulation(ledger: &Ledger, through: NaiveDate) -> Option<Finding> {
let first = ledger
.posted_entries()
.filter(|e| e.date() <= through)
.map(|e| e.date())
.min()?;
let pnl = crate::reports::profit_and_loss_for_period(ledger, first, through, None).ok()?;
let bs = crate::reports::balance_sheet_as_of(ledger, through).ok()?;
check_articulation(
pnl.net_income(),
bs.current_earnings,
closed_net(ledger, through),
)
}
/// The P&L ties to equity: net income since the first entry is current
/// earnings plus what the closes moved into equity.
pub fn check_articulation(
net_income: Decimal,
current_earnings: Decimal,
closed: Decimal,
) -> Option<Finding> {
(net_income != current_earnings + closed).then(|| {
Finding::new(
"pnl_not_articulated",
Subject::Books,
format!(
"net income {net_income} differs from current earnings {current_earnings} plus closed {closed}"
),
)
})
}
/// The statement of cash flows, tried by the caller from the books' role
/// settings: its footing, or why it could not be made.
pub fn check_cash_flow(
made: Option<&Result<crate::cashflow::CashFlowFooting, String>>,
) -> Option<Finding> {
match made? {
Ok(f) if f.difference.is_zero() => None,
Ok(f) => Some(Finding::new(
"cash_flow_unfooted",
Subject::Books,
format!(
"the sections sum to {} but the cash accounts changed by {}",
f.sections_sum, f.net_change
),
)),
Err(e) => Some(Finding::new(
"cash_flow_unfooted",
Subject::Books,
format!("the statement of cash flows cannot be made: {e}"),
)),
}
}
fn control(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
match ledger.control_check(through) {
Ok(checks) => check_control(&checks),
Err(e) => vec![Finding::new(
"control_difference",
Subject::Books,
format!("the control check cannot be made: {e}"),
)],
}
}
/// The control accounts' tie-out, as the engine read it.
pub fn check_control(checks: &[crate::arap::ControlCheck]) -> Vec<Finding> {
let mut out = Vec::new();
for c in checks {
if !c.difference.is_zero() {
out.push(Finding::new(
"control_difference",
Subject::Account(c.account),
format!(
"the {} account's balance {} differs from its documents {} by {}",
c.role, c.balance, c.documents_total, c.difference
),
));
}
for u in &c.unattached {
out.push(Finding::new(
"unattached_control_line",
Subject::Entry(u.entry),
format!(
"line {} of entry {} ({}), {} on the {} account, carries no document",
u.line, u.entry, u.date, u.amount, c.role
),
));
}
for a in c
.attention
.iter()
.filter(|a| a.status == DocStatus::Overpaid)
{
let subject = match a.doc {
crate::DocRef::Invoice(i) => Subject::Invoice(i),
crate::DocRef::Bill(b) => Subject::Bill(b),
};
out.push(Finding::new(
"document_overpaid",
subject,
format!("{} is overpaid: its total is {}", a.doc, a.total),
));
}
}
out
}
fn closes(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
let unit = Decimal::new(1, ledger.base_currency().minor_units().unwrap_or(2));
let mut out = Vec::new();
for p in ledger.list_periods() {
if p.status != PeriodStatus::Closed || p.end > through {
continue;
}
if let Some((_, split)) = ledger.member_close_split(p.id) {
out.extend(check_split(p.id, &p.name, &split, unit));
}
}
out
}
/// One close's members: each one's running total of the closes must be
/// within a minor unit of its exact share (the cumulative rule,
/// `kernel::apportion_cumulative`).
pub fn check_split(
period: u64,
name: &str,
split: &[crate::periods::MemberSplit],
unit: Decimal,
) -> Vec<Finding> {
split
.iter()
.filter(|m| (m.running - m.exact).abs() > unit)
.map(|m| {
Finding::new(
"close_split_drifts",
Subject::Period(period),
format!(
"after the close of {name}, account {} holds {} of the closes where its exact share is {}",
m.capital, m.running, m.exact
),
)
})
.collect()
}
fn schedules(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
let mut out = Vec::new();
for s in ledger.schedules() {
let months = match s.due(through) {
Ok(m) => m,
Err(e) => {
out.push(Finding::new(
"schedule_behind",
Subject::Schedule(s.id),
format!("\"{}\" cannot be planned: {e}", s.description),
));
continue;
}
};
if let Some((first, _)) = months.first() {
let total: Decimal = months.iter().map(|(_, a)| *a).sum();
out.push(Finding::new(
"schedule_behind",
Subject::Schedule(s.id),
format!(
"\"{}\" has {} month(s) due and not posted, from {first}, {total} in all",
s.description,
months.len()
),
));
}
}
out
}
fn assets(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
match crate::reports::fixed_asset_register(ledger, through) {
Ok(reg) => check_register(&reg),
Err(e) => vec![Finding::new(
"register_differs",
Subject::Books,
format!("the fixed asset register cannot be made: {e}"),
)],
}
}
/// The register against the books' cost and accumulated depreciation.
pub fn check_register(reg: &crate::reports::AssetRegister) -> Vec<Finding> {
let mut out = Vec::new();
if reg.unposted_depreciation() > Decimal::ZERO {
out.push(Finding::new(
"depreciation_behind",
Subject::Books,
format!(
"the schedules say {} of depreciation is due and the books carry {}",
reg.total_accumulated_scheduled, reg.ledger_accumulated
),
));
}
if !reg.unregistered_cost().is_zero() {
out.push(Finding::new(
"register_differs",
Subject::Books,
format!(
"the register holds {} of cost and the cost accounts {}",
reg.total_cost, reg.ledger_cost
),
));
}
out
}
fn evidence(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec<Finding> {
let policy = Policy::from_settings(input.settings);
let mut out = Vec::new();
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
if input.receipts.contains(&e.id()) {
continue;
}
match crate::evidence::requirement(ledger, e, input.settings) {
Requirement::Required { reason } => out.push(Finding::new(
"evidence_missing",
Subject::Entry(e.id()),
format!("entry {} ({}) has no receipt: {reason}", e.id(), e.date()),
)),
Requirement::Exempt { .. } if e.reversal_of().is_some() => {
if policy.receipt_required(ledger, e) {
out.push(Finding::new(
"legacy_reversal_exempt",
Subject::Entry(e.id()),
format!(
"entry {} reverses entry {}: its evidence is the original's",
e.id(),
e.reversal_of().unwrap_or_default()
),
));
}
}
Requirement::Exempt { .. } => {}
}
}
out
}
fn drafts(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
ledger
.drafts()
.into_iter()
.filter(|e| e.date() <= through)
.map(|e| {
Finding::new(
"draft_open",
Subject::Entry(e.id()),
format!(
"draft {} ({}) \"{}\" is not posted",
e.id(),
e.date(),
e.description()
),
)
})
.collect()
}
fn tax_categories(ledger: &Ledger) -> Vec<Finding> {
crate::tax::uncategorized_accounts(ledger)
.into_iter()
.map(|a| {
Finding::new(
"tax_category_missing",
Subject::Account(a.id),
format!("account {} {} has no tax category", a.code, a.name),
)
})
.collect()
}
fn vendors_1099(undecided: &[(String, Decimal)]) -> Vec<Finding> {
undecided
.iter()
.map(|(name, paid)| {
Finding::new(
"vendor_1099_undecided",
Subject::Vendor(name.clone()),
format!(
"{name} was paid {paid} in a way that could need a 1099 and has no decision"
),
)
})
.collect()
}
fn settings_problems(problems: &[String]) -> Vec<Finding> {
problems
.iter()
.map(|p| Finding::new("settings_problem", Subject::Books, p.clone()))
.collect()
}
// ---------------------------------------------------------------------------
// Scope, method and the attestation bar (rules.md B-45; D-9)
// ---------------------------------------------------------------------------
/// What an audit covers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
/// A month's books, for its month-end attestation.
Month,
/// A fiscal year.
Annual,
/// The year, for the CPA handoff.
CpaHandoff,
/// Everything since the first entry.
Full,
}
impl Scope {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
Scope::Month => "month",
Scope::Annual => "annual",
Scope::CpaHandoff => "cpa_handoff",
Scope::Full => "full",
}
}
/// The scope a word names.
pub fn parse(s: &str) -> Option<Scope> {
match s.trim().to_ascii_lowercase().as_str() {
"month" => Some(Scope::Month),
"annual" => Some(Scope::Annual),
"cpa_handoff" => Some(Scope::CpaHandoff),
"full" => Some(Scope::Full),
_ => None,
}
}
/// Whether a period attested on this audit is held to the whole bar
/// (adversarial, several verifiers) and not the effort alone (D-9).
pub fn full_bar(self) -> bool {
!matches!(self, Scope::Month)
}
/// Whether an audit of this scope is one the CPA package may rest on.
pub fn serves_handoff(self) -> bool {
matches!(self, Scope::CpaHandoff | Scope::Full)
}
}
/// How hard the checker thought, as the client reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Effort {
/// low
Low,
/// medium
Medium,
/// high
High,
/// xhigh
Xhigh,
/// max
Max,
}
impl Effort {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::Xhigh => "xhigh",
Effort::Max => "max",
}
}
/// The effort a word names.
pub fn parse(s: &str) -> Option<Effort> {
match s.trim().to_ascii_lowercase().as_str() {
"low" => Some(Effort::Low),
"medium" => Some(Effort::Medium),
"high" => Some(Effort::High),
"xhigh" => Some(Effort::Xhigh),
"max" => Some(Effort::Max),
_ => None,
}
}
}
/// How an attestation was made, as the client says (self-reported: the
/// books record the claim, never prove it; rules.md B-45).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Method {
/// The model that checked, as it names itself.
pub model: Option<String>,
/// How hard it thought.
pub reasoning_effort: Option<Effort>,
/// Whether the check was adversarial: a verifier set to find what is
/// wrong, not to confirm.
pub adversarial: Option<bool>,
/// How many independent verifier agents checked.
pub verifier_agents: Option<u32>,
}
/// What counts as an attestation (`policy.attestation_bar`; D-9). The
/// default: reasoning effort high, two independent verifiers, adversarial.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct AttestationBar {
/// The least effort that counts, for every attestation.
pub reasoning_effort: Effort,
/// The fewest independent verifier agents a year-level attestation
/// needs.
pub verifier_agents: u32,
/// Whether a year-level attestation must be adversarial.
pub adversarial: bool,
}
impl Default for AttestationBar {
fn default() -> Self {
AttestationBar {
reasoning_effort: Effort::High,
verifier_agents: 2,
adversarial: true,
}
}
}
impl AttestationBar {
/// The bar a setting's text names: `key=value` words in any order,
/// separated by spaces or commas (`effort=high verifiers=2
/// adversarial=true`); a word left out keeps its default.
pub fn parse(s: &str) -> Result<AttestationBar, String> {
let mut bar = AttestationBar::default();
for word in s.split(|c: char| c.is_whitespace() || c == ',') {
if word.is_empty() {
continue;
}
let (k, v) = word.split_once('=').ok_or_else(|| {
format!("{word:?}: expected key=value (effort, verifiers, adversarial)")
})?;
match k {
"effort" | "reasoning_effort" => {
bar.reasoning_effort = Effort::parse(v).ok_or_else(|| {
format!("effort {v:?} is not low, medium, high, xhigh or max")
})?;
}
"verifiers" | "verifier_agents" => {
bar.verifier_agents = v
.parse::<u32>()
.ok()
.filter(|n| (1..=64).contains(n))
.ok_or_else(|| format!("verifiers {v:?} is not a count from 1 to 64"))?;
}
"adversarial" => {
bar.adversarial = match v {
"true" => true,
"false" => false,
_ => return Err(format!("adversarial {v:?} is not true or false")),
};
}
other => {
return Err(format!(
"{other:?} is not a part of the bar: effort, verifiers, adversarial"
));
}
}
}
Ok(bar)
}
/// The bar in its one canonical spelling.
pub fn describe(&self) -> String {
format!(
"effort={} verifiers={} adversarial={}",
self.reasoning_effort.key(),
self.verifier_agents,
self.adversarial
)
}
/// Whether an attestation made `method` counts. A claim that
/// disagrees with the books never does. Every attestation needs the
/// effort; one given on a year-level audit (`full`) needs the whole
/// bar. Evaluated when asked, against the bar as it stands (D-9: no
/// bar versioning).
pub fn counts(&self, method: &Method, full: bool, disagrees: bool) -> bool {
if disagrees {
return false;
}
if !method
.reasoning_effort
.is_some_and(|e| e >= self.reasoning_effort)
{
return false;
}
if !full {
return true;
}
(!self.adversarial || method.adversarial == Some(true))
&& method
.verifier_agents
.is_some_and(|n| n >= self.verifier_agents)
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod account;
pub mod analysis;
pub mod arap;
pub mod assets;
pub mod audit;
pub mod calendar;
pub mod cashflow;
pub mod classes;
+39
View File
@@ -146,6 +146,9 @@ pub enum Kind {
Terms,
/// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`.
PaymentMethods,
/// What counts as an attestation ([`crate::audit::AttestationBar`]),
/// in its one canonical spelling.
AttestationBar,
}
/// One setting the books know.
@@ -170,6 +173,7 @@ const PARTY_POLICY: &[(&str, &[&str])] =
const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])];
const TAX_CATEGORY_POLICY: &[(&str, &[&str])] =
&[("warn", &[]), ("refuse", &["required", "require"])];
const AUDIT_REQUIRED: &[(&str, &[&str])] = &[("refuse", &["required", "require"]), ("warn", &[])];
const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])];
/// A return's key as a setting keeps it.
@@ -401,6 +405,21 @@ pub static KEYS: &[Key] = &[
sensitivity: Sensitivity::Public,
doc: "off: approval_rule conventions are recorded but never block a post. enforce: an entry a rule covers posts only once its approver approves it (books.approve_entry). Unset: off.",
},
// P8: audits and attestations.
Key {
name: "policy.attestation_bar",
toml: ("books", "attestation_bar"),
kind: Kind::AttestationBar,
sensitivity: Sensitivity::Public,
doc: "What counts as an attestation (plan D-9): the least reasoning effort a checker reports (low, medium, high, xhigh, max), and for a year-level audit the fewest independent verifier agents and whether the check was adversarial, written like effort=high verifiers=2 adversarial=true (a word left out keeps its default). The client reports how it checked; the books record that claim and say whether it meets this bar when asked, so a change here changes what counts, earlier attestations too. Unset: effort=high verifiers=2 adversarial=true.",
},
Key {
name: "policy.cpa_package_requires_audit",
toml: ("books", "cpa_package_requires_audit"),
kind: Kind::Choice(AUDIT_REQUIRED),
sensitivity: Sensitivity::Public,
doc: "Whether books.export_cpa_package waits for an audit: refuse (the package is refused unless an audit for the handoff or a full audit at the current books has its period attested; a reason given for the export overrides it and is recorded as a decision) or warn (the package is made and says so). Unset: refuse.",
},
Key {
name: "backup.git_remote",
toml: ("backup", "git_remote"),
@@ -1103,6 +1122,11 @@ pub fn check(key: &Key, v: &Value) -> Result<Value, String> {
Ok(json!(terms.describe()))
}
Kind::PaymentMethods => payment_methods(name, want_list(name, v)?),
Kind::AttestationBar => {
let bar = crate::audit::AttestationBar::parse(want_str(name, v)?)
.map_err(|e| format!("{name}: {e}"))?;
Ok(json!(bar.describe()))
}
Kind::TimeZone => {
let s = want_str(name, v)?.trim();
let zone = jiff::tz::TimeZone::get(s).map_err(|_| {
@@ -1230,6 +1254,9 @@ pub fn shape(key: &Key, v: &Value) -> Result<(), String> {
.ok_or_else(|| format!("{name} {s:?} is not a decimal"))
}
Kind::Count => count_of(name, v).map(|_| ()),
Kind::AttestationBar => crate::audit::AttestationBar::parse(want_str(name, v)?)
.map(|_| ())
.map_err(|e| format!("{name}: {e}")),
Kind::PaymentMethods => {
for (i, m) in want_list(name, v)?.iter().enumerate() {
let n = format!("{name} method {}", i + 1);
@@ -1605,6 +1632,18 @@ impl Settings {
}
}
/// `policy.attestation_bar` (unset: the default bar, D-9).
pub fn attestation_bar(&self) -> crate::audit::AttestationBar {
self.str("policy.attestation_bar")
.and_then(|s| crate::audit::AttestationBar::parse(s).ok())
.unwrap_or_default()
}
/// `policy.cpa_package_requires_audit` (unset: refuse).
pub fn cpa_package_requires_audit(&self) -> bool {
self.str("policy.cpa_package_requires_audit") != Some("warn")
}
/// `backup.git_remote`, if set.
pub fn backup_git_remote(&self) -> Option<&str> {
self.str("backup.git_remote")
+439
View File
@@ -0,0 +1,439 @@
//! The audit's statements checks (plan P8; rules.md B-45): one test per
//! finding code, each seeding exactly the defect the code names.
use std::collections::BTreeSet;
use chrono::NaiveDate;
use ledger_core::arap::{ControlCheck, DocAttention, UnattachedLine};
use ledger_core::audit::{
self, AttestationBar, AuditInput, CODES, Effort, Finding, Method, Severity,
};
use ledger_core::cashflow::CashFlowFooting;
use ledger_core::periods::MemberSplit;
use ledger_core::reports::{AssetRegister, TrialBalance};
use ledger_core::settings::Settings;
use ledger_core::{
AccountType, Currency, DocRef, DocStatus, EntryId, Ledger, Money, NewAsset, NewSchedule,
ScheduleConvention, ScheduleKind, Side,
};
use rust_decimal::Decimal;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
struct Books {
l: Ledger,
cash: u64,
capital: u64,
expense: u64,
revenue: u64,
}
/// A bank, capital, an expense and a revenue account, all categorized.
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = a("1000", "Bank", AccountType::Asset);
let capital = a("3000", "Capital", AccountType::Equity);
let revenue = a("4000", "Sales", AccountType::Revenue);
let expense = a("6000", "Supplies", AccountType::Expense);
l.set_account_tax(revenue, Some("gross_receipts"), None)
.unwrap();
l.set_account_tax(expense, Some("supplies"), None).unwrap();
Books {
l,
cash,
capital,
expense,
revenue,
}
}
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) -> EntryId {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
l.post_entry(e).unwrap();
e
}
fn run(l: &Ledger, receipts: &BTreeSet<EntryId>, through: NaiveDate) -> Vec<Finding> {
let settings = Settings::default();
audit::statements(
l,
&AuditInput {
settings: &settings,
receipts,
cash_flow: None,
settings_problems: &[],
undecided_1099: &[],
},
through,
)
}
/// Every posted entry has its receipt.
fn receipted(l: &Ledger) -> BTreeSet<EntryId> {
l.posted_entries().map(|e| e.id()).collect()
}
fn codes(f: &[Finding]) -> Vec<&'static str> {
f.iter().map(|f| f.code).collect()
}
fn control(difference: &str) -> ControlCheck {
ControlCheck {
role: "receivables".into(),
account: 7,
balance: dec("100.00"),
documents_total: dec("100.00"),
unattached: vec![],
difference: dec(difference),
attention: vec![],
}
}
/// Every code's seed: the defect, run, and the codes it yields.
fn seeds() -> Vec<(&'static str, Vec<Finding>)> {
let mut out = Vec::new();
let none = BTreeSet::new();
out.push((
"trial_balance_unbalanced",
audit::check_trial_balance(&TrialBalance {
lines: vec![],
total_debits: dec("10.00"),
total_credits: dec("9.99"),
})
.into_iter()
.collect(),
));
let b = books();
let mut bs = ledger_core::reports::balance_sheet_as_of(&b.l, d(12, 31)).unwrap();
bs.total_assets = dec("1.00");
out.push((
"equation_broken",
audit::check_equation(&bs).into_iter().collect(),
));
out.push((
"pnl_not_articulated",
audit::check_articulation(dec("100.00"), dec("40.00"), dec("50.00"))
.into_iter()
.collect(),
));
// A statement the engine refuses (a liability named as cash) and one
// that does not foot are both unfooted.
let settings = Settings::default();
let refused: Result<CashFlowFooting, String> = Err("account 9 is not an asset".into());
let off: Result<CashFlowFooting, String> = Ok(CashFlowFooting {
sections_sum: dec("10.00"),
net_change: dec("9.00"),
difference: dec("1.00"),
});
let b = books();
for made in [&refused, &off] {
let f = audit::statements(
&b.l,
&AuditInput {
settings: &settings,
receipts: &none,
cash_flow: Some(made),
settings_problems: &[],
undecided_1099: &[],
},
d(12, 31),
);
assert_eq!(codes(&f), vec!["cash_flow_unfooted"]);
}
out.push((
"cash_flow_unfooted",
audit::check_cash_flow(Some(&off)).into_iter().collect(),
));
out.push((
"control_difference",
audit::check_control(&[control("0.01")]),
));
let mut c = control("0.00");
c.unattached = vec![UnattachedLine {
entry: 3,
line: 0,
date: d(2, 1),
amount: dec("5.00"),
}];
out.push(("unattached_control_line", audit::check_control(&[c])));
let mut c = control("0.00");
c.attention = vec![DocAttention {
doc: DocRef::Invoice(1),
status: DocStatus::Overpaid,
total: dec("10.00"),
outstanding: dec("-5.00"),
}];
out.push(("document_overpaid", audit::check_control(&[c])));
out.push((
"close_split_drifts",
audit::check_split(
2,
"2026-10",
&[MemberSplit {
capital: 9,
share: dec("0.5"),
part: dec("1.00"),
running: dec("1.00"),
exact: dec("1.05"),
}],
dec("0.01"),
),
));
// A prepaid plan with a month due and nothing posted.
let mut b = books();
let prepaid =
b.l.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
b.l.register_schedule(NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1200.00"),
start: d(1, 1),
months: 12,
balance_account: prepaid,
pl_account: b.expense,
convention: ScheduleConvention::MonthlyEven,
})
.unwrap();
out.push(("schedule_behind", run(&b.l, &receipted(&b.l), d(3, 31))));
// A registered asset, no depreciation run.
let mut b = books();
let (cost, accum, dep) = asset_accounts(&mut b);
post(&mut b.l, d(1, 2), cost, b.capital, "6000.00");
b.l.register_asset(NewAsset {
description: "Van".into(),
in_service_date: d(1, 15),
cost: usd("6000.00"),
salvage_value: usd("0"),
useful_life_months: 60,
cost_account: cost,
accum_depr_account: accum,
expense_account: dep,
})
.unwrap();
out.push(("depreciation_behind", run(&b.l, &receipted(&b.l), d(6, 30))));
// Cost posted to the asset's account that the register doesn't hold.
post(&mut b.l, d(2, 1), cost, b.capital, "100.00");
let f = run(&b.l, &receipted(&b.l), d(6, 30));
out.push((
"register_differs",
f.into_iter()
.filter(|f| f.code == "register_differs")
.collect(),
));
// A 40.00 hand entry (threshold 25.00) with no receipt, then reversed.
let mut b = books();
let e = post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00");
let f = run(&b.l, &none, d(12, 31));
out.push(("evidence_missing", f));
let r = b.l.reverse_entry(e, d(2, 2), "undo").unwrap();
let mut with_original = BTreeSet::new();
with_original.insert(e);
let f = run(&b.l, &with_original, d(12, 31));
assert!(f.iter().any(|f| f.subject == audit::Subject::Entry(r)));
out.push(("legacy_reversal_exempt", f));
let mut b = books();
let e = b.l.create_entry(d(3, 3), "draft");
b.l.add_line(e, b.expense, Side::Debit, usd("1.00"))
.unwrap();
b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap();
out.push(("draft_open", run(&b.l, &none, d(12, 31))));
let mut b = books();
b.l.add_account("4100", "Other sales", AccountType::Revenue, None, false)
.unwrap();
out.push(("tax_category_missing", run(&b.l, &none, d(12, 31))));
let b = books();
let settings = Settings::default();
out.push((
"vendor_1099_undecided",
audit::statements(
&b.l,
&AuditInput {
settings: &settings,
receipts: &none,
cash_flow: None,
settings_problems: &[],
undecided_1099: &[("Tobin Harlow Supply".into(), dec("900.00"))],
},
d(12, 31),
),
));
out.push((
"settings_problem",
audit::statements(
&b.l,
&AuditInput {
settings: &settings,
receipts: &none,
cash_flow: None,
settings_problems: &["books.close_into names 9999".into()],
undecided_1099: &[],
},
d(12, 31),
),
));
let _ = (b.revenue, AccountType::Asset, Side::Debit);
out
}
fn asset_accounts(b: &mut Books) -> (u64, u64, u64) {
let cost =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum =
b.l.add_account(
"1590",
"Accumulated depreciation",
AccountType::Asset,
None,
false,
)
.unwrap();
let dep =
b.l.add_account("6500", "Depreciation", AccountType::Expense, None, false)
.unwrap();
b.l.set_account_tax(dep, Some("supplies"), None).unwrap();
(cost, accum, dep)
}
#[test]
fn audit_detects_each_seeded_defect() {
let seeds = seeds();
for row in CODES {
let (_, found) = seeds
.iter()
.find(|(c, _)| *c == row.code)
.unwrap_or_else(|| panic!("no seeding fixture for {}", row.code));
let mine: Vec<&Finding> = found.iter().filter(|f| f.code == row.code).collect();
assert!(!mine.is_empty(), "{} was not found", row.code);
assert!(
mine.iter().all(|f| f.severity == row.severity),
"{}",
row.code
);
// Exactly that finding: nothing else but what the seed implies.
let others: Vec<_> = found.iter().filter(|f| f.code != row.code).collect();
let implied = match row.code {
// A 40.00 reversal chain, or a draft's unbalanced side, leaves
// the neighbouring checks quiet.
"legacy_reversal_exempt" => vec!["legacy_reversal_exempt", "evidence_missing"],
_ => vec![],
};
for o in others {
assert!(
implied.contains(&o.code),
"{}: also found {} ({})",
row.code,
o.code,
o.message
);
}
}
for (c, _) in &seeds {
assert!(CODES.iter().any(|r| r.code == *c), "{c} is not in CODES");
}
}
#[test]
fn a_whole_book_gives_no_failure_or_warning() {
let mut b = books();
post(&mut b.l, d(1, 5), b.cash, b.capital, "1000.00");
post(&mut b.l, d(1, 6), b.cash, b.revenue, "20.00");
let f = run(&b.l, &receipted(&b.l), d(12, 31));
assert!(
f.iter().all(|f| f.severity == Severity::Info),
"{:?}",
codes(&f)
);
}
#[test]
fn findings_come_in_one_order() {
let mut b = books();
post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00");
let e = b.l.create_entry(d(3, 3), "draft");
b.l.add_line(e, b.expense, Side::Debit, usd("1.00"))
.unwrap();
b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap();
let a = run(&b.l, &BTreeSet::new(), d(12, 31));
let c = run(&b.l, &BTreeSet::new(), d(12, 31));
assert_eq!(a, c);
assert_eq!(codes(&a), vec!["evidence_missing", "draft_open"]);
let _ = AssetRegister::unposted_depreciation;
}
fn method(effort: Option<&str>, adversarial: Option<bool>, agents: Option<u32>) -> Method {
Method {
model: None,
reasoning_effort: effort.map(|e| Effort::parse(e).unwrap()),
adversarial,
verifier_agents: agents,
}
}
/// D-9: effort at the bar counts for a receipt or a month; a year-level
/// attestation also needs the verifiers and the adversarial check; less
/// than the bar, or no claim at all, never counts.
#[test]
fn below_bar_not_counted() {
let bar = AttestationBar::default();
assert_eq!(bar.describe(), "effort=high verifiers=2 adversarial=true");
assert!(bar.counts(&method(Some("high"), None, None), false, false));
assert!(bar.counts(&method(Some("max"), None, None), false, false));
assert!(!bar.counts(&method(Some("medium"), None, None), false, false));
assert!(!bar.counts(&method(None, Some(true), Some(3)), false, false));
assert!(bar.counts(&method(Some("high"), Some(true), Some(2)), true, false));
assert!(!bar.counts(&method(Some("high"), Some(true), Some(1)), true, false));
assert!(!bar.counts(&method(Some("high"), Some(false), Some(2)), true, false));
assert!(!bar.counts(&method(Some("high"), None, Some(2)), true, false));
// A bar change changes what counts, earlier claims too (no
// versioning, D-9).
let lax = AttestationBar::parse("effort=low verifiers=1 adversarial=false").unwrap();
assert!(lax.counts(&method(Some("low"), Some(false), Some(1)), true, false));
assert!(AttestationBar::parse("effort=wild").is_err());
assert!(AttestationBar::parse("verifiers=0").is_err());
assert!(AttestationBar::parse("nonsense").is_err());
assert_eq!(
AttestationBar::parse("adversarial=false, effort=xhigh")
.unwrap()
.describe(),
"effort=xhigh verifiers=2 adversarial=false"
);
}
/// A reading that disagrees with the books never counts, however hard the
/// checker says it thought.
#[test]
fn amount_mismatch_never_counts() {
let bar = AttestationBar::default();
let top = method(Some("max"), Some(true), Some(9));
assert!(bar.counts(&top, true, false));
assert!(!bar.counts(&top, false, true));
assert!(!bar.counts(&top, true, true));
}