diff --git a/bookspec/checker/src/rules.rs b/bookspec/checker/src/rules.rs index 94087c4..44cf549 100644 --- a/bookspec/checker/src/rules.rs +++ b/bookspec/checker/src/rules.rs @@ -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, /// 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), &[], ), ]; diff --git a/crates/ledger-cli/tests/audit.rs b/crates/ledger-cli/tests/audit.rs index ed3e2e2..b8cd31f 100644 --- a/crates/ledger-cli/tests/audit.rs +++ b/crates/ledger-cli/tests/audit.rs @@ -290,6 +290,14 @@ fn a_period_is_attested_on_a_standing_audit() { #[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}); diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index 568a21d..1f20afb 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -418,7 +418,7 @@ pub static KEYS: &[Key] = &[ 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.", + 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", @@ -1639,9 +1639,16 @@ impl Settings { .unwrap_or_default() } - /// `policy.cpa_package_requires_audit` (unset: refuse). - pub fn cpa_package_requires_audit(&self) -> bool { - self.str("policy.cpa_package_requires_audit") != Some("warn") + /// `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 { + match self.str("policy.cpa_package_requires_audit") { + Some("warn") => Some(false), + Some(_) => Some(true), + None => None, + } } /// `backup.git_remote`, if set. diff --git a/crates/ledger-mcp/handbook/attestations-and-audit.md b/crates/ledger-mcp/handbook/attestations-and-audit.md index 0c4b868..8041133 100644 --- a/crates/ledger-mcp/handbook/attestations-and-audit.md +++ b/crates/ledger-mcp/handbook/attestations-and-audit.md @@ -7,7 +7,7 @@ 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 since the first entry). +- 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. @@ -18,7 +18,7 @@ The receipt rule in force: {evidence_rule} 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: do not adjust your reading to fit. +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. @@ -30,4 +30,4 @@ books.attestations lists every claim with counted: whether it meets policy.attes ## The CPA package -Under policy.cpa_package_requires_audit = refuse (the default once audits are declared), 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. +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. diff --git a/crates/ledger-mcp/prompts/audit.md b/crates/ledger-mcp/prompts/audit.md index 4717732..c21e4ed 100644 --- a/crates/ledger-mcp/prompts/audit.md +++ b/crates/ledger-mcp/prompts/audit.md @@ -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. diff --git a/crates/ledger-mcp/src/guards.rs b/crates/ledger-mcp/src/guards.rs index d118c6a..5d04eb0 100644 --- a/crates/ledger-mcp/src/guards.rs +++ b/crates/ledger-mcp/src/guards.rs @@ -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() } diff --git a/crates/ledger-mcp/src/next_steps.rs b/crates/ledger-mcp/src/next_steps.rs index f583801..e77e146 100644 --- a/crates/ledger-mcp/src/next_steps.rs +++ b/crates/ledger-mcp/src/next_steps.rs @@ -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 { diff --git a/crates/ledger-mcp/src/tools/audit.rs b/crates/ledger-mcp/src/tools/audit.rs index 9dc7a0d..b878785 100644 --- a/crates/ledger-mcp/src/tools/audit.rs +++ b/crates/ledger-mcp/src/tools/audit.rs @@ -683,7 +683,15 @@ impl BooksServer { .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(err)?; + 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)?)) } diff --git a/crates/ledger-mcp/src/tools/prompts.rs b/crates/ledger-mcp/src/tools/prompts.rs index 5d8a35f..459d9f3 100644 --- a/crates/ledger-mcp/src/tools/prompts.rs +++ b/crates/ledger-mcp/src/tools/prompts.rs @@ -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.", diff --git a/crates/ledger-mcp/src/tools/reports.rs b/crates/ledger-mcp/src/tools/reports.rs index 0616959..38dbbbd 100644 --- a/crates/ledger-mcp/src/tools/reports.rs +++ b/crates/ledger-mcp/src/tools/reports.rs @@ -1155,7 +1155,11 @@ impl BooksServer { // were: there is no audit to ask for. let mut audit_warning: Option = None; if let Some(why) = self.cpa_audit_standing(&store)? { - if store.ledger().settings().cpa_package_requires_audit() { + 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() diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index df1862e..60cfda8 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -5248,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, } @@ -5896,7 +5896,7 @@ pub struct AuditParams { pub through: Option, /// month (a month-end attestation), annual (a fiscal year), /// cpa_handoff (the year, for the CPA package) or full (everything - /// since the first entry). + /// 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. diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 7f7ae47..17ffa7a 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2797,7 +2797,7 @@ effort, and a period attested on a year-level audit (`annual`, `books.export_cpa_package` gains `without_audit_reason?`: on books that declared `audits`, with `policy.cpa_package_requires_audit` at `refuse` (the -default), the package is refused (`audit_required`) unless an audit with +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. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index a601169..35bf2f0 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -3596,7 +3596,7 @@ withdraw_attachment_duplicate}` and `Store::attach`'s dedup; feature **bookspec:** `BS-ledger-13` **Status:** CONVENTION (the SOURCE block below; Pub. 583 and Rev. Proc. 98-25 -for the audit trail); DECISION D-9 (owner-adopted recommendation, +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). @@ -3607,10 +3607,10 @@ GOAL: the books can be audited by a programme that gives the same 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); DECISION D-9 (the bar: reasoning + 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); DECISION + 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: @@ -3641,7 +3641,8 @@ INVARIANTS: 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`, needs a run of scope + `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. diff --git a/plan/served.md b/plan/served.md index cf56c27..42406ed 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1596,10 +1596,11 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 - 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. `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) was not written: P5 has not merged and `tools/prompts.rs` does not exist; the procedure is the handbook section `attestations-and-audit`. + - 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.