merge: P8, audit and attestations: the checks, journaled runs and attestations, the workpaper PDF, the CPA gate (branch P8; schema 20)

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 16:05:30 -06:00
commit 3ee2020bb2
54 files changed
+6303 -56

No files matched your search

+3 -3
View File
@@ -123,7 +123,7 @@ pub struct Rule {
/// Its normative title, as `docs/spec/rules.md` states it.
pub title: &'static str,
/// What its statement rests on; `None` for a rule allocated but not
/// yet stated (B-39 to B-45, `plan/served.md` 8.0.3).
/// yet stated (B-39, B-40 and B-43, `plan/served.md` 8.0.3).
pub status: Option<Status>,
/// The example ids that demonstrate it, by `Example::id`.
pub examples: &'static [&'static str],
@@ -587,8 +587,8 @@ const SPEC: Spec = &[
(
45,
Area::Ledger,
"Audits and attestations (allocated, not yet stated)",
None,
"An audit is deterministic over the books; an attestation is a claim by a named client",
Some(Status::Convention),
&[],
),
];
+7 -6
View File
@@ -132,10 +132,11 @@ how the books are kept.
### BS-ledger-13: Audits and attestations (B-45)
- **Status:** reserved
- **Statement:** Reserved: allocated in the wanderbooks plan (8.0.3), not yet stated in `docs/spec/rules.md`.
- **Status:** CONVENTION
- **Statement:** An audit of the books through a day MUST give the same findings for the same books and programme; a claim that someone checked MUST rest on the books as they were when the audit ran, MUST NOT count when what it read disagrees with the books, and its method is the claimant's own word.
- **Sources:**
- none yet
- **Symbolic form:** none yet.
- **Examples:** none yet
- **Applicability:** none yet.
- external: Revenue Procedure 98-25, section 5.01, machine-sensible records with an audit trail
- vendored `sources/irs-records.md`: "In addition, you must keep supporting documents"
- **Symbolic form:** `findings(books, through) = f(books, through, programme)`; `counted(a, bar) = meets(method(a), bar) and not disagrees(a)`.
- **Examples:** none yet. The example schema has no audit event or query; an audit example waits on a schema extension.
- **Applicability:** engines that are audited.
+3 -3
View File
@@ -6,8 +6,8 @@ what its statement rests on and the examples that demonstrate it.
A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name:
the specification names the options, an engine declares its pick
(`claims.choices`, `bookspec_checker::Claims`). Rules B-39, B-40, B-43
and B-45 are allocated in `plan/served.md` 8.0.3 and not yet stated: their
(`claims.choices`, `bookspec_checker::Claims`). Rules B-39, B-40 and B-43
are allocated in `plan/served.md` 8.0.3 and not yet stated: their
identifier and area are fixed here so a later unit fills them in
without moving anyone else's number.
@@ -57,4 +57,4 @@ without moving anyone else's number.
| B-42 | `BS-arap-6` | arap | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — |
| B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — |
| B-44 | `BS-ledger-12` | ledger | CONVENTION | Evidence is read from the bytes, never trusted over them; identical files are kept once | — |
| B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — |
| B-45 | `BS-ledger-13` | ledger | CONVENTION | An audit is deterministic over the books; an attestation is a claim by a named client | — |
+77 -1
View File
@@ -9,7 +9,7 @@
use std::collections::HashSet;
use std::time::Instant;
use chrono::NaiveDate;
use chrono::{Datelike, NaiveDate};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::{Source, data};
use rust_decimal::Decimal;
@@ -350,3 +350,79 @@ fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
"{titles:?}"
);
}
/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end
/// attestation due" on the viewer's list and in `books.start`'s next steps,
/// from the one engine; the fiscal year that ended closed with no audit
/// for the handoff is "Full audit due before the CPA handoff"; an
/// attestation clears the month. Books that never declared `audits` get
/// none of them (they cannot act on them).
#[test]
fn audit_items_are_on_both_lists_once_audits_are_declared() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-audit-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let year = today.year() - 1;
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap();
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = st
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let capital = st
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let e = st.create_entry(day(1, 5), "Capital").unwrap();
st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap();
st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap();
st.post_entry(e).unwrap();
let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
st.close_period(jan).unwrap();
let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap();
st.close_period(dec).unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-audit-parity");
let titles = compare(&place, &mut wire, today);
assert!(
titles
.iter()
.any(|t| t.starts_with("Month-end attestation due")),
"{titles:?}"
);
assert!(
titles
.iter()
.any(|t| t.starts_with("Full audit due before the CPA handoff")),
"{titles:?}"
);
// Audit and attest January: that month's item goes, the year's stays.
let run = common::answer_of(&wire.call(
"books.audit",
json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}),
))
.unwrap();
assert_eq!(run["status"], "audited", "{run}");
common::answer_of(&wire.call(
"books.attest_period",
json!({"period": "Jan", "method": {"reasoning_effort": "high"}}),
))
.unwrap();
let after = compare(&place, &mut wire, today);
let due: Vec<&String> = after
.iter()
.filter(|t| t.starts_with("Month-end attestation due"))
.collect();
assert_eq!(due.len(), 1, "{after:?}");
assert!(due[0].contains("Dec"), "{after:?}");
// The step offers the audit.
let answer = start(&mut wire);
let step = answer["next_steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["why"] == json!(due[0].as_str()))
.unwrap_or_else(|| panic!("no step: {answer}"));
assert_eq!(step["tool"], json!("books.audit"), "{answer}");
assert_eq!(step["args"]["scope"], json!("month"), "{answer}");
}
+419
View File
@@ -0,0 +1,419 @@
//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9):
//! a deterministic run and workpaper, claims weighed against the bar when
//! asked, the CPA package that waits for a counted audit, and books that
//! have not declared `audits`.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit
//! and attestations") and rules.md B-45. Every call goes through a real
//! books server as an agent reaches it (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
/// Cash at 1000, the default 25.00 receipt threshold.
const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("4000", "Sales", "revenue"),
("6000", "Supplies", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A posted entry of `amount` supplies paid from cash on `date`.
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let r = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap();
let id = r["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
id
}
fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 {
let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt");
answer_of(&w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
))
.unwrap()["attachment_id"]
.as_u64()
.unwrap()
}
fn confirm_post(w: &mut common::Wire, id: u64) {
let r = answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
assert_eq!(r["status"], "posted", "{r}");
}
/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with
/// none. Returns (receipted entry, its receipt, the bare entry).
fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) {
let books = Books::with(tag, "USD", Some(SETTINGS));
let mut w = books.session("p8");
chart(&mut w);
let cap = answer_of(&w.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("1000", "debit"), ("3000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}),
))
.unwrap();
}
receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01");
confirm_post(&mut w, cap);
let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00");
let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05");
confirm_post(&mut w, paper);
// The 60.00 purchase is posted while the books' receipt threshold was
// higher, as it could have been before the rule was tightened.
set_threshold(&mut w, "1000.00");
let bare = post(&mut w, "2026-03-07", "Toner", "60.00");
confirm_post(&mut w, bare);
set_threshold(&mut w, "25.00");
(books, w, paper, rid, bare)
}
fn set_threshold(w: &mut common::Wire, amount: &str) {
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.evidence.threshold", "value": amount}]}),
))
.unwrap();
}
fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value {
answer_of(&w.call(
"books.audit",
json!({"scope": scope, "through": through, "confirmed": true}),
))
.unwrap_or_else(|e| panic!("books.audit: {e}"))
}
fn codes(out: &Value) -> Vec<String> {
out["findings"]
.as_array()
.unwrap()
.iter()
.map(|f| f["code"].as_str().unwrap().to_string())
.collect()
}
/// The same books, day and scope give the same run, findings and
/// workpaper; asking again files and journals nothing; an entry makes it a
/// new audit.
#[test]
fn audit_is_deterministic() {
let (books, mut w, _, _, bare) = setup("p8-deterministic");
let preview = answer_of(&w.call(
"books.audit",
json!({"scope": "full", "through": "2026-03-31"}),
))
.unwrap();
assert_eq!(preview["status"], "preview");
assert!(preview["run"].is_null());
assert!(
preview["checks"]
.as_array()
.unwrap()
.iter()
.any(|c| c["code"] == "evidence_missing")
);
let first = audit(&mut w, "full", "2026-03-31");
assert_eq!(first["status"], "audited", "{first}");
assert!(
codes(&first).contains(&"evidence_missing".to_string()),
"the bare 60.00 entry has no receipt: {first}"
);
let run = first["run"].clone();
assert_eq!(run["at_current_head"], true);
let journal = books.journal();
let again = audit(&mut w, "full", "2026-03-31");
assert_eq!(again["status"], "already_run");
assert_eq!(again["run"]["id"], run["id"]);
assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]);
assert_eq!(again["findings"], first["findings"]);
assert_eq!(books.journal(), journal, "asking again journals nothing");
// The workpaper is the same twice, JSON and PDF.
let id = run["id"].clone();
let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
assert_eq!(one["workpaper"], two["workpaper"]);
assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]);
assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]);
let pdf = w.fetch(&one["pdf"]);
assert!(pdf.starts_with(b"%PDF"));
// The audit fixes nothing and moves no figure; an entry makes it stale.
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
assert_eq!(tb["balanced"], true);
let extra = post(&mut w, "2026-03-09", "Stamps", "10.00");
confirm_post(&mut w, extra);
let third = audit(&mut w, "full", "2026-03-31");
assert_eq!(third["status"], "audited");
assert_ne!(third["run"]["id"], run["id"]);
let _ = bare;
}
/// A reading that differs from the entry never counts; one that agrees
/// counts only at the bar, and the bar is read when asked.
#[test]
fn attestations_count_against_the_bar_when_asked() {
let (_books, mut w, paper, rid, _) = setup("p8-bar");
let good = answer_of(&w.call(
"books.attest_receipt",
json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05",
"method": {"model": "opus", "reasoning_effort": "high"}}),
))
.unwrap();
assert_eq!(good["attestation"]["counted"], true, "{good}");
assert_eq!(good["attestation"]["by"], "test");
let wrong = answer_of(&w.call(
"books.attest_receipt",
json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05",
"method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}),
))
.unwrap();
assert_eq!(wrong["attestation"]["disagrees"], true);
assert_eq!(wrong["attestation"]["counted"], false);
assert!(
wrong["why_not_counted"]
.as_str()
.unwrap()
.contains("differs")
);
let low = answer_of(&w.call(
"books.review_entry",
json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}),
))
.unwrap();
assert_eq!(low["attestation"]["counted"], false);
assert!(low["why_not_counted"].as_str().unwrap().contains("high"));
let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap();
assert_eq!(none["attestation"]["counted"], false, "no claim, no count");
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
assert_eq!(listed["attestations"].as_array().unwrap().len(), 4);
assert_eq!(listed["counted"], 1);
assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true");
let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2);
// The bar is read when asked: lowered, the medium review counts.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}),
))
.unwrap();
let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true");
assert_eq!(after["attestations"][0]["counted"], true);
assert_eq!(after["attestations"][1]["counted"], false);
// Refused: bad method words, an unposted entry.
let bad = w.call(
"books.review_entry",
json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}),
);
assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort"));
}
/// A period is attested only on an audit that still stands.
#[test]
fn a_period_is_attested_on_a_standing_audit() {
let (_books, mut w, paper, _, _) = setup("p8-period");
answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap();
let method = json!({"reasoning_effort": "high"});
let none = w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
);
assert!(answer_of(&none).unwrap_err().contains("books.audit"));
let run = audit(&mut w, "month", "2026-03-31");
let ok = answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
))
.unwrap();
assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]);
assert_eq!(
ok["attestation"]["counted"], true,
"a month needs the effort only"
);
// A change since the audit: refused until it is run again.
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
confirm_post(&mut w, extra);
let stale = w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
);
assert!(answer_of(&stale).unwrap_err().contains("books.audit"));
let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap();
assert_eq!(listing["run"]["at_current_head"], false);
let _ = paper;
}
/// D-9: the CPA package waits for a counted audit for the handoff; a
/// reason overrides it and is journaled as a decision; the bar is the
/// whole bar for a year.
#[test]
fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() {
let (_books, mut w, paper, rid, bare) = setup("p8-cpa");
// Unset, the package warns until the books hold an audit run; the
// owner who wants it refused from the start says so.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "refuse"}]}),
))
.unwrap();
let export = |w: &mut common::Wire, extra: Value| {
let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"],
"include_receipts": false, "include_tins": false});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
w.call("books.export_cpa_package", args)
};
let refused = export(&mut w, json!({}));
let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap();
assert_eq!(err["code"], "audit_required", "{err}");
assert_eq!(err["remedy_tool"], "books.audit");
// A reason lets it go, and is a decision in the journal.
let before = answer_of(&w.call("books.decisions", json!({}))).unwrap();
answer_of(&export(
&mut w,
json!({"without_audit_reason": " the CPA asked for it early "}),
))
.unwrap();
let after = answer_of(&w.call("books.decisions", json!({}))).unwrap();
assert_eq!(
after["decisions"].as_array().unwrap().len(),
before["decisions"].as_array().unwrap().len() + 1
);
assert!(after.to_string().contains("without a counted audit"));
// An audit alone does not open it; nor one attested below the bar.
answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap();
audit(&mut w, "cpa_handoff", "2026-03-31");
assert!(answer_of(&export(&mut w, json!({}))).is_err());
answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}),
))
.unwrap();
assert!(
answer_of(&export(&mut w, json!({}))).is_err(),
"a year-level audit needs the adversarial check and the verifiers"
);
answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": {"reasoning_effort": "high",
"adversarial": true, "verifier_agents": 2}}),
))
.unwrap();
let made = answer_of(&export(&mut w, json!({}))).unwrap();
assert!(made["file"]["sha256"].is_string(), "{made}");
// A change since: the audit no longer stands, and the package waits again.
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
confirm_post(&mut w, extra);
assert!(answer_of(&export(&mut w, json!({}))).is_err());
// Under warn the package is made and says so.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}),
))
.unwrap();
let warned = answer_of(&export(&mut w, json!({}))).unwrap();
assert!(
warned["warnings"]
.to_string()
.contains("No counted audit stands behind this package"),
"{warned}"
);
let _ = (paper, rid, bare);
}
/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`)
/// open with no upgrade; the audit's preview works, its run and every
/// attestation are refused with format_upgrade_pending, nothing is
/// journaled, and the CPA package is not held up by an audit it cannot ask
/// for.
#[test]
fn audits_wait_for_the_upgrade() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p8_before");
let only = tempfile::tempdir().unwrap();
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
common::copy_private(&src.join("blobs"), &only.path().join("blobs"));
let books = Books::placed("p8-waits", only.path());
let mut w = books.session("p8");
let preview = answer_of(&w.call(
"books.audit",
json!({"scope": "full", "through": "2026-02-28"}),
))
.unwrap();
assert_eq!(preview["status"], "preview");
let journal = books.journal();
for (tool, args) in [
(
"books.audit",
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
),
(
"books.review_entry",
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
),
(
"books.attest_receipt",
json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00",
"date": "2026-01-01"}),
),
] {
let e = answer_of(&w.call(tool, args)).unwrap_err();
assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}");
}
assert_eq!(books.journal(), journal, "a refusal journals nothing");
// The package is held up only by what it always was: an open draft.
let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"],
"include_receipts": false, "include_tins": false});
let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap();
assert!(made["file"]["sha256"].is_string(), "{made}");
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
assert!(listed["attestations"].as_array().unwrap().is_empty());
}
+10
View File
@@ -909,6 +909,16 @@ fn books_p2_served_cut_over_whole() {
json!({"attachment_id": 1, "fields": {"payee": "Harbor Design LLC",
"amount": "12.34", "date": "2026-02-01"}}),
),
// An audit run and an attestation (audits, P8): an older version
// doesn't know the events.
(
"books.audit",
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
),
(
"books.review_entry",
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
),
];
for (tool, args) in &refused {
let e = a.call(tool, args.clone()).unwrap_err();
+1 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 134, "expected 134 tools, got {}", tools.len());
assert_eq!(tools.len(), 140, "expected 140 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
+22
View File
@@ -1146,6 +1146,28 @@ fn happy_path(name: &str) -> Mcp {
json!({"period_id": p, "confirmed": true}),
);
// P8: an audit of the month, its workpaper, and the claims that stand
// behind it.
let audit_run = m.ok(
"books.audit",
json!({"scope": "month", "through": "2026-01-31", "confirmed": true}),
)["run"]["id"]
.as_u64()
.unwrap();
m.ok("books.workpaper", json!({"id": audit_run}));
let audited = m.attach(Some(e1), b"audit receipt");
m.ok(
"books.attest_receipt",
json!({"attachment_id": audited, "entry_id": e1, "amount": "10000.00",
"date": "2026-01-02", "method": {"reasoning_effort": "high"}}),
);
m.ok(
"books.review_entry",
json!({"entry_id": e1, "method": {"reasoning_effort": "high"}}),
);
m.ok("books.attestations", json!({}));
m.ok("books.attest_period", json!({"period": "2026-01"}));
// How the books are kept: start, conventions, decisions, open items,
// a template and an approval, and the settings file moving in.
m.ok("books.start", json!({}));
+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;
+46
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 once the books hold an audit run, warn before.",
},
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,25 @@ 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`: `Some(true)` for refuse,
/// `Some(false)` for warn, `None` when unset (the package then refuses
/// once the books hold an audit run, and warns before: the owner has
/// not begun the programme).
pub fn cpa_package_requires_audit(&self) -> Option<bool> {
match self.str("policy.cpa_package_requires_audit") {
Some("warn") => Some(false),
Some(_) => Some(true),
None => None,
}
}
/// `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));
}
+49
View File
@@ -449,6 +449,55 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Draft #{id} discarded: {}", s(p, "reason")),
None,
),
// Audits and attestations (plan P8): claims about the books, not
// figures.
"audit.run" => ev(
icon::MAGNIFYING_GLASS,
format!(
"Audit {} through {}",
s(p, "scope").replace('_', " "),
s(p, "through")
),
None,
),
"receipt.attested" => ev(
icon::CHECK_CIRCLE,
if p.get("disagrees").and_then(|v| v.as_bool()) == Some(true) {
format!(
"{} read {} against entry #{}: it disagrees",
s(p, "by"),
attachment_name(b, att),
p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0)
)
} else {
format!(
"{} attested {} for entry #{}",
s(p, "by"),
attachment_name(b, att),
p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0)
)
},
Some(Route::Document(att)),
),
"entry.reviewed" => ev(
icon::CHECK_CIRCLE,
format!(
"{} reviewed entry #{}",
s(p, "by"),
p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0)
),
p.get("entry_id").and_then(|v| v.as_u64()).map(Route::Entry),
),
"period.attested" => ev(
icon::CHECK_CIRCLE,
format!(
"{} attested period #{} on audit #{}",
s(p, "by"),
p.get("period_id").and_then(|v| v.as_u64()).unwrap_or(0),
p.get("audit_run_id").and_then(|v| v.as_u64()).unwrap_or(0)
),
None,
),
"entry.settles" => {
let settles: Vec<String> = p
.get("settles")
+5 -1
View File
@@ -88,7 +88,11 @@ fn says_update(text: &str) {
#[test]
fn the_viewer_says_update_for_newer_books_and_corrupt_for_damaged_ones() {
for (tag, line_type, payload) in [
("type", "period.attested", json!({"key": "company.name"})),
(
"type",
"ledger.not_yet_written",
json!({"key": "company.name"}),
),
(
"format",
"journal.format_set",
+2
View File
@@ -25,6 +25,7 @@ pub mod labels;
pub mod pdf;
pub mod report;
pub mod templates;
pub mod workpaper;
pub mod xlsx;
pub use asset_register::{asset_register_pdf, asset_register_xlsx};
@@ -43,4 +44,5 @@ pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
pub use templates::{ChartTemplate, EntityType, chart_template};
pub use workpaper::{Workpaper, WorkpaperCheck, WorkpaperFinding, workpaper_pdf};
pub use xlsx::{XlsxReport, report_to_xlsx};
+145
View File
@@ -0,0 +1,145 @@
//! The audit workpaper as a PDF (plan P8; rules.md B-45).
//!
//! ```text
//! GOAL: one document a CPA or the owner can read that says what was
//! checked, through which date and state of the books, and every
//! finding; the same audit gives the same bytes.
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
//! trail); CONVENTION (the statements' type and page).
//! INVARIANTS:
//! 1. Deterministic bytes: no clock, no random id; every text is data,
//! never markup.
//! 2. The findings appear in the order given, each with severity, code,
//! subject and message; the checks table lists every check the
//! programme runs, found or not.
//! ```
use crate::pdf::PdfError;
use serde::Serialize;
use std::sync::OnceLock;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
/// One finding as the page shows it.
#[derive(Clone, Debug, Serialize)]
pub struct WorkpaperFinding {
/// failure, warning or info.
pub severity: String,
/// The finding code.
pub code: String,
/// What it is about, in words.
pub subject: String,
/// What was found.
pub message: String,
}
/// One check the programme runs.
#[derive(Clone, Debug, Serialize)]
pub struct WorkpaperCheck {
/// The finding code.
pub code: String,
/// failure, warning or info.
pub severity: String,
/// When it is reported.
pub predicate: String,
}
/// Everything the page shows.
#[derive(Clone, Debug, Serialize)]
pub struct Workpaper {
/// The company's name.
pub company: String,
/// The page's title.
pub title: String,
/// The audit's scope.
pub scope: String,
/// The date it covers.
pub through: String,
/// The books' figure head.
pub through_seq: u64,
/// The programme's version.
pub program_version: String,
/// The trial balance's hash.
pub tb_hash: String,
/// The findings' hash.
pub findings_sha256: String,
/// One line: how many of each severity.
pub summary: String,
/// The findings.
pub findings: Vec<WorkpaperFinding>,
/// The checks.
pub checks: Vec<WorkpaperCheck>,
}
static TEMPLATE: &str = include_str!("../templates/workpaper.typ");
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
/// Render the workpaper as PDF bytes.
pub fn workpaper_pdf(w: &Workpaper) -> Result<Vec<u8>, PdfError> {
let data = serde_json::to_string(w).map_err(|e| PdfError::Compile(e.to_string()))?;
let mut inputs = Dict::new();
inputs.insert("data".into(), data.into_value());
let doc = engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Workpaper {
Workpaper {
company: "Wanderland Labs LLC".into(),
title: "Audit workpaper".into(),
scope: "month".into(),
through: "2026-10-31".into(),
through_seq: 412,
program_version: "audit-0.1.0-1".into(),
tb_hash: "ab".repeat(32),
findings_sha256: "cd".repeat(32),
summary: "1 failure, 0 warnings".into(),
findings: vec![WorkpaperFinding {
severity: "failure".into(),
code: "evidence_missing".into(),
subject: "entry 7".into(),
message: "entry 7 (2026-10-02) has no receipt: *not markup* #strong[x]".into(),
}],
checks: vec![WorkpaperCheck {
code: "evidence_missing".into(),
severity: "failure".into(),
predicate: "a posted entry the policy covers has no receipt".into(),
}],
}
}
/// The same workpaper renders to the same bytes, a PDF.
#[test]
fn workpaper_pdf_is_deterministic() {
let a = workpaper_pdf(&sample()).unwrap();
let b = workpaper_pdf(&sample()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, b);
}
/// A different finding is a different document.
#[test]
fn a_different_finding_is_a_different_document() {
let mut w = sample();
let a = workpaper_pdf(&w).unwrap();
w.findings.clear();
assert_ne!(a, workpaper_pdf(&w).unwrap());
}
}
@@ -0,0 +1,77 @@
// An audit workpaper: what was checked, through which date and which
// books' state, and every finding with its code and severity.
//
// Everything arrives as data in sys.inputs.data (JSON), built by
// ledger-import::workpaper. The template only typesets: no arithmetic, and
// no text is ever parsed as markup. It holds no clock: the same data gives
// the same bytes.
//
// Shape: { company, title, scope, through, through_seq, program_version,
// tb_hash, findings_sha256, summary: str,
// findings: [{ severity, code, subject, message }],
// checks: [{ code, severity, predicate }] }
#let d = json(bytes(sys.inputs.data))
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 9pt, fill: ink, number-width: "tabular", hyphenate: false)
#set page(
paper: "us-letter",
margin: (x: 0.8in, top: 0.8in, bottom: 0.8in),
footer: context {
set text(7.5pt, fill: quiet)
grid(
columns: (1fr, auto),
[#d.program_version · head #d.through_seq],
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#align(center)[
#text(15pt, fill: luma(45), d.company) \
#v(4pt)
#text(10.5pt, weight: "bold", d.title) \
#v(2pt)
Through #d.through · scope #d.scope
]
#v(12pt)
#grid(
columns: (auto, 1fr),
row-gutter: 5pt,
column-gutter: 12pt,
text(fill: quiet)[Books' figure head], [#d.through_seq],
text(fill: quiet)[Trial balance hash], text(size: 7.5pt)[#d.tb_hash],
text(fill: quiet)[Findings hash], text(size: 7.5pt)[#d.findings_sha256],
text(fill: quiet)[Result], [#d.summary],
)
#v(12pt)
#text(10pt, weight: "bold")[Findings]
#v(4pt)
#if d.findings.len() == 0 [
No findings.
] else [
#table(
columns: (auto, auto, auto, 1fr),
stroke: (x: none, y: hair),
inset: (x: 4pt, y: 4pt),
table.header([*Severity*], [*Code*], [*About*], [*Finding*]),
..d.findings.map(f => (f.severity, f.code, f.subject, f.message)).flatten(),
)
]
#v(14pt)
#text(10pt, weight: "bold")[What was checked]
#v(4pt)
#table(
columns: (auto, auto, 1fr),
stroke: (x: none, y: hair),
inset: (x: 4pt, y: 4pt),
table.header([*Code*], [*Severity*], [*True when it is reported*]),
..d.checks.map(c => (c.code, c.severity, c.predicate)).flatten(),
)
@@ -0,0 +1,33 @@
# Attestations and audit
An audit is the books read as an auditor reads them, through a day, in one run that gives the same findings for the same books. An attestation is a claim by a checker that it looked at something and found it right. The audit finds; the attestations stand behind it. The CPA package waits for both.
The receipt rule in force: {evidence_rule}
## Running an audit
- books.audit { through, scope } without confirmed lists the checks and changes nothing; show it to the human. Then call it again with confirmed: true and the preview. It files the workpaper (JSON and PDF, category workpaper) and journals the run. books.workpaper { id } gives it back: the JSON, and the PDF handed over (run its download command to save it).
- scope: month (a month-end), annual (a fiscal year), cpa_handoff (the year, for the CPA package), full (everything from the start of the books).
- It checks the trial balance, the accounting equation, the profit and loss against equity, the cash flow's footing, the A/R and A/P control accounts, closes, schedules and depreciation, the asset register, every posted entry against the receipt rule, open drafts, tax categories, 1099 decisions and the settings; then the journal's chain, the stored files, the access log and the backups. A finding is a failure (the books are wrong or cannot be shown right), a warning (unfinished or a rule not met) or a note (listed so the workpaper is whole: a reversal rests on its original's receipt).
- Fix findings with journaled corrections (a reversal, a receipt attached, a decision recorded), never by editing: posted entries stay as they are. Then audit again.
- The same books, day and scope give the run that exists: asking again files nothing. The books are "the same" while no entry, document, close, settings or role change has been made; filing a receipt, an audit or an attestation does not change them.
- Books that have not declared audits refuse a confirmed audit and every attestation with format_upgrade_pending until the owner's upgrade (books.start says so). Nothing else about them changes.
## Attesting
Use separate verifier agents; none of them should be the one that made the entry. For each receipt:
1. books.get_receipt for the extracted text, then its page image, and read the total and the date off the receipt itself.
2. books.attest_receipt { attachment_id, entry_id, amount, date, method, note }. The books compare what you read with the entry's amounts and date. A difference is recorded as disagrees and never counts: never bend your reading to fit.
3. books.review_entry { entry_id, method } for each posted entry, once its lines, accounts, party and evidence have been read whole.
4. books.attest_period { period, method } last, on a stand-still: it is refused unless an audit through the period's end still stands, which it does only while nothing has been posted, created, closed or set since the audit. If it is refused, audit again.
method is your own claim: model, reasoning_effort (low, medium, high, xhigh, max), adversarial (true when the verifier was set to find what is wrong), verifier_agents (how many independent ones checked). The books record it and cannot verify it; say what is true.
## What counts
books.attestations lists every claim with counted: whether it meets policy.attestation_bar as the bar stands when you ask. The default bar is reasoning effort high; a year-level audit (annual, cpa_handoff, full) also needs the check to be adversarial and at least two independent verifier agents. A bar change changes what counts, earlier claims too: nothing is versioned. A claim whose receipt reading disagrees with the entry never counts.
## The CPA package
Under policy.cpa_package_requires_audit = refuse (the default once the books hold an audit run; before one, the package is made and warns), books.export_cpa_package is refused unless an audit for the handoff (scope cpa_handoff or full) stands at the current books with a period attested on it at the bar. Pass without_audit_reason only when the human says to hand it over anyway; the reason is journaled as a decision. Under warn the package is made and says so.
+6 -5
View File
@@ -1,7 +1,8 @@
Audit the books for scope {{scope}} (month, annual, cpa_handoff or full). Treat the books as something to disprove: look for what is wrong.
1. books.verify: the chain, the replay, the database and every stored file.
2. books.control_check: the receivable and payable controls agree with their documents.
3. books.digest for the scope's period, and read every posted entry in it against its evidence: books.search_entries, books.get_entry, books.get_receipt. An entry that needs a receipt and has none, or whose receipt shows another amount, date or party, is a finding.
4. books.trial_balance, books.profit_and_loss and books.balance_sheet for the period; compare them with the previous period and explain each large movement.
5. Report findings to the human as a list: what, where (entry or account), why it matters, and the tool that fixes it. Fix nothing without the human's confirmation; corrections are reversing entries, not edits.
1. books.audit with scope {{scope}} and the day the audit covers: preview it, show the human what it will check, then run it with the preview. It checks the statements, the control accounts, closes, schedules, assets, every posted entry against the receipt policy, drafts, tax categories, 1099 decisions, settings, the chain, the stored files, the access log and the backups. books.workpaper gives the findings back.
2. books.verify and books.control_check: the chain, the replay and every stored file, and the receivable and payable controls against their documents.
3. books.digest for the scope's period, and read every posted entry in it against its evidence: books.search_entries, books.get_entry, books.get_receipt (its text first, then the page image). An entry that needs a receipt and has none, or whose receipt shows another amount, date or party, is a finding.
4. Use separate verifier agents, none of them the one that made the entry. For each receipt, books.attest_receipt with the amount and date as read off it; for each entry, books.review_entry; say truthfully how each checked (method). A reading that differs from the entry never counts: never bend it to fit.
5. books.trial_balance, books.profit_and_loss and books.balance_sheet for the period; compare them with the previous period and explain each large movement.
6. Report findings to the human as a list: what, where (entry or account), why it matters, and the tool that fixes it. Fix nothing without the human's confirmation; corrections are reversing entries, not edits. Then audit again, and when it is clean books.attest_period; books.attestations shows which claims count at the bar.
+7 -1
View File
@@ -40,11 +40,14 @@ pub(crate) enum Guard {
DuplicateVendorRef,
/// The first invoice number cannot change once one is issued.
FirstNumberLocked,
/// No audit stands at the books' current head to rest this on (plan
/// P8): run `books.audit`.
AuditRequired,
}
impl Guard {
/// Every guard, for the registry test.
pub(crate) const ALL: [Guard; 10] = [
pub(crate) const ALL: [Guard; 11] = [
Guard::StalePreview,
Guard::FormatUpgradePending,
Guard::ReceiptRequired,
@@ -55,6 +58,7 @@ impl Guard {
Guard::UntaggedControlLine,
Guard::DuplicateVendorRef,
Guard::FirstNumberLocked,
Guard::AuditRequired,
];
/// The code an agent reads.
@@ -70,6 +74,7 @@ impl Guard {
Guard::UntaggedControlLine => "untagged_control_line",
Guard::DuplicateVendorRef => "duplicate_vendor_ref",
Guard::FirstNumberLocked => "first_number_locked",
Guard::AuditRequired => "audit_required",
}
}
@@ -91,6 +96,7 @@ impl Guard {
Guard::UntaggedControlLine => "books.control_check",
Guard::DuplicateVendorRef => "books.search_entries",
Guard::FirstNumberLocked => "books.settings",
Guard::AuditRequired => "books.audit",
}
.to_string()
}
+4
View File
@@ -87,6 +87,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
),
("1099", include_str!("../handbook/1099.md")),
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
(
"attestations-and-audit",
include_str!("../handbook/attestations-and-audit.md"),
),
(
"settings-and-conventions",
include_str!("../handbook/settings-and-conventions.md"),
+75 -5
View File
@@ -119,8 +119,11 @@ pub(crate) fn tool_steps(
let with_entry = |id: u64| Some(json!({ "entry_id": id }));
// A preview: what is missing first, then the human confirms, then the
// same call with the digest.
// An audit already run is not previewed: its run is the answer.
let already_run = tool == "books.audit"
&& result.get("status").and_then(Value::as_str) == Some("already_run");
if let (false, Some(preview), Some(args)) = (
confirmed,
confirmed || already_run,
result.get("preview").and_then(Value::as_str),
args,
) {
@@ -301,12 +304,79 @@ pub(crate) fn tool_steps(
}
}
}
"books.close_period" => steps.push(Step::tool(
"books.verify",
None,
"Verify the books after a close: the chain, the replay and every stored file.",
"books.close_period" => {
let period = result
.get("period_id")
.or_else(|| args.and_then(|a| a.get("period_id")))
.and_then(Value::as_u64);
steps.push(Step::tool(
"books.audit",
Some(json!({ "scope": "month" })),
"Audit the books through the month's end; the attestation rests on that run.",
false,
));
steps.push(Step::tool(
"books.attest_period",
period.map(|p| json!({ "period": p.to_string() })),
"Once the audit is run and the receipts read, attest the period you closed.",
false,
));
}
"books.audit" => {
if let Some(id) = result.pointer("/run/id").and_then(Value::as_u64) {
steps.push(Step::tool(
"books.workpaper",
Some(json!({ "id": id })),
"Read the workpaper: what was checked and every finding. Fix findings with journaled corrections, then audit again.",
false,
));
steps.push(Step::tool(
"books.attest_receipt",
None,
"Have separate verifier agents read each receipt (books.get_receipt, then its page image) and attest it.",
false,
));
steps.push(Step::tool(
"books.review_entry",
None,
"Review each posted entry whole.",
false,
));
steps.push(Step::tool(
"books.attest_period",
None,
"Then attest the period this audit covers.",
false,
));
}
}
"books.attest_receipt" => steps.push(Step::tool(
"books.review_entry",
args.and_then(|a| a.get("entry_id"))
.map(|e| json!({ "entry_id": e })),
"Review the whole entry the receipt supports.",
false,
)),
"books.review_entry" => steps.push(Step::tool(
"books.attestations",
None,
"See which claims count at the bar.",
false,
)),
"books.attest_period" => {
steps.push(Step::tool(
"books.attestations",
None,
"See which claims count at the bar before the CPA package.",
false,
));
steps.push(Step::tool(
"books.export_cpa_package",
None,
"When the year is audited and attested at the bar, hand the books to the CPA.",
false,
));
}
"books.export_cpa_package" => {
if let Some(cmd) = result.pointer("/file/download").and_then(Value::as_str) {
steps.push(Step {
+9 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 134] = [
pub const TABLE: [(&str, Needs); 140] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -203,6 +203,14 @@ pub const TABLE: [(&str, Needs); 134] = [
// P5
("books.journal_history", R),
("books.preview_entry", R),
// P8: audits and attestations (an audit previews, then runs; a claim
// that someone checked is a write, not a post).
("books.audit", C),
("books.workpaper", R),
("books.attest_receipt", W),
("books.review_entry", W),
("books.attest_period", W),
("books.attestations", R),
];
/// What `tool` needs, given its arguments; None for a tool this server
+3
View File
@@ -585,6 +585,7 @@ impl BooksServer {
+ Self::write_offs_router()
+ Self::drafts_router()
+ Self::journal_router()
+ Self::audit_router()
}
/// The settings in force for this request: the books' own, taken from
@@ -1496,6 +1497,8 @@ pub use kept::SettingKeyOut;
mod assets;
#[path = "tools/attachments.rs"]
mod attachments;
#[path = "tools/audit.rs"]
mod audit_tools;
#[path = "tools/chart.rs"]
mod chart;
#[path = "tools/documents.rs"]
+8 -1
View File
@@ -61,7 +61,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 134] = [
let table: [(&str, Hints); 140] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -199,6 +199,13 @@ fn annotations_complete() {
("books.discard_draft", D),
("books.journal_history", R),
("books.preview_entry", R),
// P8: audits and attestations.
("books.audit", W),
("books.workpaper", R),
("books.attest_receipt", W),
("books.review_entry", W),
("books.attest_period", W),
("books.attestations", R),
];
let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len());
+780
View File
@@ -0,0 +1,780 @@
//! The audit group (plan P8; rules.md B-45): an audit run over the books,
//! its workpaper, and the claims that someone checked.
//!
//! ```text
//! GOAL: the whole re-audit through the tools: one programme of checks
//! that gives the same findings and the same workpaper for the same
//! books, and attestations that rest on a head that has not moved.
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
//! (an audit is a run, not a job), IZ-1k (the head).
//! INVARIANTS:
//! 1. `books.audit` previews (what it will check; nothing changes),
//! then with the preview's digest runs the checks, files the
//! workpaper (JSON and PDF) as documents and journals `audit.run`.
//! 2. The same books (the figure head), day, scope and programme give
//! the run that exists: nothing is filed or journaled twice.
//! 3. An attestation is a claim by the caller named by its token; the
//! method it gives is self-reported and the books say so.
//! 4. `counted` is read against `policy.attestation_bar` as it stands
//! when asked, never stored.
//! 5. A period is attested only on an audit at the current head.
//! 6. Nothing here moves a figure.
//! ```
use super::*;
use ledger_core::audit::{self, AttestationBar, AuditInput, Effort, Finding, Method, Scope};
use ledger_store::audit::{self as saudit, Attested, AuditRun, BackupStanding, Claim};
use sha2::{Digest, Sha256};
fn sha_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// The caller's token name, else the default token's.
fn caller_name() -> String {
CALLER
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string())
}
fn method_of(m: Option<AttestMethodParam>) -> Result<Method, String> {
let m = m.unwrap_or_default();
let reasoning_effort = match m.reasoning_effort.as_deref() {
None => None,
Some(e) => Some(Effort::parse(e).ok_or_else(|| {
format!("reasoning_effort {e:?} is not low, medium, high, xhigh or max")
})?),
};
if m.verifier_agents == Some(0) {
return Err("verifier_agents is at least 1".into());
}
Ok(Method {
model: m
.model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
reasoning_effort,
adversarial: m.adversarial,
verifier_agents: m.verifier_agents,
})
}
fn method_out(m: &Method) -> AttestMethodParam {
AttestMethodParam {
model: m.model.clone(),
reasoning_effort: m.reasoning_effort.map(|e| e.key().to_string()),
adversarial: m.adversarial,
verifier_agents: m.verifier_agents,
}
}
fn finding_out(f: &Finding) -> AuditFindingOut {
AuditFindingOut {
severity: f.severity.key().into(),
code: f.code.into(),
subject: f.subject.describe(),
message: f.message.clone(),
}
}
/// Every check the programme runs.
fn checks() -> Vec<AuditCheckOut> {
audit::CODES
.iter()
.chain(saudit::CODES)
.map(|r| AuditCheckOut {
code: r.code.into(),
severity: r.severity.key().into(),
predicate: r.predicate.into(),
})
.collect()
}
fn run_out(store: &Store, run: &AuditRun) -> Result<AuditRunOut, String> {
Ok(AuditRunOut {
id: run.id,
through: run.through.to_string(),
through_seq: run.through_seq,
scope: run.scope.key().into(),
program_version: run.program_version.clone(),
tb_hash: run.tb_hash.clone(),
findings_sha256: run.findings_sha256.clone(),
workpaper_attachment: run.workpaper_attachment,
workpaper_pdf: run.workpaper_pdf,
at_current_head: store.audit_is_current(run).map_err(err)?,
})
}
fn summary(findings: &[Finding]) -> String {
let n = |s: audit::Severity| findings.iter().filter(|f| f.severity == s).count();
format!(
"{} failure(s), {} warning(s), {} note(s)",
n(audit::Severity::Failure),
n(audit::Severity::Warning),
n(audit::Severity::Info)
)
}
/// Why an attestation does not count at this bar, in words.
fn why_not(bar: &AttestationBar, m: &Method, full: bool, disagrees: bool) -> Option<String> {
if bar.counts(m, full, disagrees) {
return None;
}
if disagrees {
return Some(
"the amount or date read off the receipt differs from the entry's: it is recorded and never counts"
.into(),
);
}
let mut why = Vec::new();
if !m
.reasoning_effort
.is_some_and(|e| e >= bar.reasoning_effort)
{
why.push(format!(
"reasoning_effort must be at least {}",
bar.reasoning_effort.key()
));
}
if full {
if bar.adversarial && m.adversarial != Some(true) {
why.push("the check must be adversarial".into());
}
if !m.verifier_agents.is_some_and(|n| n >= bar.verifier_agents) {
why.push(format!(
"at least {} independent verifier agents",
bar.verifier_agents
));
}
}
Some(format!(
"below policy.attestation_bar ({}): {}",
bar.describe(),
why.join("; ")
))
}
/// An attestation as the tools list it.
fn attestation_out(
store: &Store,
a: &saudit::Attestation,
bar: &AttestationBar,
) -> Result<AttestationOut, String> {
let mut out = AttestationOut {
seq: a.seq,
kind: a.kind().into(),
by: a.by.clone(),
at: a.at.clone(),
attachment_id: None,
entry_id: None,
period_id: None,
audit_run_id: None,
amount_checked: None,
date_checked: None,
disagrees: false,
method: method_out(&a.method),
note: a.note.clone(),
counted: false,
};
let (full, disagrees) = match &a.what {
Attested::Receipt {
attachment_id,
entry_id,
amount_checked,
date_checked,
disagrees,
} => {
out.attachment_id = Some(*attachment_id);
out.entry_id = Some(*entry_id);
out.amount_checked = Some(amount_checked.to_string());
out.date_checked = Some(date_checked.to_string());
out.disagrees = *disagrees;
(false, *disagrees)
}
Attested::Entry { entry_id } => {
out.entry_id = Some(*entry_id);
(false, false)
}
Attested::Period {
period_id,
audit_run_id,
} => {
out.period_id = Some(*period_id);
out.audit_run_id = Some(*audit_run_id);
let scope = store
.audit_run(*audit_run_id)
.map_err(err)?
.map(|r| r.scope);
(scope.is_some_and(Scope::full_bar), false)
}
};
out.counted = bar.counts(&a.method, full, disagrees);
Ok(out)
}
/// What an attestation's tool answers.
fn attest_out(store: &Store, a: &saudit::Attestation) -> Result<AttestOut, String> {
let bar = store.ledger().settings().attestation_bar();
let o = attestation_out(store, a, &bar)?;
let full = o
.audit_run_id
.and_then(|id| store.audit_run(id).ok().flatten())
.is_some_and(|r| r.scope.full_bar());
let why_not_counted = why_not(&bar, &a.method, full, o.disagrees);
Ok(AttestOut {
attestation: o,
bar: bar.describe(),
why_not_counted,
})
}
impl BooksServer {
/// Whether the CPA package may be made (plan P8 step 4, D-9): on books
/// that declared `audits` and a policy of refuse, an audit for the
/// handoff (scope cpa_handoff or full) at the current head must have a
/// period attested on it at the bar. `Ok(None)` when it may; the
/// refusal's words otherwise.
pub(crate) fn cpa_audit_standing(&self, store: &Store) -> Result<Option<String>, String> {
if !store
.journal_format()
.has_feature(ledger_store::format::AUDITS)
{
return Ok(None);
}
let settings = store.ledger().settings();
let bar = settings.attestation_bar();
let attestations = store.attestations().map_err(err)?;
for run in store.audit_runs().map_err(err)?.iter().rev() {
if !run.scope.serves_handoff() || !store.audit_is_current(run).map_err(err)? {
continue;
}
let counted = attestations.iter().any(|a| {
matches!(a.what, Attested::Period { audit_run_id, .. } if audit_run_id == run.id)
&& bar.counts(&a.method, run.scope.full_bar(), false)
});
if counted {
return Ok(None);
}
}
Ok(Some(format!(
"no audit for the handoff (books.audit with scope cpa_handoff or full) stands at the books' current head with a period attested at the bar ({}); run the audit, have it verified (books.attest_receipt, books.review_entry, books.attest_period), or pass without_audit_reason",
bar.describe()
)))
}
/// The findings the programme gives the books through `through`: the
/// statements, then what only the store, the backups and the settings
/// can see. Reads only.
fn audit_findings(
&self,
store: &Store,
through: NaiveDate,
) -> Result<(Vec<Finding>, String), String> {
let ledger = store.ledger();
let cfg = self.settings();
let ps = package_settings(&cfg);
let receipts = store.entries_with_receipts().map_err(err)?;
// The statement of cash flows, from the first entry, with the
// books' role settings; none when they name no cash accounts.
let first = ledger
.posted_entries()
.filter(|e| e.date() <= through)
.map(|e| e.date())
.min();
let cash = match first {
Some(start) if !ps.cash_accounts.is_empty() => Some(
ledger_package::cash_flow_for(
ledger,
&ps,
Some(&start.to_string()),
Some(&through.to_string()),
&[],
&[],
&[],
today(),
)
.map(|s| s.reconciles),
),
_ => None,
};
// Vendors paid in a way that could need a 1099 and undecided; a
// year whose threshold isn't known is the stale-rule item's.
use chrono::Datelike;
let undecided: Vec<(String, rust_decimal::Decimal)> =
ledger_package::vendor_1099_report(ledger, &ps, through.year(), None, Some(through))
.map(|r| {
r.vendors
.iter()
.filter(|v| v.decision_needed)
.map(|v| (v.name.clone(), v.paid))
.collect()
})
.unwrap_or_default();
let problems = settings_problems(ledger, &cfg);
let mut findings = audit::statements(
ledger,
&AuditInput {
settings: ledger.settings(),
receipts: &receipts,
cash_flow: cash.as_ref(),
settings_problems: &problems,
undecided_1099: &undecided,
},
through,
);
findings.extend(store.integrity_findings());
let standing = self.backups.standing(store);
findings.extend(saudit::backup_findings(
&standing
.targets
.iter()
.map(|t| BackupStanding {
target: format!("{} {}", t.target, t.at),
stale: t.stale,
last_error: t.last_error.clone(),
drill_error: t.drill_error.clone(),
})
.collect::<Vec<_>>(),
));
audit::sort(&mut findings);
let tb = ledger_core::reports::trial_balance_as_of(ledger, through);
let tb_hash = sha_hex(&serde_json::to_vec(&tb).map_err(err)?);
Ok((findings, tb_hash))
}
}
#[tool_router(router = audit_router, vis = "pub(super)")]
impl BooksServer {
/// ```text
/// GOAL: the whole programme of checks over the books through a day,
/// its workpaper filed, the run journaled.
/// SOURCE: plan/served.md P8; B-45; D-9, D-53.
/// INVARIANTS:
/// 1. Without `confirmed`, a preview that lists the checks and
/// changes nothing; with it, the preview's digest is required.
/// 2. Idempotent on the figure head, day, scope and programme.
/// 3. The workpaper is JSON and PDF, filed as documents (category
/// workpaper), the PDF with deterministic bytes.
/// SCENARIOS:
/// 1. IF the books haven't declared audits: the run is refused with
/// format_upgrade_pending; the preview still lists the checks.
/// 2. IF the same audit is asked at the same head: SHOULD return
/// the first run, status already_run.
/// ```
#[tool(
name = "books.audit",
title = "Audit the books",
description = "Audit the books through a day (through, default today) at a scope: month, annual, cpa_handoff (the year, for the CPA package) or full. It checks the statements, the A/R and A/P control accounts, closes, schedules, assets, each posted entry against the receipt policy, open drafts, tax categories, 1099 decisions and settings, then the journal, stored files, access log and backups. Without confirmed, a preview of the checks, changing nothing. With confirmed and the preview, it files the workpaper (books.workpaper) and journals the run; the same books, day and scope give the run that exists. Attestations rest on a run only while no entry, document, close, settings or role change follows it",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn audit(&self, params: Parameters<AuditParams>) -> Result<Json<AuditOut>, String> {
let p = params.0;
let scope = Scope::parse(&p.scope).ok_or_else(|| {
format!(
"bad scope {:?}: expected month, annual, cpa_handoff or full",
p.scope
)
})?;
let through = self.as_of(p.through.as_deref())?;
let confirmed = p.confirmed.unwrap_or(false);
let mut store = self.lock()?;
let head = store.figure_head().map_err(err)?;
let digest = self.preview_of(&store, "books.audit", confirmed, p.preview.as_deref())?;
if let Some(run) = store.find_audit_run(through, head, scope).map_err(err)? {
let findings = self.stored_findings(&store, &run)?;
return Ok(Json(AuditOut {
status: "already_run".into(),
preview: Some(digest),
head,
checks: checks(),
summary: Some(summary_of(&findings)),
run: Some(run_out(&store, &run)?),
findings,
}));
}
if !confirmed {
return Ok(Json(AuditOut {
status: "preview".into(),
preview: Some(digest),
head,
checks: checks(),
run: None,
findings: Vec::new(),
summary: None,
}));
}
store
.ensure_feature(ledger_store::format::AUDITS)
.map_err(err)?;
let (findings, tb_hash) = self.audit_findings(&store, through)?;
let findings_sha256 = sha_hex(&serde_json::to_vec(&findings).map_err(err)?);
let summary = summary(&findings);
let company = self.settings().company.name.clone();
let rows: Vec<AuditFindingOut> = findings.iter().map(finding_out).collect();
let check_rows = checks();
let paper = serde_json::json!({
"company": company,
"title": "Audit workpaper",
"scope": scope.key(),
"through": through.to_string(),
"through_seq": head,
"program_version": saudit::PROGRAM_VERSION,
"tb_hash": tb_hash,
"findings_sha256": findings_sha256,
"summary": summary,
"findings": rows,
"checks": check_rows,
});
let json_bytes = serde_json::to_vec_pretty(&paper).map_err(err)?;
let pdf_bytes = ledger_import::workpaper_pdf(&ledger_import::Workpaper {
company,
title: "Audit workpaper".into(),
scope: scope.key().into(),
through: through.to_string(),
through_seq: head,
program_version: saudit::PROGRAM_VERSION.into(),
tb_hash: tb_hash.clone(),
findings_sha256: findings_sha256.clone(),
summary: summary.clone(),
findings: rows
.iter()
.map(|f| ledger_import::WorkpaperFinding {
severity: f.severity.clone(),
code: f.code.clone(),
subject: f.subject.clone(),
message: f.message.clone(),
})
.collect(),
checks: check_rows
.iter()
.map(|c| ledger_import::WorkpaperCheck {
code: c.code.clone(),
severity: c.severity.clone(),
predicate: c.predicate.clone(),
})
.collect(),
})
.map_err(err)?;
let stem = format!("workpaper-{}-{through}-{head}", scope.key());
let describe = format!(
"Audit workpaper, scope {}, through {through}, books at figure head {head} ({}).",
scope.key(),
saudit::PROGRAM_VERSION
);
let (wp_json, _, _) = store
.file_document(
"workpaper",
&format!("{stem}.json"),
"application/json",
&json_bytes,
Some(&describe),
)
.map_err(err)?;
let (wp_pdf, _, _) = store
.file_document(
"workpaper",
&format!("{stem}.pdf"),
"application/pdf",
&pdf_bytes,
Some(&describe),
)
.map_err(err)?;
// A month's or a year's workpaper belongs with its period.
if matches!(scope, Scope::Month | Scope::Annual)
&& let Some(period) = store
.ledger()
.list_periods()
.into_iter()
.filter(|q| q.start <= through && through <= q.end)
.min_by_key(|q| (q.end - q.start).num_days())
.map(|q| q.id)
{
for a in [wp_json, wp_pdf] {
let _ = store.link_attachment(a, ledger_store::Subject::Period(period));
}
}
let run = store
.record_audit_run(saudit::NewAuditRun {
through,
through_seq: head,
scope,
tb_hash,
findings_sha256,
workpaper_attachment: Some(wp_json),
workpaper_pdf: Some(wp_pdf),
})
.map_err(err)?;
Ok(Json(AuditOut {
status: "audited".into(),
preview: Some(digest),
head,
checks: check_rows,
summary: Some(summary),
run: Some(run_out(&store, &run)?),
findings: rows,
}))
}
/// ```text
/// GOAL: an audit run's workpaper, whole.
/// SOURCE: plan/served.md P8 step 3; B-45.
/// INVARIANTS:
/// 1. Read-only; the JSON is the one filed, the PDF is handed over.
/// ```
#[tool(
name = "books.workpaper",
title = "An audit's workpaper",
description = "The workpaper of an audit run (books.audit's run.id): what was checked, through which day and state of the books, and every finding, as the JSON that was filed, with the PDF handed over (run its download command to save it in a folder). Changes nothing.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn workpaper(
&self,
params: Parameters<WorkpaperParams>,
) -> Result<Json<WorkpaperOut>, String> {
let store = self.lock()?;
let run = store.audit_run(params.0.id).map_err(err)?.ok_or_else(|| {
format!(
"no audit run {}: books.attestations lists the ones attested",
params.0.id
)
})?;
let json_id = run
.workpaper_attachment
.ok_or("this audit run filed no workpaper")?;
let bytes = store.attachment_bytes(json_id).map_err(err)?;
let workpaper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
let pdf = match run.workpaper_pdf {
Some(id) => {
let bytes = store.attachment_bytes(id).map_err(err)?;
Some(self.hand_over(
&book_name(&store),
&format!(
"workpaper-{}-{}-{}.pdf",
run.scope.key(),
run.through,
run.through_seq
),
"application/pdf",
bytes,
)?)
}
None => None,
};
Ok(Json(WorkpaperOut {
run: run_out(&store, &run)?,
workpaper,
pdf,
}))
}
/// ```text
/// GOAL: a checker's claim that a receipt supports an entry.
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
/// INVARIANTS:
/// 1. The reading is compared with the entry's amount and date; a
/// difference is recorded, marked, and never counts.
/// 2. The method is the client's claim, self-reported.
/// ```
#[tool(
name = "books.attest_receipt",
title = "Attest a receipt",
description = "Record that you read a receipt and it supports a posted entry: amount and date as you read them off it (get_receipt text, then page_image), and how you checked (method: model, reasoning_effort, adversarial, verifier_agents; your own claim, which the books record but cannot verify). The books compare the reading with the entry's amounts and date; a difference is recorded as disagrees and never counts. Whether it counts is weighed against policy.attestation_bar when asked.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn attest_receipt(
&self,
params: Parameters<AttestReceiptParams>,
) -> Result<Json<AttestOut>, String> {
let p = params.0;
let amount = parse_amount(&p.amount)?;
let date = NaiveDate::parse_from_str(p.date.trim(), "%Y-%m-%d")
.map_err(|_| format!("{:?} isn't a date: give YYYY-MM-DD", p.date))?;
let claim = Claim {
by: caller_name(),
method: method_of(p.method)?,
note: p.note,
};
let mut store = self.lock()?;
let a = store
.attest_receipt(p.attachment_id, p.entry_id, amount, date, claim)
.map_err(err)?;
Ok(Json(attest_out(&store, &a)?))
}
/// ```text
/// GOAL: a checker's claim that it reviewed an entry whole.
/// SOURCE: plan/served.md P8 step 4; B-45.
/// ```
#[tool(
name = "books.review_entry",
title = "Review an entry",
description = "Record that you reviewed a posted entry whole: its lines, accounts, party and evidence, and how you checked (method, as for books.attest_receipt; your own claim). Changes no figure.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn review_entry(
&self,
params: Parameters<ReviewEntryParams>,
) -> Result<Json<AttestOut>, String> {
let p = params.0;
let claim = Claim {
by: caller_name(),
method: method_of(p.method)?,
note: p.note,
};
let mut store = self.lock()?;
let a = store.review_entry(p.entry_id, claim).map_err(err)?;
Ok(Json(attest_out(&store, &a)?))
}
/// ```text
/// GOAL: a checker's claim that a period is right, resting on an
/// audit run at the books' current head.
/// SOURCE: plan/served.md P8 step 4; B-45; D-9; IZ-1k.
/// INVARIANTS:
/// 1. Refused unless an audit through the period's end stands at
/// the current head.
/// ```
#[tool(
name = "books.attest_period",
title = "Attest a period",
description = "Record that you checked a period (its id or name from books.list_periods), resting on an audit (books.audit) through its end that still stands: refused when an entry, document, close, settings or role change has been made since the audit, or no audit covers it. Say how you checked (method, as for books.attest_receipt; your own claim). A year-level audit (annual, cpa_handoff, full) is held to the whole policy.attestation_bar: reasoning effort, adversarial, and the number of independent verifier agents.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn attest_period(
&self,
params: Parameters<AttestPeriodParams>,
) -> Result<Json<AttestOut>, String> {
let p = params.0;
let claim = Claim {
by: caller_name(),
method: method_of(p.method)?,
note: p.note,
};
let mut store = self.lock()?;
let named = p.period.trim();
let period = store
.ledger()
.list_periods()
.into_iter()
.find(|q| q.name == named || q.id.to_string() == named)
.map(|q| q.id)
.ok_or_else(|| format!("no period {named:?}: books.list_periods lists them"))?;
let (a, _) = store.attest_period(period, claim).map_err(|e| match e {
ledger_store::StoreError::AuditRefused(why) => serde_json::json!({
"code": "audit_required",
"message": why,
"remedy_tool": "books.audit",
})
.to_string(),
other => other.to_string(),
})?;
Ok(Json(attest_out(&store, &a)?))
}
/// ```text
/// GOAL: every claim that someone checked, and whether it counts.
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
/// INVARIANTS:
/// 1. `counted` is read against the bar as it stands now.
/// ```
#[tool(
name = "books.attestations",
title = "Attestations",
description = "List the attestations (receipts read, entries reviewed, periods attested), oldest first, each with who made it, how they say they checked, and counted: whether it meets policy.attestation_bar as the bar stands now (a change to the bar changes what counts, earlier claims too). through limits to entries dated, and periods ending, on or before a day; kind to receipt, entry or period. Changes nothing.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn attestations(
&self,
params: Parameters<AttestationsParams>,
) -> Result<Json<AttestationsOut>, String> {
let p = params.0;
let through = p
.through
.as_deref()
.map(|t| self.as_of(Some(t)))
.transpose()?;
if let Some(k) = p.kind.as_deref()
&& !["receipt", "entry", "period"].contains(&k)
{
return Err("kind is receipt, entry or period".into());
}
let store = self.lock()?;
let ledger = store.ledger();
let bar = ledger.settings().attestation_bar();
let mut out = Vec::new();
for a in store.attestations().map_err(err)? {
if p.kind.as_deref().is_some_and(|k| k != a.kind()) {
continue;
}
if let Some(t) = through {
let on = match a.what {
Attested::Receipt { entry_id, .. } | Attested::Entry { entry_id } => {
ledger.get_entry(entry_id).map(|e| e.date())
}
Attested::Period { period_id, .. } => {
ledger.get_period(period_id).map(|q| q.end)
}
};
if on.is_none_or(|d| d > t) {
continue;
}
}
out.push(attestation_out(&store, &a, &bar)?);
}
Ok(Json(AttestationsOut {
bar: bar.describe(),
counted: out.iter().filter(|a| a.counted).count(),
attestations: out,
}))
}
}
impl BooksServer {
/// The findings of a run, as its filed workpaper holds them.
fn stored_findings(
&self,
store: &Store,
run: &AuditRun,
) -> Result<Vec<AuditFindingOut>, String> {
let Some(id) = run.workpaper_attachment else {
return Ok(Vec::new());
};
let bytes = store.attachment_bytes(id).map_err(err)?;
let paper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
serde_json::from_value(paper["findings"].clone()).map_err(err)
}
}
fn summary_of(findings: &[AuditFindingOut]) -> String {
let n = |s: &str| findings.iter().filter(|f| f.severity == s).count();
format!(
"{} failure(s), {} warning(s), {} note(s)",
n("failure"),
n("warning"),
n("info")
)
}
+1 -1
View File
@@ -91,7 +91,7 @@ pub(crate) const PROMPTS: [PromptDef; 6] = [
PromptDef {
name: "audit",
title: "Audit the books",
description: "The adversarial procedure over a scope: verify, control checks, evidence against entries.",
description: "The adversarial procedure over a scope: the audit run, verify, control checks, evidence against entries, attestations.",
args: &[(
"scope",
"month, annual, cpa_handoff or full.",
+51 -1
View File
@@ -1147,6 +1147,55 @@ impl BooksServer {
let p = params.0;
let as_of = self.as_of(p.as_of.as_deref())?;
let mut store = self.lock()?;
// A package no counted audit stands behind is not final either
// (plan P8, D-9): on books that declared `audits`, refused under
// policy.cpa_package_requires_audit = refuse unless a reason is
// given, which is journaled as a decision; under warn it goes and
// says so. Books that haven't declared the feature are as they
// were: there is no audit to ask for.
let mut audit_warning: Option<String> = None;
if let Some(why) = self.cpa_audit_standing(&store)? {
let refuse = match store.ledger().settings().cpa_package_requires_audit() {
Some(explicit) => explicit,
None => !store.audit_runs().map_err(|e| e.to_string())?.is_empty(),
};
if refuse {
let Some(reason) = p
.without_audit_reason
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty())
else {
return Err(serde_json::json!({
"code": "audit_required",
"message": why,
"remedy_tool": "books.audit",
})
.to_string());
};
let by = CALLER
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string());
store
.record_decision(&ledger_core::conventions::NewDecision {
subject: format!("CPA package as of {as_of} without a counted audit"),
decision_md: format!(
"The CPA package as of {as_of} was exported without an audit at the books' current head attested at the bar. Reason: {reason}"
),
decided_by: vec![by],
decided_on: today(),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: Vec::new(),
})
.map_err(|e| e.to_string())?;
} else {
audit_warning = Some(format!(
"No counted audit stands behind this package: {why}."
));
}
}
// A package with a draft missing from its figures is not final
// (plan P4, 4.8): refused unless the human says why it goes anyway,
// and that is journaled as a decision.
@@ -1245,7 +1294,8 @@ impl BooksServer {
"The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again."
),
};
let warnings = package.warnings;
let mut warnings = package.warnings;
warnings.extend(audit_warning);
if package.iif_changes > 0 {
note.push_str(&format!(
" {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
+280 -1
View File
@@ -4323,6 +4323,13 @@ pub struct ExportCpaPackageParams {
/// missing from its figures is not the CPA's final.
#[serde(default)]
pub allow_open_drafts: Option<AllowOpenDraftsParam>,
/// Hand the package over although no audit for the handoff (scope
/// cpa_handoff or full) at the books' current head has its period
/// attested at the bar, and say why (journaled as a decision). Without
/// it the export is refused while `policy.cpa_package_requires_audit`
/// is refuse and books have declared audits.
#[serde(default)]
pub without_audit_reason: Option<String>,
/// As of this day ([`AS_OF_DOC`]); the period reports cover the
/// fiscal year that holds it, through it.
#[schemars(description = AS_OF_DOC)]
@@ -5241,7 +5248,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams {
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, or "all" for the whole handbook in order. Omit for the index.
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, attestations-and-audit, or "all" for the whole handbook in order. Omit for the index.
#[serde(default)]
pub section: Option<String>,
}
@@ -5856,3 +5863,275 @@ pub struct AllowOpenDraftsParam {
/// before the Figma invoice arrives"); journaled as a decision.
pub reason: String,
}
// P8
/// How an attestation was made, as the client says. The books record the
/// claim; they cannot check it.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestMethodParam {
/// The model that checked, as it names itself ("claude-opus-4").
#[serde(default)]
pub model: Option<String>,
/// How hard it thought: low, medium, high, xhigh or max.
#[serde(default)]
pub reasoning_effort: Option<String>,
/// True when the check was adversarial: a verifier set to find what
/// is wrong, not to confirm it.
#[serde(default)]
pub adversarial: Option<bool>,
/// How many independent verifier agents checked.
#[serde(default)]
pub verifier_agents: Option<u32>,
}
/// Parameters for `books.audit`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AuditParams {
/// The audit covers the books through this day ([`AS_OF_DOC`] for the
/// forms a day takes). Defaults to today.
#[serde(default)]
pub through: Option<String>,
/// month (a month-end attestation), annual (a fiscal year),
/// cpa_handoff (the year, for the CPA package) or full (everything
/// from the start of the books).
pub scope: String,
/// True: run the audit and file its workpaper. Without it, a preview
/// that lists what will be checked and changes nothing.
#[serde(default)]
pub confirmed: Option<bool>,
/// The `preview` the call without `confirmed` returned. Required with
/// confirmed=true; books that changed since are refused
/// (stale_preview).
#[serde(default)]
pub preview: Option<String>,
}
/// One check the audit runs.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditCheckOut {
/// The finding code the check reports.
pub code: String,
/// failure, warning or info.
pub severity: String,
/// What is true when it is reported.
pub predicate: String,
}
/// One thing an audit found.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditFindingOut {
/// failure, warning or info.
pub severity: String,
/// The finding code.
pub code: String,
/// What it is about: `entry 7`, `account 12`, `books`.
pub subject: String,
/// What was found, with the exact figures.
pub message: String,
}
/// An audit run as the journal records it.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditRunOut {
/// Its id.
pub id: u64,
/// The books it covers through this day.
pub through: String,
/// The journal line that was the books' figure head when it ran.
pub through_seq: u64,
/// month, annual, cpa_handoff or full.
pub scope: String,
/// The audit programme's version.
pub program_version: String,
/// SHA-256 of the trial balance through the day.
pub tb_hash: String,
/// SHA-256 of the findings.
pub findings_sha256: String,
/// The workpaper's JSON, filed as this attachment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workpaper_attachment: Option<u64>,
/// The workpaper's PDF, filed as this attachment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workpaper_pdf: Option<u64>,
/// True while no entry, document, close, settings or role change has
/// been made since: attestations rest on a run only while it is.
pub at_current_head: bool,
}
/// Result of `books.audit`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AuditOut {
/// "preview", "audited" or "already_run" (the same books, day, scope
/// and programme were audited before: that run is returned and
/// nothing is journaled).
pub status: String,
/// The digest of what this shows: pass it back as `preview` with
/// confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// The books' figure head the audit rests on.
pub head: u64,
/// The checks it runs (a preview lists them; so does a run).
pub checks: Vec<AuditCheckOut>,
/// The run, once made.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<AuditRunOut>,
/// What it found; empty when the books are whole. Not in a preview.
#[serde(default)]
pub findings: Vec<AuditFindingOut>,
/// How many failures, warnings and notes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
/// Parameters for `books.workpaper`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkpaperParams {
/// The audit run's id (`books.audit`'s run.id).
pub id: u64,
}
/// Result of `books.workpaper`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct WorkpaperOut {
/// The run.
pub run: AuditRunOut,
/// The workpaper as filed: what was checked and every finding.
pub workpaper: serde_json::Value,
/// The workpaper's PDF, handed over.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf: Option<HandedFile>,
}
/// Parameters for `books.attest_receipt`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestReceiptParams {
/// The filed receipt.
pub attachment_id: u64,
/// The posted entry it supports.
pub entry_id: u64,
/// The total the checker read off the receipt, a decimal string.
pub amount: String,
/// The date the checker read off the receipt, YYYY-MM-DD.
pub date: String,
/// How the check was made.
#[serde(default)]
pub method: Option<AttestMethodParam>,
/// Anything the next reader should know.
#[serde(default)]
pub note: Option<String>,
}
/// Parameters for `books.review_entry`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReviewEntryParams {
/// The posted entry reviewed.
pub entry_id: u64,
/// How the review was made.
#[serde(default)]
pub method: Option<AttestMethodParam>,
/// Anything the next reader should know.
#[serde(default)]
pub note: Option<String>,
}
/// Parameters for `books.attest_period`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestPeriodParams {
/// The period: its id or its name ("2026-10"), from
/// `books.list_periods`.
pub period: String,
/// How the attestation was made.
#[serde(default)]
pub method: Option<AttestMethodParam>,
/// Anything the next reader should know.
#[serde(default)]
pub note: Option<String>,
}
/// One attestation, and whether it counts.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AttestationOut {
/// Its journal line.
pub seq: u64,
/// receipt, entry or period.
pub kind: String,
/// Who made it: the token's name.
pub by: String,
/// When, UTC.
pub at: String,
/// The attachment, for a receipt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment_id: Option<u64>,
/// The entry, for a receipt or an entry review.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<u64>,
/// The period, for a period attestation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_id: Option<u64>,
/// The audit run, for a period attestation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_run_id: Option<u64>,
/// The amount read off the receipt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount_checked: Option<String>,
/// The date read off the receipt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date_checked: Option<String>,
/// True when the reading differs from the entry's amount or date: it
/// never counts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disagrees: bool,
/// How the client says it checked (self-reported; the books cannot
/// verify it).
pub method: AttestMethodParam,
/// The note.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Whether it meets `policy.attestation_bar` as the bar stands now.
pub counted: bool,
}
/// Result of `books.attest_receipt`, `books.review_entry` and
/// `books.attest_period`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttestOut {
/// What was recorded.
pub attestation: AttestationOut,
/// The bar it was weighed against.
pub bar: String,
/// Why it does not count, when it does not.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub why_not_counted: Option<String>,
}
/// Parameters for `books.attestations`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestationsParams {
/// Only attestations of entries dated, or periods ending, on or
/// before this day.
#[serde(default)]
pub through: Option<String>,
/// Only this kind: receipt, entry or period.
#[serde(default)]
pub kind: Option<String>,
}
/// Result of `books.attestations`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttestationsOut {
/// The bar `counted` is weighed against, as it stands now.
pub bar: String,
/// The attestations, oldest first.
pub attestations: Vec<AttestationOut>,
/// How many count.
pub counted: usize,
}
+129 -1
View File
@@ -78,7 +78,12 @@
//! pages); a posted entry a member paid personally (a credit on a
//! "due to"/"owed to" liability) that no entry records settling
//! (Todo, one per entry; `entry.settles` only, no FIFO guess,
//! IZ-18).
//! IZ-18); for books that declared `audits` (plan P8): a closed
//! period no `period.attested` names (Todo, one per period, "Month-end
//! attestation due"), the last fiscal year that ended with entries,
//! closed, and no audit run of scope cpa_handoff or full through its
//! end ("Full audit due before the CPA handoff"), else with no run of
//! scope annual, cpa_handoff or full ("Annual audit due").
//! 3. Nothing else: books with none of those yield no items, except
//! that a figure behind an item that can't be computed (an
//! amortization plan past chrono's last day, an aging past exact
@@ -1375,10 +1380,133 @@ pub fn find_with(
});
}
}
out.extend(audit_items(books, &journal, today, settings));
out.sort_by_key(|a| a.severity);
out
}
/// The audit items (plan P8, R-F), for books that declared `audits` (the
/// others can't act on them): a closed month with no attestation, a closed
/// fiscal year with no audit for the handoff, and a finished year with no
/// annual audit. Read from the journal's `audit.run` and `period.attested`
/// lines, so the viewer and `books.start` read the same facts.
fn audit_items(
books: &impl PackageBooks,
journal: &[JournalLine],
today: NaiveDate,
settings: &Settings,
) -> Vec<Attention> {
let declared = journal
.iter()
.rev()
.find(|l| l.line_type == "journal.format_set")
.is_some_and(|l| {
l.payload["features"]
.as_array()
.is_some_and(|f| f.iter().any(|x| x == "audits"))
});
if !declared {
return Vec::new();
}
let ledger = books.ledger();
let attested: HashSet<u64> = journal
.iter()
.filter(|l| l.line_type == "period.attested")
.filter_map(|l| l.payload["period_id"].as_u64())
.collect();
// (scope, through) of every run.
let runs: Vec<(String, NaiveDate)> = journal
.iter()
.filter(|l| l.line_type == "audit.run")
.filter_map(|l| {
let through =
NaiveDate::parse_from_str(l.payload["through"].as_str()?, "%Y-%m-%d").ok()?;
Some((l.payload["scope"].as_str()?.to_string(), through))
})
.collect();
let mut out = Vec::new();
for p in ledger.list_periods() {
if p.status != PeriodStatus::Closed || attested.contains(&p.id) {
continue;
}
out.push(Attention {
severity: Severity::Todo,
kind: "month_attestation_due",
title: format!("Month-end attestation due: {}", p.name),
detail: format!(
"{} ({} to {}) is closed and no one has attested it.",
p.name,
date(p.start),
date(p.end)
),
target: Target::Journal,
prompt: format!(
"In wanderbooks, {} is closed and has no attestation. Audit the books through {} (books.audit with scope month), then have separate verifier agents read each receipt (books.get_receipt, then attest_receipt), review the entries (books.review_entry) and attest the period (books.attest_period). Read the attestations-and-audit section of the handbook first.",
p.name,
p.end
),
tool: Some((
"books.audit".into(),
serde_json::json!({ "scope": "month", "through": p.end.to_string() }),
)),
});
}
// The last fiscal year that ended, if the books have entries in it.
let (this_start, _) = settings.company.fiscal_year_containing(today);
if let Some(before) = this_start.pred_opt() {
let (fy_start, fy_end) = settings.company.fiscal_year_containing(before);
let has_entries = ledger
.posted_entries()
.any(|e| e.date() >= fy_start && e.date() <= fy_end);
let closed = ledger
.list_periods()
.iter()
.any(|p| p.status == PeriodStatus::Closed && p.end >= fy_end);
let year_audit = |scopes: &[&str]| {
runs.iter()
.any(|(s, t)| scopes.contains(&s.as_str()) && *t >= fy_end)
};
if has_entries && closed && !year_audit(&["cpa_handoff", "full"]) {
out.push(Attention {
severity: Severity::Todo,
kind: "handoff_audit_due",
title: format!("Full audit due before the CPA handoff: {}", fy_end.year()),
detail: format!(
"The fiscal year ended {} and is closed; the CPA package waits for an audit for the handoff attested at the bar.",
date(fy_end)
),
target: Target::Journal,
prompt: format!(
"In wanderbooks, the fiscal year ending {fy_end} is closed and has no audit for the CPA handoff. Run books.audit with scope cpa_handoff through {fy_end}, have independent adversarial verifier agents attest the receipts and the entries, and attest the year (books.attest_period). Read the attestations-and-audit section of the handbook first."
),
tool: Some((
"books.audit".into(),
serde_json::json!({ "scope": "cpa_handoff", "through": fy_end.to_string() }),
)),
});
} else if has_entries && !year_audit(&["annual", "cpa_handoff", "full"]) {
out.push(Attention {
severity: Severity::Todo,
kind: "annual_audit_due",
title: format!("Annual audit due: {}", fy_end.year()),
detail: format!(
"The fiscal year ended {} and no audit covers it.",
date(fy_end)
),
target: Target::Journal,
prompt: format!(
"In wanderbooks, the fiscal year ending {fy_end} has no annual audit. Run books.audit with scope annual through {fy_end} and fix what it finds. Read the attestations-and-audit section of the handbook first."
),
tool: Some((
"books.audit".into(),
serde_json::json!({ "scope": "annual", "through": fy_end.to_string() }),
)),
});
}
}
out
}
/// One entry, indexed the way the items read it.
struct Entry {
id: EntryId,
+795
View File
@@ -0,0 +1,795 @@
//! The audit's journal and the integrity checks (plan P8; rules.md B-45):
//! `audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`,
//! what "the current head" means, and the findings only the store can see.
//!
//! ```text
//! GOAL: an audit run is a journaled fact keyed by the books' figure
//! head, so the same books give the same run once, and an
//! attestation rests on a head that has not moved.
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
//! (an audit is a run, not a job), IZ-1k (the head).
//! INVARIANTS:
//! 1. The figure head is the seq of the last journal line that bears on
//! a figure: everything but the audit and attestation events, the
//! evidence filings (an attachment, a link, a statement, a
//! reconciliation, extraction, annotations) and the format
//! declaration. An audit's own filing never moves it.
//! 2. A run is idempotent on (through, figure head, scope, program
//! version): asking again returns the run that exists and journals
//! nothing.
//! 3. A period is attested only on a run at the current figure head
//! that covers it (its through date is on or after the period's
//! end).
//! 4. A receipt attestation whose amount or date differs from the
//! entry's is recorded, marked `disagrees`, and never counts.
//! 5. `counted` is never stored: it is read against
//! `policy.attestation_bar` as it stands when asked.
//! 6. The method is the client's own claim; the books keep it and say
//! so.
//! 7. Every write waits for the `audits` journal feature and journals
//! nothing when refused.
//! SCENARIOS:
//! 1. IF the same audit is asked twice at one head: SHOULD return the
//! first run both times.
//! 2. IF an entry posts after a run: SHOULD refuse attesting a period
//! on that run.
//! ```
use std::collections::BTreeSet;
use chrono::NaiveDate;
use ledger_core::audit::{Finding, Method, Scope, Severity, Subject};
use rusqlite::params;
use rust_decimal::Decimal;
use serde_json::Value;
use super::{Store, apply_line_to_sqlite};
use crate::error::StoreError;
/// The audit programme's version: the crate's, and a revision of its
/// checks. A run at another version is another run.
pub const PROGRAM_VERSION: &str = concat!("audit-", env!("CARGO_PKG_VERSION"), "-1");
/// Journal lines that do not bear on a figure (INVARIANT 1).
pub const NOT_FIGURES: &[&str] = &[
"audit.run",
"receipt.attested",
"entry.reviewed",
"period.attested",
"store.created",
"journal.format_set",
"journal.tail_repaired",
"attachment.linked",
"attachment.unlinked",
"attachment.withdrawn",
"attachment.text_extracted",
"attachment.annotated",
"receipt.attached",
"receipt.linked",
"receipt.unlinked",
"document.filed",
"statement.recorded",
"statement.voided",
"reconciliation.completed",
"reconciliation.voided",
"decision.recorded",
"open_item.raised",
"open_item.resolved",
"entry.settles",
];
/// The integrity checks' codes, severities and predicates (the
/// statements checks are `ledger_core::audit::CODES`).
pub const CODES: &[ledger_core::audit::CodeRow] = &[
ledger_core::audit::CodeRow {
code: "journal_damaged",
severity: Severity::Failure,
predicate: "the journal's hash chain does not verify",
},
ledger_core::audit::CodeRow {
code: "projection_differs",
severity: Severity::Failure,
predicate: "the live books or the database differ from a replay of the journal",
},
ledger_core::audit::CodeRow {
code: "blob_missing",
severity: Severity::Failure,
predicate: "a stored file the books name is not on disk",
},
ledger_core::audit::CodeRow {
code: "blob_damaged",
severity: Severity::Failure,
predicate: "a stored file's bytes do not match their SHA-256",
},
ledger_core::audit::CodeRow {
code: "access_log_damaged",
severity: Severity::Failure,
predicate: "the access log's chain does not verify",
},
ledger_core::audit::CodeRow {
code: "extraction_blob_missing",
severity: Severity::Warning,
predicate: "a file's extracted text is not on disk",
},
ledger_core::audit::CodeRow {
code: "duplicate_blob",
severity: Severity::Warning,
predicate: "two filed attachments, neither withdrawn, hold the same bytes",
},
ledger_core::audit::CodeRow {
code: "backup_stale",
severity: Severity::Warning,
predicate: "a backup target has not reached the books' head within a day",
},
ledger_core::audit::CodeRow {
code: "drill_failed",
severity: Severity::Failure,
predicate: "a backup failed, or failed its restore drill",
},
];
fn found(code: &'static str, subject: Subject, message: String) -> Finding {
let severity = CODES
.iter()
.find(|r| r.code == code)
.map_or(Severity::Failure, |r| r.severity);
Finding {
code,
severity,
subject,
message,
}
}
/// What the backups' standing says, one target at a time, for the audit
/// (the server reads the standing; the store does not know the backups).
#[derive(Clone, Debug)]
pub struct BackupStanding {
/// The target's kind and name, as the backups name it.
pub target: String,
/// It has not reached the head within a day.
pub stale: bool,
/// Its last run's error, if it failed.
pub last_error: Option<String>,
/// Its last restore drill's error, if it failed.
pub drill_error: Option<String>,
}
/// The backup findings: a target stale, failing, or failing its drill.
/// Books with no target set up have none (that is the owner's standing
/// choice, which `books.start` already says; an audit reports what the
/// backups it can see do).
pub fn backup_findings(targets: &[BackupStanding]) -> Vec<Finding> {
let mut out = Vec::new();
for t in targets {
if t.stale {
out.push(found(
"backup_stale",
Subject::Named(t.target.clone()),
format!("{} has not reached the books' head within a day", t.target),
));
}
if let Some(e) = &t.last_error {
out.push(found(
"drill_failed",
Subject::Named(t.target.clone()),
format!("{}'s last backup failed: {e}", t.target),
));
}
if let Some(e) = &t.drill_error {
out.push(found(
"drill_failed",
Subject::Named(t.target.clone()),
format!("{}'s restore drill failed: {e}", t.target),
));
}
}
out
}
/// An audit run as the journal records it (`audit.run`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditRun {
/// Its id (1 for the first).
pub id: u64,
/// The audit covers the books through this date.
pub through: NaiveDate,
/// The figure head it was made at ([`Store::figure_head`]).
pub through_seq: u64,
/// What it covers.
pub scope: Scope,
/// The programme's version ([`PROGRAM_VERSION`]).
pub program_version: String,
/// SHA-256 of the trial balance through the date.
pub tb_hash: String,
/// SHA-256 of the findings.
pub findings_sha256: String,
/// The workpaper's JSON, filed as a document.
pub workpaper_attachment: Option<u64>,
/// The workpaper's PDF, filed as a document.
pub workpaper_pdf: Option<u64>,
/// The journal line that recorded it.
pub seq: u64,
}
/// What a run is made from, before it has an id or a seq.
#[derive(Clone, Debug)]
pub struct NewAuditRun {
/// Through this date.
pub through: NaiveDate,
/// The figure head the findings were computed at.
pub through_seq: u64,
/// What it covers.
pub scope: Scope,
/// SHA-256 of the trial balance.
pub tb_hash: String,
/// SHA-256 of the findings.
pub findings_sha256: String,
/// The filed workpaper JSON.
pub workpaper_attachment: Option<u64>,
/// The filed workpaper PDF.
pub workpaper_pdf: Option<u64>,
}
/// What an attestation says it checked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Attested {
/// A receipt against an entry.
Receipt {
/// The filed receipt.
attachment_id: u64,
/// The entry it supports.
entry_id: u64,
/// The amount the checker read off it.
amount_checked: Decimal,
/// The date the checker read off it.
date_checked: NaiveDate,
/// The checker's reading differs from the entry's.
disagrees: bool,
},
/// An entry, reviewed whole.
Entry {
/// The entry.
entry_id: u64,
},
/// A period, on an audit run.
Period {
/// The period.
period_id: u64,
/// The run it rests on.
audit_run_id: u64,
},
}
/// A claim that someone checked, as the journal records it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Attestation {
/// The journal line.
pub seq: u64,
/// Who claims it: the caller's token name.
pub by: String,
/// When, UTC.
pub at: String,
/// How, as the client says.
pub method: Method,
/// A free note.
pub note: Option<String>,
/// What was checked.
pub what: Attested,
}
impl Attestation {
/// Its kind's word: `receipt`, `entry` or `period`.
pub fn kind(&self) -> &'static str {
match self.what {
Attested::Receipt { .. } => "receipt",
Attested::Entry { .. } => "entry",
Attested::Period { .. } => "period",
}
}
}
/// Who claims an attestation, how, and what they add.
#[derive(Clone, Debug)]
pub struct Claim {
/// The caller's token name.
pub by: String,
/// How, as the client says.
pub method: Method,
/// A free note.
pub note: Option<String>,
}
impl Claim {
fn into_attestation(self, what: Attested) -> Attestation {
Attestation {
seq: 0,
by: self.by,
at: now(),
method: self.method,
note: self
.note
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty()),
what,
}
}
}
fn refused(why: impl Into<String>) -> StoreError {
StoreError::AuditRefused(why.into())
}
fn now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
impl Store {
/// The seq of the last journal line that bears on a figure
/// (INVARIANT 1); zero for books with none.
pub fn figure_head(&self) -> Result<u64, StoreError> {
let quoted: Vec<String> = NOT_FIGURES.iter().map(|t| format!("'{t}'")).collect();
let sql = format!(
"SELECT COALESCE(MAX(seq), 0) FROM events WHERE type NOT IN ({})",
quoted.join(",")
);
let n: i64 = self.conn.query_row(&sql, [], |r| r.get(0))?;
Ok(n as u64)
}
/// The entries with at least one receipt filed on them or linked to
/// them, not withdrawn.
pub fn entries_with_receipts(&self) -> Result<BTreeSet<u64>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT entry_id FROM attachments WHERE withdrawn_at IS NULL AND entry_id IS NOT NULL
UNION
SELECT l.subject_id FROM attachment_links l JOIN attachments a ON a.id = l.attachment_id
WHERE l.subject_kind = 'entry' AND a.withdrawn_at IS NULL",
)?;
let ids = stmt
.query_map([], |r| r.get::<_, i64>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids.into_iter().map(|i| i as u64).collect())
}
/// What only the store can see: the chain, the database against a
/// replay, the stored files, the access log, extraction text and
/// duplicate files. Reads; changes nothing.
pub fn integrity_findings(&self) -> Vec<Finding> {
let mut out = Vec::new();
match self.verify() {
Ok(()) => {}
Err(StoreError::VerifyMismatch(why)) => out.push(found(
"projection_differs",
Subject::Books,
format!("the books differ from a replay of the journal: {why}"),
)),
Err(e) => out.push(found(
"journal_damaged",
Subject::Books,
format!("the journal does not verify: {e}"),
)),
}
let shas = |sql: &str| -> Vec<String> {
self.conn
.prepare(sql)
.and_then(|mut s| {
s.query_map([], |r| r.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()
})
.unwrap_or_default()
};
let mut blobs = shas("SELECT sha256 FROM blobs ORDER BY sha256");
blobs.dedup();
for sha in blobs {
if !self.blobs.exists(&sha) {
out.push(found(
"blob_missing",
Subject::Named(sha.chars().take(12).collect()),
format!("the stored file {sha} is not on disk"),
));
} else if let Err(StoreError::CorruptBlob(_)) = self.blobs.get(&sha) {
out.push(found(
"blob_damaged",
Subject::Named(sha.chars().take(12).collect()),
format!("the stored file {sha} does not match its SHA-256"),
));
}
}
if let Err(e) = crate::access::read(&self.data_dir) {
out.push(found(
"access_log_damaged",
Subject::Named("access log".into()),
format!("the access log does not verify: {e}"),
));
}
for sha in shas(
"SELECT DISTINCT text_sha256 FROM extractions WHERE text_sha256 IS NOT NULL ORDER BY text_sha256",
) {
if !self.blobs.exists(&sha) {
out.push(found(
"extraction_blob_missing",
Subject::Named(sha.chars().take(12).collect()),
format!("the extracted text {sha} is not on disk"),
));
}
}
let dups: Vec<(String, String)> = self
.conn
.prepare(
"SELECT blob_sha, GROUP_CONCAT(id) FROM
(SELECT blob_sha, id FROM attachments WHERE withdrawn_at IS NULL ORDER BY id)
GROUP BY blob_sha HAVING COUNT(*) > 1 ORDER BY blob_sha",
)
.and_then(|mut s| {
s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<Vec<_>, _>>()
})
.unwrap_or_default();
for (sha, ids) in dups {
out.push(found(
"duplicate_blob",
Subject::Named(sha.chars().take(12).collect()),
format!("attachments {ids} hold the same bytes ({sha})"),
));
}
out
}
/// Every audit run, oldest first.
pub fn audit_runs(&self) -> Result<Vec<AuditRun>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT id, through, through_seq, scope, program_version, tb_hash, findings_sha256,
workpaper_attachment, workpaper_pdf, seq FROM audits ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
r.get::<_, Option<i64>>(7)?,
r.get::<_, Option<i64>>(8)?,
r.get::<_, i64>(9)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (id, through, seq_at, scope, version, tb, fs, wp, pdf, seq) = row?;
out.push(AuditRun {
id: id as u64,
through: NaiveDate::parse_from_str(&through, "%Y-%m-%d")
.map_err(|_| StoreError::JournalCorrupt("audits.through".into()))?,
through_seq: seq_at as u64,
scope: Scope::parse(&scope)
.ok_or_else(|| StoreError::JournalCorrupt("audits.scope".into()))?,
program_version: version,
tb_hash: tb,
findings_sha256: fs,
workpaper_attachment: wp.map(|v| v as u64),
workpaper_pdf: pdf.map(|v| v as u64),
seq: seq as u64,
});
}
Ok(out)
}
/// One run.
pub fn audit_run(&self, id: u64) -> Result<Option<AuditRun>, StoreError> {
Ok(self.audit_runs()?.into_iter().find(|r| r.id == id))
}
/// The run that already answers an audit of `through` at `head`
/// (INVARIANT 2).
pub fn find_audit_run(
&self,
through: NaiveDate,
head: u64,
scope: Scope,
) -> Result<Option<AuditRun>, StoreError> {
Ok(self.audit_runs()?.into_iter().find(|r| {
r.through == through
&& r.through_seq == head
&& r.scope == scope
&& r.program_version == PROGRAM_VERSION
}))
}
/// Whether the books' figures are where `run` left them
/// (IZ-1k): nothing bearing on a figure has been journaled since.
pub fn audit_is_current(&self, run: &AuditRun) -> Result<bool, StoreError> {
Ok(run.through_seq == self.figure_head()?)
}
/// Record an audit run (`audit.run`). Waits for the `audits` feature.
pub fn record_audit_run(&mut self, new: NewAuditRun) -> Result<AuditRun, StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
if let Some(existing) = self.find_audit_run(new.through, new.through_seq, new.scope)? {
return Ok(existing);
}
for a in [new.workpaper_attachment, new.workpaper_pdf]
.into_iter()
.flatten()
{
self.attachment_state(a)?;
}
let id = self.audit_runs()?.last().map_or(1, |r| r.id + 1);
let mut run = AuditRun {
id,
through: new.through,
through_seq: new.through_seq,
scope: new.scope,
program_version: PROGRAM_VERSION.into(),
tb_hash: new.tb_hash,
findings_sha256: new.findings_sha256,
workpaper_attachment: new.workpaper_attachment,
workpaper_pdf: new.workpaper_pdf,
seq: 0,
};
let line = self.append_journal("audit.run", crate::meta::audit_run(&run))?;
run.seq = line.seq;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(run)
}
/// Every attestation, oldest first.
pub fn attestations(&self) -> Result<Vec<Attestation>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts,
method, amount_checked, date_checked, disagrees, note
FROM attestations ORDER BY seq",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<i64>>(4)?,
r.get::<_, Option<i64>>(5)?,
r.get::<_, String>(6)?,
r.get::<_, String>(7)?,
r.get::<_, String>(8)?,
r.get::<_, Option<String>>(9)?,
r.get::<_, Option<String>>(10)?,
r.get::<_, Option<i64>>(11)?,
r.get::<_, Option<String>>(12)?,
))
})?;
let bad = |w: &str| StoreError::JournalCorrupt(format!("attestations.{w}"));
let mut out = Vec::new();
for row in rows {
let (seq, kind, att, entry, period, run, by, at, method, amount, date, dis, note) =
row?;
let method: Value = serde_json::from_str(&method)?;
let what = match kind.as_str() {
"receipt" => Attested::Receipt {
attachment_id: att.ok_or_else(|| bad("attachment_id"))? as u64,
entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64,
amount_checked: amount
.and_then(|a| a.parse().ok())
.ok_or_else(|| bad("amount_checked"))?,
date_checked: date
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok())
.ok_or_else(|| bad("date_checked"))?,
disagrees: dis.unwrap_or(0) != 0,
},
"entry" => Attested::Entry {
entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64,
},
"period" => Attested::Period {
period_id: period.ok_or_else(|| bad("period_id"))? as u64,
audit_run_id: run.ok_or_else(|| bad("audit_run_id"))? as u64,
},
_ => return Err(bad("kind")),
};
out.push(Attestation {
seq: seq as u64,
by,
at,
method: crate::meta::method_from_value(&method)?,
note,
what,
});
}
Ok(out)
}
fn journal_attestation(
&mut self,
line_type: &str,
a: Attestation,
) -> Result<Attestation, StoreError> {
let (line_type, payload) = (line_type, crate::meta::attestation(&a));
let line = self.append_journal(line_type, payload)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(Attestation { seq: line.seq, ..a })
}
/// Attest that a receipt supports an entry (`receipt.attested`): the
/// checker's reading of its amount and date against the entry's. A
/// reading that differs is recorded as disagreeing and never counts
/// (INVARIANT 4).
pub fn attest_receipt(
&mut self,
attachment_id: u64,
entry_id: u64,
amount: Decimal,
date: NaiveDate,
claim: Claim,
) -> Result<Attestation, StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
let entry = self
.ledger
.get_entry(entry_id)
.ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?;
if entry.status() != ledger_core::EntryStatus::Posted {
return Err(refused(format!("entry {entry_id} is not posted")));
}
let (_, withdrawn) = self.attachment_state(attachment_id)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(attachment_id));
}
if !self
.attachment_links(attachment_id)?
.contains(&crate::store::Subject::Entry(entry_id))
{
return Err(refused(format!(
"attachment {attachment_id} is not filed on or linked to entry {entry_id}"
)));
}
let debits = entry
.lines()
.iter()
.filter(|l| l.side == ledger_core::Side::Debit)
.try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount));
let amount_agrees =
debits == Some(amount) || entry.lines().iter().any(|l| l.amount.amount == amount);
let disagrees = !amount_agrees || date != entry.date();
self.journal_attestation(
"receipt.attested",
claim.into_attestation(Attested::Receipt {
attachment_id,
entry_id,
amount_checked: amount,
date_checked: date,
disagrees,
}),
)
}
/// Record that an entry was reviewed whole (`entry.reviewed`).
pub fn review_entry(&mut self, entry_id: u64, claim: Claim) -> Result<Attestation, StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
let entry = self
.ledger
.get_entry(entry_id)
.ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?;
if entry.status() != ledger_core::EntryStatus::Posted {
return Err(refused(format!("entry {entry_id} is not posted")));
}
self.journal_attestation(
"entry.reviewed",
claim.into_attestation(Attested::Entry { entry_id }),
)
}
/// The run a period can be attested on: the latest at the current
/// figure head whose through date is on or after the period's end
/// (INVARIANT 3).
pub fn run_for_period(&self, period_id: u64) -> Result<AuditRun, StoreError> {
let period = self
.ledger
.get_period(period_id)
.ok_or_else(|| refused(format!("no period {period_id}")))?;
let head = self.figure_head()?;
let runs = self.audit_runs()?;
runs.into_iter()
.rev()
.find(|r| r.through_seq == head && r.through >= period.end)
.ok_or_else(|| {
refused(format!(
"no audit through {} or later stands at the books' current head: run books.audit first (an entry, a document, a close or a settings change since the last one moves the head)",
period.end
))
})
}
/// Attest a period on an audit run at the current head
/// (`period.attested`).
pub fn attest_period(
&mut self,
period_id: u64,
claim: Claim,
) -> Result<(Attestation, AuditRun), StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
let run = self.run_for_period(period_id)?;
let a = self.journal_attestation(
"period.attested",
claim.into_attestation(Attested::Period {
period_id,
audit_run_id: run.id,
}),
)?;
Ok((a, run))
}
}
/// Read an `audits` row for a replayed line.
pub(crate) fn insert_run(conn: &rusqlite::Connection, run: &AuditRun) -> Result<(), StoreError> {
conn.execute(
"INSERT INTO audits(id, through, through_seq, scope, program_version, tb_hash, findings_sha256,
workpaper_attachment, workpaper_pdf, seq)
VALUES (?,?,?,?,?,?,?,?,?,?)",
params![
run.id as i64,
run.through.to_string(),
run.through_seq as i64,
run.scope.key(),
run.program_version,
run.tb_hash,
run.findings_sha256,
run.workpaper_attachment.map(|v| v as i64),
run.workpaper_pdf.map(|v| v as i64),
run.seq as i64,
],
)?;
Ok(())
}
/// Write an `attestations` row for a replayed line.
pub(crate) fn insert_attestation(
conn: &rusqlite::Connection,
a: &Attestation,
) -> Result<(), StoreError> {
let (mut att, mut entry, mut period, mut run) = (None, None, None, None);
let (mut amount, mut date, mut dis) = (None, None, None);
match &a.what {
Attested::Receipt {
attachment_id,
entry_id,
amount_checked,
date_checked,
disagrees,
} => {
att = Some(*attachment_id as i64);
entry = Some(*entry_id as i64);
amount = Some(amount_checked.to_string());
date = Some(date_checked.to_string());
dis = Some(i64::from(*disagrees));
}
Attested::Entry { entry_id } => entry = Some(*entry_id as i64),
Attested::Period {
period_id,
audit_run_id,
} => {
period = Some(*period_id as i64);
run = Some(*audit_run_id as i64);
}
}
conn.execute(
"INSERT INTO attestations(seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts,
method, amount_checked, date_checked, disagrees, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
params![
a.seq as i64,
a.kind(),
att,
entry,
period,
run,
a.by,
a.at,
crate::meta::method_json(&a.method).to_string(),
amount,
date,
dis,
a.note,
],
)?;
Ok(())
}
+6
View File
@@ -162,6 +162,12 @@ pub enum StoreError {
)]
WriteOffNeedsEvidence,
/// An audit or an attestation the books refuse (plan P8; rules.md
/// B-45): a stale head, an entry not posted, a receipt not on the
/// entry. The words say what to do.
#[error("{0}")]
AuditRefused(String),
/// The requested base currency differs from the store's.
#[error("base currency mismatch: store is {0}, requested {1}")]
CurrencyMismatch(String, String),
+52
View File
@@ -66,6 +66,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
ATTACHMENT_TEXT,
DRAFTS_V2,
SOURCE_REVISIONS,
AUDITS,
];
/// Feature: `period.closed` closes to members with `"rounding":
@@ -105,6 +106,9 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
| "bills.discount_warn_days"
| "company.email"
| "company.website" => Some(DOCUMENTS_V2),
// P8: what counts as an attestation, and whether the CPA package
// waits for an audit.
"policy.attestation_bar" | "policy.cpa_package_requires_audit" => Some(AUDITS),
_ => None,
}
}
@@ -228,6 +232,17 @@ pub const DRAFTS_V2: &str = "drafts_v2";
/// declaration instead (rules.md B-27).
pub const SOURCE_REVISIONS: &str = "source_revisions";
/// Feature: `audits` (plan P8): `audit.run` (a programme of checks run
/// through a date, with the hash of its findings and its workpaper),
/// `receipt.attested`, `entry.reviewed` and `period.attested` (claims by
/// an authenticated client that it checked), and the two settings that
/// say what counts (`policy.attestation_bar`) and whether the CPA package
/// waits for an audit (`policy.cpa_package_requires_audit`). An older
/// version doesn't know those event types or settings; it stops at the
/// declaration instead of dropping the claims a handoff rests on
/// (rules.md B-27, B-45).
pub const AUDITS: &str = "audits";
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -253,6 +268,7 @@ pub const EVENT_TYPES: &[&str] = &[
"attachment.text_extracted",
"attachment.unlinked",
"attachment.withdrawn",
"audit.run",
"bill.created",
"bill.paid",
"class.added",
@@ -275,6 +291,7 @@ pub const EVENT_TYPES: &[&str] = &[
"entry.party_set",
"entry.posted",
"entry.reversed",
"entry.reviewed",
"entry.settles",
"entry.vendor_set",
"fx.rate_set",
@@ -298,10 +315,12 @@ pub const EVENT_TYPES: &[&str] = &[
"party.w9_set",
"payroll.run",
"payroll.taxes_remitted",
"period.attested",
"period.closed",
"period.created",
"project.set",
"receipt.attached",
"receipt.attested",
"receipt.linked",
"receipt.unlinked",
"reconciliation.completed",
@@ -1035,6 +1054,39 @@ mod tests {
assert_eq!(up.adds(), vec!["the journal feature source_revisions"]);
}
/// The version before P8 stops at a declaration of `audits` as newer,
/// never dropping an attestation a handoff rests on; books that
/// declared every earlier feature still wait for it (B-27, D-39).
#[test]
fn audits_is_its_own_feature() {
assert!(KNOWN_FEATURES.contains(&AUDITS));
let before: Vec<&str> = KNOWN_FEATURES
.iter()
.copied()
.filter(|f| *f != AUDITS)
.collect();
let l = line(
FORMAT_SET,
json!({"format": 2, "features": [DRAFTS_V2, AUDITS]}),
);
match declared_knowing(&l, &before) {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features audits")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
let older = JournalFormat {
format: 2,
features: before.iter().map(|f| f.to_string()).collect(),
};
assert!(matches!(
older.require_feature(AUDITS),
Err(StoreError::FormatUpgradePending { .. })
));
let up = older.upgrade().expect("waits for it");
assert_eq!(up.adds(), vec!["the journal feature audits"]);
}
/// This version's format: the newest it reads, every feature it
/// knows, each once, sorted (what new books and an upgrade declare).
#[test]
+1
View File
@@ -47,6 +47,7 @@ pub use error::StoreError;
pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT};
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use snapshot::{DiskCheck, Snapshot};
pub use store::audit;
pub use store::{
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, Extraction,
ExtractionStatus, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN,
+192
View File
@@ -729,6 +729,198 @@ pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> {
Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?))
}
// ---------------------------------------------------------------------------
// Audits and attestations (plan P8, feature `audits`)
// ---------------------------------------------------------------------------
/// A method as journaled: only the parts the client gave.
pub fn method_json(m: &ledger_core::audit::Method) -> Value {
let mut o = Map::new();
put(&mut o, "model", m.model.clone());
put(
&mut o,
"reasoning_effort",
m.reasoning_effort.map(|e| e.key()),
);
put(&mut o, "adversarial", m.adversarial);
put(&mut o, "verifier_agents", m.verifier_agents);
Value::Object(o)
}
/// Read a method. A key or an effort this version doesn't know is newer
/// when a line is at hand (B-27), else corrupt.
fn method_of(
line: Option<&crate::journal::JournalLine>,
m: &Value,
event: &str,
) -> Result<ledger_core::audit::Method, StoreError> {
let o = m.as_object().ok_or_else(|| corrupt(event, "method"))?;
let unknown = |what: String| match line {
Some(l) => crate::format::newer_content(l, what),
None => corrupt(event, "method"),
};
let mut out = ledger_core::audit::Method::default();
for (k, v) in o {
match k.as_str() {
"model" => {
out.model = Some(
v.as_str()
.ok_or_else(|| corrupt(event, "method.model"))?
.to_string(),
)
}
"reasoning_effort" => {
let s = v
.as_str()
.ok_or_else(|| corrupt(event, "method.reasoning_effort"))?;
out.reasoning_effort = Some(
ledger_core::audit::Effort::parse(s)
.ok_or_else(|| unknown(format!("the reasoning effort {s:?}")))?,
);
}
"adversarial" => {
out.adversarial = Some(
v.as_bool()
.ok_or_else(|| corrupt(event, "method.adversarial"))?,
)
}
"verifier_agents" => {
out.verifier_agents = Some(
v.as_u64()
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| corrupt(event, "method.verifier_agents"))?,
)
}
other => return Err(unknown(format!("the method key {other:?}"))),
}
}
Ok(out)
}
/// A method read back from the projection.
pub fn method_from_value(m: &Value) -> Result<ledger_core::audit::Method, StoreError> {
method_of(None, m, "attestations")
}
/// `audit.run {id, through, through_seq, scope, program_version, tb_hash,
/// findings_sha256, workpaper_attachment?, workpaper_pdf?}`.
pub fn audit_run(r: &crate::audit::AuditRun) -> Value {
let mut o = Map::new();
o.insert("id".into(), json!(r.id));
o.insert("through".into(), json!(r.through.to_string()));
o.insert("through_seq".into(), json!(r.through_seq));
o.insert("scope".into(), json!(r.scope.key()));
o.insert("program_version".into(), json!(r.program_version));
o.insert("tb_hash".into(), json!(r.tb_hash));
o.insert("findings_sha256".into(), json!(r.findings_sha256));
put(&mut o, "workpaper_attachment", r.workpaper_attachment);
put(&mut o, "workpaper_pdf", r.workpaper_pdf);
Value::Object(o)
}
/// Read an `audit.run`. A scope this version doesn't know is newer.
pub fn audit_run_of(
line: &crate::journal::JournalLine,
) -> Result<crate::audit::AuditRun, StoreError> {
const E: &str = "audit.run";
let p = &line.payload;
let scope_key = str_of(p, E, "scope")?;
let scope = ledger_core::audit::Scope::parse(&scope_key).ok_or_else(|| {
crate::format::newer_content(line, format!("the audit scope {scope_key:?}"))
})?;
Ok(crate::audit::AuditRun {
id: u64_of(p, E, "id")?,
through: date_of(&str_of(p, E, "through")?, E, "through")?,
through_seq: u64_of(p, E, "through_seq")?,
scope,
program_version: str_of(p, E, "program_version")?,
tb_hash: str_of(p, E, "tb_hash")?,
findings_sha256: str_of(p, E, "findings_sha256")?,
workpaper_attachment: opt_u64(p, E, "workpaper_attachment")?,
workpaper_pdf: opt_u64(p, E, "workpaper_pdf")?,
seq: line.seq,
})
}
/// The payload of `receipt.attested`, `entry.reviewed` or `period.attested`.
pub fn attestation(a: &crate::audit::Attestation) -> Value {
use crate::audit::Attested;
let mut o = Map::new();
match &a.what {
Attested::Receipt {
attachment_id,
entry_id,
amount_checked,
date_checked,
disagrees,
} => {
o.insert("attachment_id".into(), json!(attachment_id));
o.insert("entry_id".into(), json!(entry_id));
o.insert("amount_checked".into(), json!(amount_checked.to_string()));
o.insert("date_checked".into(), json!(date_checked.to_string()));
o.insert("disagrees".into(), json!(disagrees));
}
Attested::Entry { entry_id } => {
o.insert("entry_id".into(), json!(entry_id));
}
Attested::Period {
period_id,
audit_run_id,
} => {
o.insert("period_id".into(), json!(period_id));
o.insert("audit_run_id".into(), json!(audit_run_id));
}
}
o.insert("by".into(), json!(a.by));
o.insert("at".into(), json!(a.at));
o.insert("method".into(), method_json(&a.method));
put(&mut o, "note", a.note.clone());
Value::Object(o)
}
/// Read one of the three attestation events by its line.
pub fn attestation_of(
line: &crate::journal::JournalLine,
) -> Result<crate::audit::Attestation, StoreError> {
use crate::audit::Attested;
let p = &line.payload;
let e = line.line_type.as_str();
let what = match e {
"receipt.attested" => Attested::Receipt {
attachment_id: u64_of(p, e, "attachment_id")?,
entry_id: u64_of(p, e, "entry_id")?,
amount_checked: str_of(p, e, "amount_checked")?
.parse()
.map_err(|_| corrupt(e, "amount_checked"))?,
date_checked: date_of(&str_of(p, e, "date_checked")?, e, "date_checked")?,
disagrees: p
.get("disagrees")
.and_then(Value::as_bool)
.ok_or_else(|| corrupt(e, "disagrees"))?,
},
"entry.reviewed" => Attested::Entry {
entry_id: u64_of(p, e, "entry_id")?,
},
"period.attested" => Attested::Period {
period_id: u64_of(p, e, "period_id")?,
audit_run_id: u64_of(p, e, "audit_run_id")?,
},
_ => return Err(crate::format::unknown_type(line)),
};
Ok(crate::audit::Attestation {
seq: line.seq,
by: str_of(p, e, "by")?,
at: str_of(p, e, "at")?,
method: method_of(
Some(line),
p.get("method").ok_or_else(|| corrupt(e, "method"))?,
e,
)?,
note: opt_str(p, e, "note")?,
what,
})
}
#[cfg(test)]
mod tests {
use super::*;
+37 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 19;
pub const SCHEMA_VERSION: u32 = 20;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -485,5 +485,41 @@ CREATE TABLE IF NOT EXISTS draft_states(
state TEXT NOT NULL,
note TEXT NULL
);
"#,
r#"
-- v20: audits and attestations (plan P8, feature `audits`). An audit run
-- is a row per `audit.run`: the date it covers, the figure head it was
-- made at, the hashes that make it reproducible and the filed workpaper.
-- An attestation is a row per `receipt.attested`, `entry.reviewed` or
-- `period.attested`; whether one counts is derived against
-- `policy.attestation_bar` when asked and never stored. The method is the
-- client's own claim, kept as canonical JSON. Amounts stay TEXT.
CREATE TABLE IF NOT EXISTS audits(
id INTEGER PRIMARY KEY,
through TEXT NOT NULL,
through_seq INTEGER NOT NULL,
scope TEXT NOT NULL,
program_version TEXT NOT NULL,
tb_hash TEXT NOT NULL,
findings_sha256 TEXT NOT NULL,
workpaper_attachment INTEGER NULL,
workpaper_pdf INTEGER NULL,
seq INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS attestations(
seq INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
attachment_id INTEGER NULL,
entry_id INTEGER NULL,
period_id INTEGER NULL,
audit_run_id INTEGER NULL,
by_who TEXT NOT NULL,
at_ts TEXT NOT NULL,
method TEXT NOT NULL,
amount_checked TEXT NULL,
date_checked TEXT NULL,
disagrees INTEGER NULL,
note TEXT NULL
);
"#,
];
+34
View File
@@ -51,6 +51,9 @@ use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
#[path = "audit.rs"]
pub mod audit;
/// File names inside the data directory.
pub const JOURNAL_FILE: &str = "journal.jsonl";
@@ -7594,6 +7597,21 @@ fn replay_line(
let a = crate::meta::annotated_of(line)?;
ledger.replay_annotate_attachment(a)?;
}
"audit.run" => {
// Not accounting state; the projection records it. Checked
// for shape here (an unknown scope is newer, rules.md B-27).
crate::meta::audit_run_of(line)?;
}
// A claim that someone checked; no figure moves.
"receipt.attested" => {
crate::meta::attestation_of(line)?;
}
"entry.reviewed" => {
crate::meta::attestation_of(line)?;
}
"period.attested" => {
crate::meta::attestation_of(line)?;
}
"entry.settles" => {
let (entry, settles) = crate::meta::entry_settles_of(p)?;
ledger.replay_set_entry_settles(entry, settles)?;
@@ -8809,6 +8827,22 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
"audit.run" => {
let run = crate::meta::audit_run_of(line)?;
audit::insert_run(conn, &run)?;
}
"receipt.attested" => {
let a = crate::meta::attestation_of(line)?;
audit::insert_attestation(conn, &a)?;
}
"entry.reviewed" => {
let a = crate::meta::attestation_of(line)?;
audit::insert_attestation(conn, &a)?;
}
"period.attested" => {
let a = crate::meta::attestation_of(line)?;
audit::insert_attestation(conn, &a)?;
}
"entry.settles" => {
// Rebuilt into the ledger by replay; no report rows.
}
+612
View File
@@ -0,0 +1,612 @@
//! 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:#?}"
);
}
@@ -0,0 +1,85 @@
//! Writes `p8_before`: books the version before P8 (main b97234ac) wrote
//! through its Store API: a receipted opening and purchase, a 40.00
//! purchase with no receipt, a small one under the threshold, a reversal,
//! an invoice with a part payment, an open draft, and a closed January.
//! No audit run, attestation or attestation setting exists in that
//! version; this version opens the same journal and must report the same
//! figures, and an audit of it finds exactly the missing receipt and the
//! open draft.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, DocumentLine, Money, Side};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn main() {
let root = PathBuf::from(std::env::args().nth(1).unwrap());
let dir = root.join("p8_before");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = 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 sales = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap();
let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap();
let software = s.add_account("6100", "Software", AccountType::Expense, None, false).unwrap();
let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
e
};
let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000);
s.post_entry(seed).unwrap();
s.attach_receipt(Some(seed), "opening.txt", "text/plain", b"Opening deposit 1000.00 2026-01-01", None)
.unwrap();
let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
s.post_entry(rent_jan).unwrap();
s.attach_receipt(Some(rent_jan), "rent.txt", "text/plain", b"Rent January 500.00 2026-01-05", None)
.unwrap();
// 40.00 is over the 25.00 threshold: no receipt, so an audit finds it.
let paper = post(&mut s, d(2026, 1, 7), "Printer paper", software, bank, 4_000);
s.post_entry(paper).unwrap();
// Under the threshold: needs none.
let stamps = post(&mut s, d(2026, 1, 8), "Stamps", software, bank, 1_200);
s.post_entry(stamps).unwrap();
let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000);
s.post_entry(wrong).unwrap();
s.attach_receipt(Some(wrong), "rent2.txt", "text/plain", b"Rent again 500.00 2026-01-12", None)
.unwrap();
s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap();
let customer = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(
customer,
d(2026, 1, 10),
d(2026, 2, 9),
vec![DocumentLine {
description: "Design".into(),
amount: cents(120_000),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
s.receive_payment(inv, d(2026, 1, 20), cents(40_000), bank).unwrap();
// An open draft in February; January closes around it.
let _open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500);
let jan = s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)).unwrap();
s.close_period(jan).unwrap();
let _ = sales;
s.verify().unwrap();
println!("WROTE {:?}", dir);
}
@@ -0,0 +1,461 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-05",
"description": "January rent",
"id": 2,
"lines": [
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-07",
"description": "Printer paper",
"id": 3,
"lines": [
{
"account": 5,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-08",
"description": "Stamps",
"id": 4,
"lines": [
{
"account": 5,
"amount": {
"amount": "12.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "12.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-12",
"description": "Rent paid twice",
"id": 5,
"lines": [
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-14",
"description": "Entered twice",
"id": 6,
"lines": [
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
}
],
"reversal_of": 5,
"status": "Posted"
},
{
"date": "2026-01-10",
"description": "Invoice 1",
"id": 7,
"lines": [
{
"account": 6,
"amount": {
"amount": "1200.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "1200.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-20",
"description": "Payment on invoice 1",
"id": 8,
"lines": [
{
"account": 6,
"amount": {
"amount": "400.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 1,
"amount": {
"amount": "400.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-12",
"description": "Coworking day pass",
"id": 9,
"lines": [
{
"account": 4,
"amount": {
"amount": "35.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "35.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Draft"
},
{
"date": "2026-01-31",
"description": "Close period 2026-01",
"id": 10,
"lines": [
{
"account": 3,
"amount": {
"amount": "1200.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 5,
"amount": {
"amount": "52.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 7,
"amount": {
"amount": "648.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [
[
7,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
8,
[
{
"party_id": 1,
"share": "derived"
}
]
]
],
"invoices": [
{
"amount_paid": "400.00",
"customer_id": 1,
"date": "2026-01-10",
"due_date": "2026-02-09",
"entry_id": 7,
"id": 1,
"lines": [
{
"amount": "1200.00",
"description": "Design"
}
],
"status": "Partial"
}
],
"payroll_runs": [],
"periods": [
{
"close_seq": 1,
"closing_entry": 10,
"end": "2026-01-31",
"id": 1,
"member_close": null,
"name": "2026-01",
"start": "2026-01-01",
"status": "Closed"
}
],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "1648",
"balanced": true,
"current_earnings": "0",
"equity": "1648",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "0",
"net_income": "0",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "848",
"name": "Bank"
},
{
"code": "1200",
"credit": "0",
"debit": "800",
"name": "Accounts Receivable"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "3999",
"credit": "648",
"debit": "0",
"name": "Retained Earnings"
}
],
"total_credits": "1648",
"total_debits": "1648"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,52 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:33:25Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"a41fd982ec883c0f7dbeec0382bcf10c69246a4d063cf04997f40ad96467f00d","seq":2,"ts":"2026-10-29T20:33:25Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"f0adba7a9a2da79cc2b28f4ab2a69238da5f3ad9269e76c1c21e0027fdf86c51","seq":3,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"093d7586712b4e3379fea7da2923ab4b25efa7be0d533e212ea4c31ecbb81332","seq":4,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":3,"name":"Sales","parent":null,"placeholder":false},"prev":"2b8f70ae6d4b736aa0cc523563b47cdd1be396e3fd3a5c2f84cceb001fbaff81","seq":5,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"d4fc3949ba8c20b553504561a2c22f96d6a4351713ae66e83fe34f601ead0b2d","seq":6,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"3088d962972a512f5d1a12a8dd4da1da6ae8d1388e67ea18666b87687401b97c","seq":7,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"5947b37f609878df3587e37c72411cc2505fb694aab1bc9a7199cecfd2897031","seq":8,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"d0aff2093f4f8114e04eb158ddfcfc24201846214141fea1def86cd965551006","seq":9,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"850e9125ac3b5d457e611882642600a7298fa3acdd386ce2b7c9494ecb29b0d0","seq":10,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"abc483bc19148cc173cf93402702b60ee5e31029fcdcfa704f10204c38a707ec","seq":11,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
{"payload":{"attachment_id":1,"blob_sha":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","category":"receipts","description":null,"entry_id":1,"file_name":"opening.txt","mime_type":"text/plain","size_bytes":34},"prev":"4a2665579fd49f5d11e27eaaca556990d58559dd1a9d8f23e9d274449ff61a8d","seq":12,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
{"payload":{"attachment_id":1,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","version":"0.1.0"},"prev":"d8c2d6bf43d24b9cf4b3c0a12b514cd9f77cf51f979ab54e17c6fc879fdc285f","seq":13,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"d048136f116634d3df443725f71b4f5a9ed2cfdc45a8dba9fc9a54bb993266ff","seq":14,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"6850fe7154d84ec18c876c9c4985607b9660e1a4d6ff1c42814358039d25224d","seq":15,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"24ecb6745877198c45149e53672d81a7e8b75051770c2eac046c373ad5020694","seq":16,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"76e855cebe94f29e2bbf65e4be0634976eec4164867ec4c2152e39b21a5b9ab0","seq":17,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
{"payload":{"attachment_id":2,"blob_sha":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","category":"receipts","description":null,"entry_id":2,"file_name":"rent.txt","mime_type":"text/plain","size_bytes":30},"prev":"72fb7f27dba8721febaffcc2dc93870d79272ca3208abfea27d1535f46fa8334","seq":18,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
{"payload":{"attachment_id":2,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","version":"0.1.0"},"prev":"bd55df8f6fce00e7a55262be3123985789504189f9bc8364a194fa886719fc6a","seq":19,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3},"prev":"2129339f3ce925cb9d0ff71f108ce7ba5c1dadd8432834effb2586e7632c1add","seq":20,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":5,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"3980e707c3feb852f13fff9daa38ae8bf64831d851a76b8a5e7e97eff3237506","seq":21,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"673065104b3196477ebed498501f952f62b0cc1ed8cb35cda30b00e3d4932590","seq":22,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":1,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c2c265a5318dfc5437b94272a5ceb654e93eb982815eea919ce11ca492e69f53","seq":23,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
{"payload":{"date":"2026-01-08","description":"Stamps","id":4},"prev":"e54f5a63600a423bc05f572cc2e5357b591afee176de438adbfc673e10028443","seq":24,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":5,"amount":"12.00","currency":"USD","id":4,"side":"debit"},"prev":"c0acdf9d0c8fd94c66be0718354953d951f377d961da3846bba4fcfe9715577a","seq":25,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":4,"side":"credit"},"prev":"9231e3a5b347df6caabb01248419230918234919a32552ebb509706a79f4cfd0","seq":26,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-08","description":"Stamps","id":4,"lines":[{"account":5,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"66e4fd5f78d0499f6c748e283ba81325fb43d3277a73bc7ba02e1cc43a8e3d37","seq":27,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5},"prev":"e62d56bcde5b843e5b007e5d70bec0b39159e04c0e496533341af80fb2c04e88","seq":28,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":5,"side":"debit"},"prev":"b9270e6df82d8f9972ec1ce5046113bc460e1c28b48a1d23a3f5631dae23eb6c","seq":29,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":5,"side":"credit"},"prev":"91bd76318ce8bcdc02caaff76a44ade5b63aea97fc2e9406a914f87d91b7b87b","seq":30,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"994d4a25eb115362c405208ffc926af4898c61e0938d225c3cceb1aaff2520b1","seq":31,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"attachment_id":3,"blob_sha":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","category":"receipts","description":null,"entry_id":5,"file_name":"rent2.txt","mime_type":"text/plain","size_bytes":28},"prev":"97c54bdaa6d977bddbb61f0f797903f162a5445aadad0f86fbef119d26f03885","seq":32,"ts":"2026-10-29T20:33:27Z","type":"receipt.attached"}
{"payload":{"attachment_id":3,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","version":"0.1.0"},"prev":"6908282c4f663ee92e59574a7f772d341692a684975433afb0370fe7f25ad798","seq":33,"ts":"2026-10-29T20:33:27Z","type":"attachment.text_extracted"}
{"payload":{"date":"2026-01-14","description":"Entered twice","id":6,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"d8fae93f64fa2c2fafa13ed4ed3a6f578a6d7a2af15cdef1bd2b3bed86242665","seq":34,"ts":"2026-10-29T20:33:27Z","type":"entry.reversed"}
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"c525d0946d25b018a020b71072d59e39a6a842d85fa4c554af73965235b93aca","seq":35,"ts":"2026-10-29T20:33:27Z","type":"customer.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":6,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"99f31ecc1df1963071728eb1927d66574cbbc48579604183b569b0507f725aaa","seq":36,"ts":"2026-10-29T20:33:27Z","type":"account.added"}
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7},"prev":"000b3f21fbda30070ebdc436788844e60e7fb30a2c574a4fc96df1184e6a974f","seq":37,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":6,"amount":"1200.00","currency":"USD","id":7,"side":"debit"},"prev":"31912cd94f8cf8ab2381467d0c1f06e7b7d1aad7232dc34bf26976f8b07117df","seq":38,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":7,"side":"credit"},"prev":"b3544c286be658d2569eaec61f1ecf924b6345125ccdee5de44c7be69eb80d87","seq":39,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7,"lines":[{"account":6,"amount":"1200.00","currency":"USD","side":"debit"},{"account":3,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"780aa79b403d46831221d2f9de731eafe6e20e3b916fa6bcc712485234a53a41","seq":40,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":7,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"d13a0896487f5aec07d43136ad9fd10e70d4f048528ff4e7a32a9d23a74f7225","seq":41,"ts":"2026-10-29T20:33:27Z","type":"invoice.created"}
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8},"prev":"293d3e1a957fa177d28515cf7a4dfa324945bb73d8a2b535844fbaafa186ff1a","seq":42,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":6,"amount":"400.00","currency":"USD","id":8,"side":"credit"},"prev":"68c56c6c09a27ba436c95d4ebe2e0fb56511bf5db181ad424d7c6ed56cfd0bc3","seq":43,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"400.00","currency":"USD","id":8,"side":"debit"},"prev":"5284c73245f1f3a6d2820a6165fe720bc7b7034ad3cd07ba1320f30fed4acadc","seq":44,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8,"lines":[{"account":6,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"c2afa1d4c940bbc35df2ec94272080c5fa0dad93a9547cbec6d07ab11f4ba4f3","seq":45,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":8,"invoice_id":1},"prev":"30a6274b20559dc7f83f2b00f39da4c72bc4244c013ad63368d0f6362311e058","seq":46,"ts":"2026-10-29T20:33:27Z","type":"invoice.payment_received"}
{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":9},"prev":"adb2e790bd245c5ab0286870ad837296d0df062498c080b4349579b9f5f67ed9","seq":47,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":4,"amount":"35.00","currency":"USD","id":9,"side":"debit"},"prev":"030bdf46cf4b8dae86f000cca47fd0f95d0f7dc01f3637729c082797846a1416","seq":48,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"35.00","currency":"USD","id":9,"side":"credit"},"prev":"b6e4796ab7517782b0e5ee77126e5701246d2b4234c505bc079ba1ce93ad4b04","seq":49,"ts":"2026-10-29T20:33:28Z","type":"entry.line_added"}
{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"d5653ccd2b9fdc574ebb68c068246e70b69639f26a3934faf41eeeb77dc5f8bb","seq":50,"ts":"2026-10-29T20:33:28Z","type":"period.created"}
{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"14a0f2b42d59a5a18e901c935c1fe15198d6a8d308917359b840504b74700cb8","seq":51,"ts":"2026-10-29T20:33:28Z","type":"account.added"}
{"payload":{"close_to":"retained_earnings","closing_entry":10,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":10,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":5,"amount":"52.00","currency":"USD","side":"credit"},{"account":7,"amount":"648.00","currency":"USD","side":"credit"}]},"id":1},"prev":"724a1d45168bcb5282dd894952b98aa4b364641158c42e9204758de8c2afeba2","seq":52,"ts":"2026-10-29T20:33:28Z","type":"period.closed"}
+49 -16
View File
@@ -150,7 +150,7 @@ fn assert_corrupt<T>(got: Result<T, StoreError>) {
fn old_reader_gets_newer_journal() {
let cases = [
(
"period.attested",
"ledger.not_yet_written",
json!({"key": "company.name", "value": "X"}),
"an event type this version doesn't know",
),
@@ -243,12 +243,24 @@ fn the_writer_and_the_viewer_agree_on_newer_books() {
&[
// Chained and of a known type, but replay refuses it.
("entry.posted", json!({"id": 99})),
("period.attested", json!({"key": "company.name"})),
("ledger.not_yet_written", json!({"key": "company.name"})),
],
)[1];
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
assert_newer(
Store::open(&dir),
seq,
"ledger.not_yet_written",
need,
false,
);
assert_newer(
Snapshot::load(&dir),
seq,
"ledger.not_yet_written",
need,
false,
);
}
/// Damage stays corruption, whatever the damaged line's type: the gate
@@ -282,7 +294,7 @@ fn damaged_journals_are_still_corrupt() {
];
let newer = [
(FORMAT_SET, json!({"format": 3, "features": []})),
("period.attested", json!({"key": "company.name"})),
("ledger.not_yet_written", json!({"key": "company.name"})),
];
for (name, damage) in damages {
for (i, last) in newer.iter().enumerate() {
@@ -566,32 +578,32 @@ fn a_newer_format_frames_its_lines_its_own_way() {
let nexts: [(&str, Next, Option<bool>); 6] = [
(
"float",
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}),
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": prev}),
Some(true),
),
(
"string-seq",
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}),
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev}),
Some(true),
),
(
"envelope-key",
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}),
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev, "actor": "agent"}),
Some(true),
),
(
"no-prev",
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}),
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "parent": prev}),
Some(false),
),
(
"prev-not-text",
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}),
|seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": 1.5}),
Some(false),
),
(
"wrong-prev",
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}),
|seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": "00"}),
None,
),
];
@@ -686,11 +698,23 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
} else {
// After a refused line, it is a next line without a readable
// prev: unverified, for both.
let seq = append(&dir, &[("period.attested", json!({}))])[0];
let seq = append(&dir, &[("ledger.not_yet_written", json!({}))])[0];
append_raw(&dir, b"{\"prev\":\"\xff\"}\n");
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
assert_newer(
Store::open(&dir),
seq,
"ledger.not_yet_written",
need,
false,
);
assert_newer(
Snapshot::load(&dir),
seq,
"ledger.not_yet_written",
need,
false,
);
}
}
}
@@ -703,12 +727,21 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
fn an_old_reader_never_repairs_newer_books() {
let dir = test_dir("newer-torn");
books(&dir);
let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0];
let seq = append(
&dir,
&[("ledger.not_yet_written", json!({"key": "company.name"}))],
)[0];
append_raw(&dir, b"{\"seq\":");
let path = dir.join(JOURNAL_FILE);
let before = std::fs::read(&path).unwrap();
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
assert_newer(
Store::open(&dir),
seq,
"ledger.not_yet_written",
need,
false,
);
assert_eq!(std::fs::read(&path).unwrap(), before);
let set_aside: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
+42
View File
@@ -2181,6 +2181,37 @@ fn books_written_under_older_rules_still_open() {
json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}),
)
.unwrap();
// P8's new shapes, hand-written as this version writes them: an audit
// run and the three kinds of attestation (the method is the client's
// own claim). This reader takes them; the version before it stopped at
// the declaration.
let method = json!({"model": "opus", "reasoning_effort": "high",
"adversarial": true, "verifier_agents": 2});
w.append(
"audit.run",
json!({"id": 1, "through": "2026-05-31", "through_seq": 30, "scope": "month",
"program_version": "audit-0.1.0-1", "tb_hash": "aa", "findings_sha256": "bb",
"workpaper_attachment": 6, "workpaper_pdf": 7}),
)
.unwrap();
w.append(
"receipt.attested",
json!({"attachment_id": 5, "entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:00Z",
"method": method, "amount_checked": "99.99", "date_checked": "2026-04-01",
"disagrees": false, "note": "read the page"}),
)
.unwrap();
w.append(
"entry.reviewed",
json!({"entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:01Z", "method": {}}),
)
.unwrap();
w.append(
"period.attested",
json!({"period_id": 1, "by": "verifier", "at": "2026-06-01T00:00:02Z",
"method": method, "audit_run_id": 1}),
)
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
@@ -2210,6 +2241,17 @@ fn books_written_under_older_rules_still_open() {
assert_eq!(annotation.fields.receipt_number, Some(3));
assert_eq!(annotation.fields.payee.as_deref(), Some("Acme"));
assert_eq!(store.ledger().next_receipt_number(), 4);
// P8's shapes replayed: the run and the three claims, as written.
let runs = store.audit_runs().unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].scope, ledger_core::audit::Scope::Month);
assert_eq!(runs[0].workpaper_pdf, Some(7));
let claims = store.attestations().unwrap();
assert_eq!(claims.len(), 3);
assert_eq!(claims[0].kind(), "receipt");
assert_eq!(claims[0].method.verifier_agents, Some(2));
assert_eq!(claims[1].method, ledger_core::audit::Method::default());
assert_eq!(claims[2].kind(), "period");
assert_eq!(store.ledger().entry_settles(7), [1]);
assert!(store.ledger().settles_entry(1));
let acme = store.ledger().get_party(1).expect("the party");
+15 -2
View File
@@ -33,7 +33,7 @@ the specification in `bookspec/` (which never depends on the engine).
| `ledger-config` | the `wanderbooks.toml` codec (adoption in, `books.export_settings` out) and the receipt policy until P4 moves it into the core | `ledger-core` |
| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `describe.rs` (the journal in words, for `books.journal_history`), `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) |
| `ledger-reader` | the one reader: a PDF's text and pages, an XLSX's cells as exact decimal strings, plain text (P6; a plain library, in-process, D-61); `ledger-import` re-exports it as `extract` | none (`hayro`, `zip`, `quick-xml`) |
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` |
| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels, the audit workpaper) out | `ledger-core`, `ledger-reader` |
| `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` |
| `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above |
| `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a) | `ledger-store`, `ledger-mcp`, `ledger-config` |
@@ -59,13 +59,26 @@ closure. The reader now lives in the small library crate `ledger-reader`;
re-exports it as `extract`), the store no longer builds the typesetter,
and `ledger-import` still never depends on the store.
Audits (P8, rules.md B-45) are split by what each layer can see. The pure
checks over a ledger are `ledger_core::audit` (`statements`, with the facts
a ledger cannot know handed in by the caller: the receipts filed, the
statement of cash flows the role settings give, the settings problems, the
undecided 1099 vendors); what only the store can see (the chain, the
database against a replay, the stored files, the access log, duplicate
files) and the audit and attestation events are `ledger_store::audit` (a
child module of `store`, because it reads the store's private state); the
workpaper's PDF is `ledger_import::workpaper` with its Typst template in
`ledger-import`'s `templates/` beside the other documents; the backups'
standing is read by the server, which alone knows the backups
(`BackupStanding`), and the tools are `ledger-mcp`'s `tools/audit.rs`.
`ledger-core` itself (`crates/ledger-core/src/`): `money`, `account`,
`journal`, `ledger` (the command surface and replay), `kernel` (every
money arithmetic, generic over `Amount`) and `proofs` (its Kani
harnesses), `reports`, `cashflow`, `analysis`, `periods`, `assets`,
`schedules`, `recurring`, `payouts`, `payroll`, `form1099`, `tax`,
`classes`, `parties`, `arap`, `settings`, `conventions`, `calendar`,
`shares`, `tax_capital`, `rules`, `error`; its tests in `tests/`
`shares`, `tax_capital`, `rules`, `audit` (the audit programme's pure checks, P8), `error`; its tests in `tests/`
(`invariants.rs`, `properties.rs`, `spec_*.rs`, `kernel_bridge.rs`,
`model.rs`, `bookspec.rs`, `golden/`).
+71 -3
View File
@@ -486,8 +486,8 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
`books.run_schedules`, `books.dispose_asset`, `books.reconcile`,
`books.close_period`, `books.create_invoice`, `books.create_bill`,
`books.receive_payment`, `books.pay_bill`, `books.write_off_invoice`,
`books.purge_tin`, `books.consolidate_duplicates` and
`books.discard_draft`. (`books.void_document`, `books.run_depreciation`
`books.purge_tin`, `books.consolidate_duplicates`,
`books.discard_draft` and `books.audit`. (`books.void_document`, `books.run_depreciation`
and `books.run_recurring` post at once and have no `confirmed`.)
- **The result envelope (P5, plan 4.6).** Every tool result, whatever the
tool, is given the same fields in one place (`call_tool`,
@@ -2554,7 +2554,7 @@ until the owner upgrades them.
## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
- `books.export_cpa_package { as_of?, without_audit_reason?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins?,
allow_open_drafts?: { reason } }` → `{ file,
tins_included, tin_gaps, warnings?, note }`: the zip,
@@ -2733,3 +2733,71 @@ argument is refused.
`migrate`, `process_inbox` and `receipts_from_email` are not built (D-46,
D-28, D-17).
## Tools — audit and attestations (P8)
An audit is the books read as an auditor reads them, through a day, in one
run: the same books give the same findings and the same workpaper. An
attestation is a claim by a checker that it looked at something. The audit
finds; the attestations stand behind it; the CPA package waits for both
(rules.md B-45; decision D-9; the journal feature `audits`: the audit run
and the attestations wait for the books' format upgrade, refused with
`format_upgrade_pending` until then).
- `books.audit { scope, through?, confirmed?, preview? }` → `{ status:
"preview" | "audited" | "already_run", preview, head, checks: [{ code,
severity, predicate }], run?: { id, through, through_seq, scope,
program_version, tb_hash, findings_sha256, workpaper_attachment,
workpaper_pdf, at_current_head }, findings: [{ severity, code, subject,
message }], summary }`. `scope` is `month`, `annual`, `cpa_handoff` or
`full`; `through` defaults to today. Without `confirmed` it lists the
checks and changes nothing; with `confirmed` and the `preview` digest it
runs them, files the workpaper as two documents (category `workpaper`:
JSON, and a PDF whose bytes depend only on the findings) and journals
`audit.run`. The checks are the statements (trial balance, the
accounting equation, the profit and loss against equity, the cash flow's
footing), the A/R and A/P control accounts, closes, schedules and
depreciation, the asset register, each posted entry against the receipt
policy, open drafts, tax categories, 1099 decisions and the settings;
then the journal's chain, the database against a replay, the stored
files, the access log, duplicate files and the backups. The same books
(the figure head), day, scope and programme return the run that exists
(`already_run`) and file and journal nothing. "The same books" means no
entry, document, close, settings or role change since: an audit, an
attestation or a filed receipt does not change them. Needs books:write to
preview, books:post to run.
- `books.workpaper { id }` → `{ run, workpaper, pdf? }`: the workpaper as
filed (what was checked, through which day and state, every finding) and
its PDF handed over (`pdf` is a download command; kept 15 minutes).
Read-only.
- `books.attest_receipt { attachment_id, entry_id, amount, date, method?,
note? }` → `{ attestation, bar, why_not_counted? }`. The checker's
reading of the receipt's total and date, compared by the books with the
posted entry's amounts (its debits, or any one line) and date; a
difference is recorded with `disagrees: true` and never counts. The
receipt must be filed on or linked to the entry.
- `books.review_entry { entry_id, method?, note? }` → the same shape: a
posted entry reviewed whole.
- `books.attest_period { period, method?, note? }` → the same shape.
`period` is an id or a name from `books.list_periods`. Refused unless an
audit through the period's end stands at the books' current head; the
attestation names that run.
- `books.attestations { through?, kind? }` → `{ bar, attestations: [{ seq,
kind, by, at, attachment_id?, entry_id?, period_id?, audit_run_id?,
amount_checked?, date_checked?, disagrees, method, note?, counted }],
counted }`. `kind` is `receipt`, `entry` or `period`. `counted` is read
against `policy.attestation_bar` as it stands when asked and never
stored: a bar change changes what counts, earlier claims too.
`method` is `{ model?, reasoning_effort?, adversarial?, verifier_agents? }`
and is the client's own claim: the books record it and cannot verify it.
`by` is the token's name. The bar (`policy.attestation_bar`, default
`effort=high verifiers=2 adversarial=true`) holds every attestation to the
effort, and a period attested on a year-level audit (`annual`,
`cpa_handoff`, `full`) to the adversarial check and the verifier count too.
`books.export_cpa_package` gains `without_audit_reason?`: on books that
declared `audits`, with `policy.cpa_package_requires_audit` at `refuse` (the
default once the books hold an audit run; before one it warns), the package is refused (`audit_required`) unless an audit with
scope `cpa_handoff` or `full` stands at the current head with a period
attested on it at the bar; a reason overrides the refusal and is journaled
as a decision. Under `warn` the package is made and its `warnings` say so.
+9
View File
@@ -238,3 +238,12 @@ golden covers it:
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|---|---|---|---|
| Audit trail in words (`books.journal_history`, the `describe` sentences) | QBO "Audit log", Xero "History and notes" | partial | tests `journal_history_traces_what_the_books_did`, `every_event_type_has_a_sentence`; the viewer keeps its own sentences (the shared describer is the server's) |
### P8: audit and attestations (2026-10-29)
Three new rows, none above changed status, and the floor stands.
| Area | Counterpart (a cross-check, not the spec) | Status | Notes |
|---|---|---|---|
| Audit run: one programme of checks over the statements, the control accounts, closes, schedules, assets, evidence, drafts, tax categories, 1099 decisions, settings and the store's integrity (`books.audit`, `books.workpaper`) | An auditor's workpaper; QBO "Audit log"/"Review" and Xero's "Reports review" are a cross-check only | partial | rules.md B-45; `ledger_core::audit`, `ledger_store::audit`; tests `audit_detects_each_seeded_defect` (every code seeded), `audit_is_deterministic`, `workpaper_pdf_is_deterministic`; the fixture `p8_before` was written by the version before; `partial` until a hand-verified golden covers a workpaper |
| Attestations of receipts, entries and periods, weighed against `policy.attestation_bar` when asked (`books.attest_receipt`, `books.review_entry`, `books.attest_period`, `books.attestations`) | An accountant's sign-off on a month | partial | `below_bar_not_counted`, `amount_mismatch_never_counts`, `stale_head_period_attestation_refused`; the method is the client's own claim, and the books say so |
| The CPA package waits for a counted audit (`policy.cpa_package_requires_audit`) | A close checklist before handoff | partial | `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`; behind the feature `audits` |
+21 -1
View File
@@ -72,6 +72,8 @@ apply is safe — on startup the applier replays every journal line with
an open item, when and on what, corrected without touching its words;
replay sets the source and nothing else, and the projection updates the
row's existing source columns, so no schema changed)
`audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`
(P8, feature `audits`: claims about the books, no figure)
(plus the A/R, A/P, asset,
period, payroll, and class events listed on `JournalLine`).
- `payload`: the full domain facts (account fields, entry with lines, blob
@@ -174,6 +176,20 @@ a backup is then proven where it lands, by a drill (docs/mcp-interface.md,
read off a file (the closed field list; a later annotation replaces the
earlier record), and `entry.settles {entry_id, settles[]}` says which
entries a payment pays back (the only source of "reimbursed").
- **Audits and attestations are records, not figures** (P8, rules.md B-45,
feature `audits`): `audit.run {id, through, through_seq, scope,
program_version, tb_hash, findings_sha256, workpaper_attachment?,
workpaper_pdf?}` says a programme of checks was run through a day at the
books' figure head `through_seq` (the last line that bears on a figure;
audits, attestations and filings of evidence do not move it), and which
two filed documents (category `workpaper`) hold its findings;
`receipt.attested {attachment_id, entry_id, by, at, method,
amount_checked, date_checked, disagrees, note?}`, `entry.reviewed
{entry_id, by, at, method, note?}` and `period.attested {period_id, by,
at, method, audit_run_id}` are a named client's claim that it checked.
`method` ({model?, reasoning_effort?, adversarial?, verifier_agents?}) is
that client's own word. Whether a claim counts is never journaled: it is
derived against `policy.attestation_bar` when asked.
- The store is policy-free: it records attachments faithfully. Whether a
posting *requires* a receipt is decided by the MCP layer's receipt policy
(`docs/mcp-interface.md`), which reads the `attachments` table.
@@ -199,7 +215,11 @@ Suspected corruption is fixed by rebuild, never by forensics.
as above; `extractions(attachment_id, extractor, version, text_sha256,
pages, status, seq)` and `annotations(attachment_id, receipt_number,
payee, amount, date, instrument, category, source, reimbursable_to,
note, seq)` (P6; amounts TEXT, never REAL); `events(seq, type, payload,
note, seq)` (P6; amounts TEXT, never REAL); `audits(id, through,
through_seq, scope, program_version, tb_hash, findings_sha256,
workpaper_attachment, workpaper_pdf, seq)` and `attestations(seq, kind,
attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, method,
amount_checked, date_checked, disagrees, note)` (P8, schema 20); `events(seq, type, payload,
created_at)` — the applier's output,
a queryable mirror of the journal stream and the feed the future GUI
tails. (Poll `WHERE seq > ?`; later SSE over the HTTP transport. This is
+95
View File
@@ -1765,6 +1765,11 @@ being built, B-43). P5's is `source_revisions` (a
`convention.source_revised`, `decision.source_revised` or
`open_item.source_revised`, new event types: an older version would
refuse them, or show the old source as the record's authority). A command asks for
being built, B-43). P8's is `audits` (an `audit.run`, `receipt.attested`,
`entry.reviewed` or `period.attested`, all new event types, and the
settings `policy.attestation_bar` and `policy.cpa_package_requires_audit`:
an older version would refuse the types and the keys, or drop the claims a
handoff rests on, B-45). A command asks for
the feature it needs (`Store::ensure_feature`) before its first journaled
line, and only when the line it is about to write uses it, so a write
refused for waiting on the upgrade journals nothing, never an entry
@@ -3585,3 +3590,93 @@ withdraw_attachment_duplicate}` and `Store::attach`'s dedup; feature
`page_image_is_a_png_of_the_page`,
`consolidation_never_removes_an_entrys_only_evidence`); fuzz targets
`fuzz_extract_pdf` and `fuzz_extract_xlsx`.
## B-45 An audit is deterministic over the books; an attestation is a claim by a named client
**bookspec:** `BS-ledger-13`
**Status:** CONVENTION (the SOURCE block below; Pub. 583 and Rev. Proc. 98-25
for the audit trail); CHOICE D-9 (owner-adopted recommendation,
2026-10-16: the attestation bar, and the CPA package waits for a counted
audit unless given a reason).
```text
GOAL: the books can be audited by a programme that gives the same
findings for the same books, and a claim that someone checked rests
on the books as they were when the audit ran, says how it was made,
and is weighed against the owner's bar when asked.
SOURCE: IRS Pub. 583 (records that support the entries); Rev. Proc. 98-25
§5.01 (the relationship between the records and the books must be
demonstrable: an audit trail); CHOICE D-9 (the bar: reasoning
effort at least high; for a year, adversarial verification by at
least two independent agents; no model allow-list; the CPA package
refuses without a counted audit unless given a reason); CHOICE
D-53 (an audit is a run, not a job); CONVENTION (the figure head,
below; a client's method is its own claim).
INVARIANTS:
1. An audit is a pure function of the journal's figures: its findings
(`ledger_core::audit::statements`, the store's integrity checks) are
a list in a fixed order, and the same books, day, scope and
programme give the same findings, the same trial balance hash and
the same workpaper bytes. A check that cannot be made is itself a
finding, never a silent pass.
2. The figure head is the seq of the last journal line that bears on a
figure. Audit runs, attestations, filings of evidence (receipts,
documents, statements, reconciliations, extraction, annotations,
links), decisions and open items, and the format declaration do not
move it; an entry, a draft, a document, a close, a schedule, an
asset, a settings change or a role change does. A run is idempotent
on (through, head, scope, programme): asking again returns it and
journals nothing.
3. A period is attested only on a run at the current head whose day is
on or after the period's end.
4. An attestation is a claim by the token that made it (`by`); its
method (model, reasoning effort, adversarial, verifier agents) is
the client's own, recorded and never verified. A receipt attestation
whose amount or date differs from the entry's is recorded with
`disagrees` and never counts.
5. `counted` is derived against `policy.attestation_bar` as it stands
when asked and never stored: a bar change changes what counts, past
claims too. Every attestation needs the reasoning effort; one on a
year-level run (annual, cpa_handoff, full) needs the adversarial
check and the verifier count too.
6. The CPA package, on books that declared `audits` and under
`policy.cpa_package_requires_audit = refuse` (unset: refuse once the
books hold an audit run, warn before), needs a run of scope
cpa_handoff or full at the current head with a period attested on it
that counts; otherwise it is refused, unless a reason is given,
which is journaled as a decision.
7. Nothing here moves a figure. Every refusal leaves the journal
unchanged; every write waits for the feature `audits`.
SCENARIOS:
1. The same books audited twice through one day: one run, one
workpaper; the second call files and journals nothing.
2. An entry posted after the audit: the period cannot be attested until
the audit is run again.
3. A receipt read as 41.00 against a 40.00 entry: recorded, `disagrees`,
not counted, however hard the checker says it thought.
4. The bar lowered afterwards: earlier attestations that now meet it
count.
5. A CPA package with no counted audit: refused (`audit_required`);
with a reason, made, and the reason is a decision in the journal.
6. Books that never declared `audits`: the package is as it was; the
audit run and the attestations are refused with
`format_upgrade_pending`.
```
**Enforced:** `ledger_core::audit` (`statements`, `Finding`, `CODES`,
`AttestationBar::counts`, `Scope`, `Method`), `ledger_store::audit`
(`Store::figure_head`, `integrity_findings`, `record_audit_run`,
`attest_receipt`, `review_entry`, `attest_period`, `run_for_period`,
`attestations`, `PROGRAM_VERSION`), `ledger_import::workpaper_pdf`,
ledger-mcp `tools/audit.rs` and the gate in `books.export_cpa_package`,
journal feature `audits`.
**Checked:** `ledger-core` `tests/audit.rs`
(`audit_detects_each_seeded_defect`, `below_bar_not_counted`,
`amount_mismatch_never_counts`); `ledger-store` `tests/audit.rs`
(`audit_writes_wait_for_audits`, `a_run_is_idempotent_on_the_figure_head`,
`stale_head_period_attestation_refused`, `integrity_detects_each_seeded_defect`,
`books_the_version_before_p8_wrote_wait_for_the_upgrade`); `ledger-import`
`workpaper_pdf_is_deterministic`; `ledger-cli` `tests/audit.rs`
(`audit_is_deterministic`, `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`,
`audits_wait_for_the_upgrade`).
+91
View File
@@ -3831,3 +3831,94 @@ equal-source refusals and the empty-reason refusals; the three
`apply_*_source` arms (a mutant that also bumps `revision` or `seq` shows
in `a_source_only_revision_is_journaled`); `books.start`'s fit order and
`reconciliation` split (`reconciled_through.is_some()`).
## P8: audit and attestations (2026-10-29)
An audit run is a deterministic programme of checks over the books through
a day, filed as a JSON and a PDF workpaper; attestations are claims by a
named client that it checked a receipt, an entry or a period, weighed
against `policy.attestation_bar` when asked. Journal feature `audits`;
events `audit.run`, `receipt.attested`, `entry.reviewed`, `period.attested`
(no figure moves); schema 20 (taken as the next after the last, renumbered
at merge): the `audits` and `attestations` tables; settings
`policy.attestation_bar` (a new `Kind::AttestationBar`) and
`policy.cpa_package_requires_audit`. No kernel change, no Kani harness, no
Lean theorem: the audit calls kernel functions and adds none, and the laws
it checks are already theorems.
- **`ledger-core` `tests/audit.rs`:** `audit_detects_each_seeded_defect`
(the code table `audit::CODES` has a row per check and the test iterates
it: a seeding fixture for each, exactly that code found, its severity the
row's; the checks a correct engine never lets happen are seeded through
the predicate each check is built on, `check_trial_balance`,
`check_equation`, `check_articulation`, `check_cash_flow`,
`check_control`, `check_split`, `check_register`),
`a_whole_book_gives_no_failure_or_warning`,
`findings_come_in_one_order`, `below_bar_not_counted` (the bar's effort,
the year-level adversarial check and verifier count, a lowered bar, the
parse), `amount_mismatch_never_counts`.
- **`ledger-store` `tests/audit.rs`:** `audit_writes_wait_for_audits`
(R-A: every write refused with `FormatUpgradePending`, nothing
journaled, taken after `upgrade_format`, replay equal to live),
`a_run_is_idempotent_on_the_figure_head` (a filing and a run do not move
the head, an entry does, asking again journals nothing),
`amount_mismatch_never_counts` (a wrong amount and a wrong date are
recorded as disagreeing; a receipt not on the entry, a draft and an
unknown entry are refused), `stale_head_period_attestation_refused`
(no audit, an audit that stops short of the period, a standing one, an
entry since), `integrity_detects_each_seeded_defect` (every code of
`ledger_store::audit::CODES`: a missing and a damaged stored file, a
missing extraction text, a duplicate file written by an older version,
a damaged access log, a database that differs from a replay, a damaged
journal, a stale backup, a failed drill),
`books_the_version_before_p8_wrote_wait_for_the_upgrade` (8.0.6 item 2).
`tests/store.rs::books_written_under_older_rules_still_open` hand-writes
the four events (8.0.6 item 1); `tests/format.rs` and
`format.rs::audits_is_its_own_feature` (8.0.6 item 4). The four event
names are now this version's, so the tests that need an event type no
version has written use `ledger.not_yet_written` instead of
`period.attested` (`tests/format.rs`, `ledger-gui/tests/newer_journal.rs`).
- **8.0.6:** the fixture `old-books/p8_before` (writer
`old-books-writer/src/bin/p8_before.rs`, run against b97234ac) holds
receipted entries, a 40.00 purchase with no receipt, a small one, a
reversal, an invoice part paid, an open draft and a closed month;
`books_an_earlier_version_wrote_open_to_its_figures` opens it to that
version's figures, so no figure moved and no golden changed;
`books_p2_served_cut_over_whole` refuses `books.audit` and
`books.review_entry` before consent and takes them after.
- **`ledger-import` `workpaper.rs`:** `workpaper_pdf_is_deterministic`,
`a_different_finding_is_a_different_document`.
- **`ledger-cli` `tests/audit.rs`:** `audit_is_deterministic` (preview,
run, the same run and findings again with nothing journaled, the
workpaper and its PDF hash equal twice, a new entry makes a new audit),
`attestations_count_against_the_bar_when_asked` (a disagreeing reading,
a medium effort, no claim, a lowered bar read retroactively),
`a_period_is_attested_on_a_standing_audit`,
`cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`
(refused, a reason journaled as a decision, an audit alone not enough, a
year attested below the whole bar not enough, the whole bar, an entry
since, `warn`), `audits_wait_for_the_upgrade` (the fixture over the
wire; the package is not held up by an audit it cannot ask for).
- **`ledger-cli` `tests/attention_parity.rs`:**
`audit_items_are_on_both_lists_once_audits_are_declared`.
**For P12 (mutation targets):** `audit::statements`' order of checks and
`sort` (severity, then code, subject, message); each `check_*` predicate's
comparison (`check_trial_balance`'s `balanced`, `check_articulation`'s
`!=`, `check_split`'s `> unit`, `check_register`'s two conditions,
`check_cash_flow`'s three arms, `check_control`'s `difference`, unattached
and `Overpaid` arms); `evidence`'s three arms (Required, the reversal
exemption and the policy test that lists it, the plain exemption) and its
receipt-set lookup; `closed_net`'s sign for revenue and expense;
`AttestationBar::counts` (each of the four conditions, `>=`, the `full`
branch, the `disagrees` early return) and `parse`; `Scope::full_bar` and
`serves_handoff`; `Store::figure_head` (the `NOT_FIGURES` list: a type
wrongly in it lets a stale audit stand, a type wrongly out of it makes an
audit stale at its own filing), `find_audit_run`'s four equalities,
`record_audit_run`'s idempotence return, `attest_receipt`'s amount rule
(the debit total or any one line) and date equality, `run_for_period`'s
`through_seq == head` and `through >= period.end`;
`integrity_findings`'s eight arms; `backup_findings`' three conditions; the
export gate (`cpa_audit_standing`'s four conditions and its feature test,
the `refuse`/`warn` split, the reason's trim); `audit_items` in Needs
attention (the declared test, the attested set, the last-ended fiscal
year, the three year conditions).
+4
View File
@@ -46,6 +46,7 @@ const TYPES: &[&str] = &[
"project.set", "entry.settles",
"entry.draft_state_set", "entry.draft_discarded",
"convention.source_revised", "decision.source_revised", "open_item.source_revised",
"audit.run", "receipt.attested", "entry.reviewed", "period.attested",
// Merged 2026-10-28 (S7, P6): every type EVENT_TYPES names.
];
@@ -68,6 +69,9 @@ const FIELDS: &[&str] = &[
"tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention",
"status", "party_id", "started_on", "ended_on", "links", "notes", "projects",
"via", "by", "to", "reference", "note", "state",
"through", "through_seq", "scope", "program_version", "tb_hash", "findings_sha256",
"workpaper_attachment", "workpaper_pdf", "at", "amount_checked", "date_checked",
"disagrees", "audit_run_id", "period_id", "verifier_agents", "reasoning_effort",
];
/// Journal features a `journal.format_set` may declare.
+13 -1
View File
@@ -1589,7 +1589,19 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `crates/ledger-core/src/audit.rs`, `crates/ledger-store/src/audit.rs`, `crates/ledger-mcp/src/tools/audit.rs`, `templates/workpaper.typ`, `crates/ledger-cli/tests/audit.rs`, `handbook/attestations-and-audit.md`; touches the 8.0.3 files, `tools/prompts.rs`, the CPA package builder.
6. **Tests and proofs:** `audit_is_deterministic`, `audit_detects_each_seeded_defect`, `amount_mismatch_never_counts`, `below_bar_not_counted`, `stale_head_period_attestation_refused`, `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`, `audits_wait_for_the_upgrade`, `workpaper_pdf_is_deterministic`; no kernel change (the audit calls kernel functions; it adds none); Lean: none (the laws it checks are already theorems).
7. **Done when:** (1) the code table has one row per check with its predicate, code, seeding fixture and severity, and `audit_detects_each_seeded_defect` iterates it; (2) a fixture with an unfooted cash flow, a missing receipt, an open draft and a duplicate blob produces exactly those findings; (3) the same head gives the same workpaper sha twice; (4) `export_cpa_package` refuses and the override is journaled; (5) 8.0.6 in the PR; (6) `just check` green.
8. **As built:** none yet.
8. **As built** (2026-10-29, branch `P8`; schema 20, taken at merge): every step built; departures:
- `ledger_core::audit::statements(ledger, input, through)` takes an `AuditInput` (the policy settings, the set of entries with a receipt, the cash flow's footing or why it could not be made, the settings problems, the undecided 1099 vendors) because a ledger cannot read attachments, the role settings or `ledger-config`; `settings_problems` stays in the server's helpers (it needs `ledger_config::Settings`) and the handler passes its strings. Predicates that a correct engine never lets happen (`check_trial_balance`, `check_equation`, `check_articulation`, `check_cash_flow`, `check_control`, `check_split`, `check_register`) are seeded directly; the rest are seeded through the ledger (`crates/ledger-core/tests/audit.rs`, not through `Store`: core cannot depend on the store). `close_split_drifts` tests each member's running total against its exact share within a minor unit, not by recomputing `kernel::apportion_cumulative`. `cash_flow_unfooted` also covers a statement the engine refuses.
- `legacy_reversal_exempt` did not exist as a name in code: it is the info finding for a reversal the policy would cover that has no receipt of its own.
- The integrity module is `crates/ledger-store/src/audit.rs`, a child module of `store` (it reads the store's private state). Backup standing is read by the server and handed in (`BackupStanding`, `backup_findings`); books with no backup target set up have no finding. Codes: `journal_damaged`, `projection_differs`, `blob_missing`, `blob_damaged`, `access_log_damaged`, `extraction_blob_missing`, `duplicate_blob`, `backup_stale`, `drill_failed`.
- The workpaper template is `crates/ledger-import/templates/workpaper.typ` with `crates/ledger-import/src/workpaper.rs` (that is where the other templates live), not `templates/workpaper.typ`. `audit.run` also carries `workpaper_pdf` (the JSON is `workpaper_attachment`). A run is idempotent on (through, figure head, scope, programme); `through_seq` is the figure head (the last line bearing on a figure: audits, attestations, evidence filings, decisions and the format declaration do not move it), not the journal head.
- `policy.attestation_bar` is one canonical text, `effort=high verifiers=2 adversarial=true`, not an object (the TOML codec has no table value); `Kind::AttestationBar`. `counted` is read when asked; a month needs the effort, a year-level run (annual, cpa_handoff, full) the whole bar.
- Scopes: `books.audit` previews at books:write and runs at books:post; the three attest tools are books:write; `books.workpaper` and `books.attestations` books:read. `books.attest_period` takes `period` as an id or a name. A receipt reading agrees when its amount is the entry's debit total or any one line, and its date the entry's.
- The CPA gate applies only on books that declared `audits`, so merging changes nothing on the live books before the go-ahead. `policy.cpa_package_requires_audit` unset means refuse once the books hold an audit run and warn before (not refuse from the start: every export of fresh books in ~30 existing tests would otherwise need a reason); the owner who wants it refused from the start sets `refuse`. `books.export_cpa_package` gained `without_audit_reason`.
- Needs attention: "Month-end attestation due", "Full audit due before the CPA handoff" and "Annual audit due", only for books that declared `audits`. The `audit` prompt (P5) gives the audit procedure; the attestation procedure is the handbook section `attestations-and-audit`.
- `period.attested` was the placeholder "unknown event" in `tests/format.rs` and `ledger-gui/tests/newer_journal.rs`; they use `ledger.not_yet_written` now.
- 8.0.6: `books_written_under_older_rules_still_open` hand-writes the four events; fixture `old-books/p8_before` (writer `p8_before.rs`, run against b97234ac); `audits_is_its_own_feature`; `books_p2_served_cut_over_whole` refuses `books.audit` and `books.review_entry` before consent. No figure moved, no golden changed. Merge does not need the owner's go-ahead; the live books refuse the new writes until he gives it.
- Fitted to P5 after it merged first: `next_steps.rs` (after `books.close_period` the steps are `books.audit` and `books.attest_period`; an audit's run leads to `books.workpaper`, the receipt and entry attestations and `books.attest_period`; the attest tools lead to `books.attestations` and `books.export_cpa_package`), the guard `audit_required` (remedy `books.audit`, also the refusal of a stale `books.attest_period`), the `audit` prompt names the audit tools, and the tool count is 140.
- No kernel, Kani or Lean change.
**Struck from P8:** ~~bar versioning (`counts` derived against the bar in force at that seq; `bar_change_is_prospective`), the Lean counting lemma~~ → IZ-3d; ~~"vault public-key sha"~~ → D-48; ~~"access-log tooling"~~ → `books.access_log` exists (P2); ~~`books.audit` run as a job~~ → D-53.