feat(audit): fit the audit tools into the agent surface (P8)

After P5 merged first: a close leads to the audit and the period's
attestation, an audit run to its workpaper and the claims behind it, the
attest tools to the attestations and the CPA package; a refusal for want of
a standing audit is the guard audit_required with books.audit as its
remedy; the audit prompt runs the audit tools. policy.cpa_package_requires_audit,
unset, refuses once the books hold an audit run and warns before, so a
book that has not begun the programme is not stopped from its first handoff
by a rule it has not met; set to refuse, it holds from the start.
bookspec states B-45 (convention) in its checker.

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:03:53 -06:00
1 parent 45bcce679b
commit 4774bd9abd
14 files changed
+139 -33

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),
&[],
),
];
+8
View File
@@ -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});
+11 -4
View File
@@ -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<bool> {
match self.str("policy.cpa_package_requires_audit") {
Some("warn") => Some(false),
Some(_) => Some(true),
None => None,
}
}
/// `backup.git_remote`, if set.
@@ -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.
+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()
}
+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
@@ -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)?))
}
+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.",
+5 -1
View File
@@ -1155,7 +1155,11 @@ impl BooksServer {
// were: there is no audit to ask for.
let mut audit_warning: Option<String> = 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()
+2 -2
View File
@@ -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<String>,
}
@@ -5896,7 +5896,7 @@ pub struct AuditParams {
pub through: Option<String>,
/// 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.
+1 -1
View File
@@ -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.
+5 -4
View File
@@ -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.
+3 -2
View File
@@ -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.