Files
wanderbooks/crates/ledger-mcp/src/guards.rs
T
swaitsandClaude Fable 5.1 4774bd9abd 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
2026-10-29 16:03:53 -06:00

181 lines
6.6 KiB
Rust

//! The guards: the named ways the books refuse a change, each with the
//! tool that remedies it.
//!
//! ```text
//! GOAL: an agent that is refused is told which rule refused it and which
//! tool to call next, in the same words every time.
//! SOURCE: plan/served.md 4.6 (the result envelope: `guards` carry a code,
//! a message and a remedy tool); DECISION (P5).
//! INVARIANTS:
//! 1. Every guard names a remedy tool that exists (test
//! `every_guard_names_an_existing_tool`).
//! 2. A code is spelled one way: the enum's `code()`; a refusal whose
//! text is `{"code": .., "message": ..}` JSON or says a code in words
//! becomes the guard of that code, and any other refusal has none.
//! ```
use serde_json::{Value, json};
/// Every guard code the books know.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Guard {
/// The preview a confirmation carries is not the one the books would
/// show now.
StalePreview,
/// The books wait for the owner's format upgrade.
FormatUpgradePending,
/// The evidence policy wants a receipt before this posts.
ReceiptRequired,
/// An approval rule wants an approver's go-ahead.
ApprovalRequired,
/// The entry's party shares do not add up.
PartyShares,
/// The parties setting requires a party (or a no-party reason).
PartyRequired,
/// Drafts stand in the way of a close.
OpenDrafts,
/// A line on a control account carries no document tag.
UntaggedControlLine,
/// A vendor already has a bill with this reference.
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; 11] = [
Guard::StalePreview,
Guard::FormatUpgradePending,
Guard::ReceiptRequired,
Guard::ApprovalRequired,
Guard::PartyShares,
Guard::PartyRequired,
Guard::OpenDrafts,
Guard::UntaggedControlLine,
Guard::DuplicateVendorRef,
Guard::FirstNumberLocked,
Guard::AuditRequired,
];
/// The code an agent reads.
pub(crate) fn code(self) -> &'static str {
match self {
Guard::StalePreview => "stale_preview",
Guard::FormatUpgradePending => "format_upgrade_pending",
Guard::ReceiptRequired => "receipt_required",
Guard::ApprovalRequired => "approval_required",
Guard::PartyShares => "party_shares",
Guard::PartyRequired => "party_required",
Guard::OpenDrafts => "open_drafts",
Guard::UntaggedControlLine => "untagged_control_line",
Guard::DuplicateVendorRef => "duplicate_vendor_ref",
Guard::FirstNumberLocked => "first_number_locked",
Guard::AuditRequired => "audit_required",
}
}
/// The guard of a code.
pub(crate) fn from_code(code: &str) -> Option<Guard> {
Self::ALL.into_iter().find(|g| g.code() == code)
}
/// The tool that remedies it; `tool` is the call that was refused (a
/// stale preview is remedied by previewing it again).
pub(crate) fn remedy_tool(self, tool: &str) -> String {
match self {
Guard::StalePreview => return tool.to_string(),
Guard::FormatUpgradePending => "books.start",
Guard::ReceiptRequired => "books.attach_receipt",
Guard::ApprovalRequired => "books.approve_entry",
Guard::PartyShares | Guard::PartyRequired => "books.set_entry_party",
Guard::OpenDrafts => "books.list_drafts",
Guard::UntaggedControlLine => "books.control_check",
Guard::DuplicateVendorRef => "books.search_entries",
Guard::FirstNumberLocked => "books.settings",
Guard::AuditRequired => "books.audit",
}
.to_string()
}
}
/// The guards a refusal's text carries, as the envelope shows them:
/// `[{code, message, remedy_tool, remedy_args?}]`. `tool` is the refused
/// call.
pub(crate) fn from_refusal(tool: &str, text: &str) -> Vec<Value> {
// A structured refusal: the JSON the tool built.
if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(text)
&& let Some(code) = o.get("code").and_then(Value::as_str)
{
let remedy = o
.get("remedy_tool")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| Guard::from_code(code).map(|g| g.remedy_tool(tool)));
if let Some(remedy) = remedy {
let mut g = json!({
"code": code,
"message": o.get("message").and_then(Value::as_str).unwrap_or(code),
"remedy_tool": remedy,
});
if let Some(entry) = o.get("entry_id") {
g["remedy_args"] = json!({ "entry_id": entry });
}
return vec![g];
}
return Vec::new();
}
// A refusal that names its code in words (the store's).
for g in Guard::ALL {
if text.starts_with(g.code()) {
return vec![json!({
"code": g.code(),
"message": text,
"remedy_tool": g.remedy_tool(tool),
})];
}
}
Vec::new()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::BooksServer;
#[test]
fn every_guard_names_an_existing_tool() {
let names: Vec<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for g in Guard::ALL {
let remedy = g.remedy_tool("books.post_entry");
assert!(
names.contains(&remedy),
"guard {} names {remedy}, which is no tool",
g.code()
);
assert_eq!(Guard::from_code(g.code()), Some(g));
}
}
#[test]
fn a_refusal_becomes_its_guard() {
let text = json!({"code":"approval_required","message":"needs approval","entry_id":7,"remedy_tool":"books.approve_entry"}).to_string();
let g = from_refusal("books.post_entry", &text);
assert_eq!(g[0]["code"], "approval_required");
assert_eq!(g[0]["remedy_args"]["entry_id"], 7);
let g = from_refusal(
"books.create_invoice",
"format_upgrade_pending: this needs documents_v2",
);
assert_eq!(g[0]["remedy_tool"], "books.start");
assert!(from_refusal("books.x", "no such account").is_empty());
}
}