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

+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.