feat(audit): books.audit, the attestation tools and the CPA gate (P8)

The whole re-audit through the tools: books.audit previews its checks, then
with the preview's digest runs them, files the workpaper and journals the
run; the same books, day and scope return the run that exists.
books.attest_receipt, books.review_entry and books.attest_period record a
named client's claim with its self-reported method; books.attestations
weighs each against policy.attestation_bar as it stands. Under
policy.cpa_package_requires_audit = refuse, books that declared audits
cannot export the CPA package without a counted audit for the handoff at the
current head unless a reason is given, which is journaled as a decision
(D-9). Needs attention asks for the month-end attestation, the annual audit
and the audit before the handoff. Rules B-45, the handbook section, the
interface, parity, receipt-store and testing records, and the As-built
block. Merge needs no go-ahead: the live books refuse the new writes until
the owner gives it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:54:26 -06:00
1 parent d45e842873
commit 45bcce679b
23 files changed
+2128 -22

No files matched your search

+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}");
}
+411
View File
@@ -0,0 +1,411 @@
//! 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");
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!({}));