fix: Windows reads of lock files, update paths cfg(unix), merge main again (P11c)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

spec_vault swept every file under a book, lock files included; on Windows
a held LockFileEx lock is mandatory and the read failed. The locks are
already dedicated files (never data), so the test skips *.lock. Merged
main (P13 step 6, the macOS server.log assert, P11a's AppImage identity):
host.rs keeps main's five-field identity on Unix and size/last-write on
Windows; the update poll and set_program_version are Unix-only.

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 17:03:30 -06:00
commit 7749afc557
234 files changed
+13028 -18411

No files matched your search

+5 -1
View File
@@ -323,7 +323,9 @@ fn run_local(mode: &str, args: Vec<String>) -> i32 {
"connect" => ledger_host::commands::connect(&prog, args),
"passphrase" => ledger_host::commands::passphrase(&prog, args),
#[cfg(unix)]
"update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args),
"update" => {
ledger_host::update::update(&prog, ledger_host::update::program_version(), args)
}
#[cfg(unix)]
"rollback" => ledger_host::update::rollback(&prog, args),
// Updates replace a running program's file in place, which Windows
@@ -377,6 +379,8 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
}
fn main() {
#[cfg(unix)]
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
// A file's path may be any name the system takes: the file commands
// get the words as given. Everything else is text.
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
+202
View File
@@ -0,0 +1,202 @@
//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a
//! JSON file in `agent_evals/`, played against a real books server through
//! the wire as an agent reaches it. The rule they enforce is the point of
//! the result envelope: **an agent that begins with `books.start` can do
//! its work by calling only what the previous result's `next_steps` named.**
//!
//! A transcript is `{ "setup": [..], "settings": "<toml>", "steps": [..] }`.
//! `setup` calls are made directly (the chart and the books an agent finds
//! when it arrives; they are not the agent's moves). A step is one of:
//!
//! - `{"call": "books.start", "args": {..}}`: only the first step, and
//! only `books.start`.
//! - `{"follow": "books.x", "with": {..}}`: the previous result's
//! `next_steps` must name tool `books.x`; the call is made with that
//! step's `args` and `with` laid over them (what the agent decides: an
//! amount, a date).
//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps`
//! must name that prompt; it is fetched with `prompts/get`.
//! - `{"expect_next_steps_contains": "books.x"}` and
//! `{"expect": {"/json/pointer": value}}` on the previous result.
//!
//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the
//! upload_id of that file, uploaded first as an agent does, and
//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x":
//! "/pointer"}`.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("The result
//! envelope") and plan 4.6.
mod common;
use common::{Books, Wire, answer_of};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
/// A value with `$upload` and `$var` laid in.
fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
match v {
Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => {
let u = &o["$upload"];
let text = u["text"].as_str().expect("an upload has text");
json!(w.upload(
text.as_bytes(),
u["name"].as_str().unwrap_or("file.pdf"),
u["mime"].as_str().unwrap_or("application/pdf"),
u["purpose"].as_str().unwrap_or("receipt"),
))
}
Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars
.get(o["$var"].as_str().unwrap())
.unwrap_or_else(|| panic!("no saved value {}", o["$var"]))
.clone(),
Value::Object(o) => Value::Object(
o.iter()
.map(|(k, x)| (k.clone(), resolve(w, vars, x)))
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()),
other => other.clone(),
}
}
/// Play one transcript; returns the tools it called after `books.start`.
fn play(name: &str, script: &Value) -> Vec<String> {
let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str());
let mut w = books.session("agent-evals");
let mut vars: HashMap<String, Value> = HashMap::new();
for s in script["setup"].as_array().into_iter().flatten() {
let args = resolve(&mut w, &vars, &s["args"]);
let tool = s["call"].as_str().unwrap();
let out =
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}"));
for (var, pointer) in s["save"].as_object().into_iter().flatten() {
vars.insert(
var.clone(),
out.pointer(pointer.as_str().unwrap())
.unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}"))
.clone(),
);
}
}
let mut last: Option<Value> = None;
let mut called = Vec::new();
for (i, step) in script["steps"]
.as_array()
.expect("steps")
.iter()
.enumerate()
{
let here = format!("{name} step {i}");
if let Some(tool) = step["call"].as_str() {
assert_eq!(
i, 0,
"{here}: only the first step may call without following"
);
assert_eq!(tool, "books.start", "{here}: the first call is books.start");
let args = resolve(&mut w, &vars, &step["args"]);
last =
Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}")));
} else if let Some(want) = step["follow"].as_str() {
let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
let offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
let named = offered
.iter()
.find(|s| s["tool"] == json!(want))
.unwrap_or_else(|| {
panic!(
"{here}: {want} is not in the previous result's next_steps: {:?}",
offered
.iter()
.map(|s| s["tool"].clone())
.collect::<Vec<_>>()
)
});
let mut args: Map<String, Value> =
named["args"].as_object().cloned().unwrap_or_default();
for (k, v) in step["with"].as_object().into_iter().flatten() {
args.insert(k.clone(), resolve(&mut w, &vars, v));
}
let out = answer_of(&w.call_raw(want, Value::Object(args)))
.unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}"));
called.push(want.to_string());
last = Some(out);
} else if let Some(want) = step["follow_prompt"].as_str() {
let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
assert!(
prev["next_steps"]
.as_array()
.into_iter()
.flatten()
.any(|s| s["prompt"] == json!(want)),
"{here}: prompt {want} is not in next_steps"
);
let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}}));
let text = got["result"]["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}"));
assert!(text.contains("books."), "{here}: {text}");
} else if let Some(tool) = step["expect_next_steps_contains"].as_str() {
let prev = last.as_ref().unwrap();
assert!(
prev["next_steps"]
.as_array()
.into_iter()
.flatten()
.any(|s| s["tool"] == json!(tool)),
"{here}: next_steps lack {tool}: {}",
prev["next_steps"]
);
} else if let Some(expect) = step["expect"].as_object() {
let prev = last.as_ref().unwrap();
for (pointer, want) in expect {
assert_eq!(
prev.pointer(pointer),
Some(want),
"{here}: {pointer} in {prev}"
);
}
} else {
panic!("{here}: not a step: {step}");
}
}
called
}
/// Every transcript follows only what it was told, and ends where it says.
#[test]
fn agent_evals_follow_only_next_steps() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
let names: Vec<String> = files
.iter()
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
[
"draft_awaiting_a_receipt",
"month_end_close",
"onboarding_to_first_post",
"receipt_backed_post",
"reconciliation",
"reversal"
],
"the six evals the plan names"
);
for (name, path) in names.iter().zip(&files) {
let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
.unwrap_or_else(|e| panic!("{name}: {e}"));
let called = play(name, &script);
assert!(!called.is_empty(), "{name} calls nothing");
}
}
@@ -0,0 +1,102 @@
{
"description": "A draft that waits for its receipt, and posts when it arrives.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "Landlord, January",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "40.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "40.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.set_draft_state",
"with": {
"state": "awaiting_evidence",
"note": "the landlord's receipt"
}
},
{
"expect": {
"/state": "awaiting_evidence"
}
},
{
"follow": "books.attach_receipt",
"with": {
"upload_id": {
"$upload": {
"name": "receipt.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 a receipt for rent",
"purpose": "receipt"
}
}
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
@@ -0,0 +1,83 @@
{
"description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
},
{
"call": "books.create_period",
"args": {
"name": "2025-12",
"start": "2025-12-01",
"end": "2025-12-31"
}
}
],
"steps": [
{
"call": "books.start",
"args": {
"focus": "close"
}
},
{
"expect_next_steps_contains": "books.list_periods"
},
{
"follow": "books.list_periods",
"with": {}
},
{
"follow": "books.close_period",
"with": {}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.close_period",
"with": {}
},
{
"expect": {
"/status": "closed"
}
},
{
"expect_next_steps_contains": "books.verify"
},
{
"follow": "books.verify",
"with": {}
},
{
"expect": {
"/status": "ok"
}
}
]
}
@@ -0,0 +1,102 @@
{
"description": "A new book: onboarding to the first posted entry, following only next_steps.",
"steps": [
{
"call": "books.start",
"args": {
"agent": {
"model": "scripted",
"reasoning_effort": "none",
"harness": "agent_evals"
}
}
},
{
"expect": {
"/state": "needs_onboarding"
}
},
{
"expect_next_steps_contains": "books.set_setting"
},
{
"follow_prompt": "onboard"
},
{
"follow": "books.set_setting",
"with": {
"changes": [
{
"key": "company.name",
"value": "Eval Co"
}
],
"decided_by": "Avery Quill"
}
},
{
"expect_next_steps_contains": "books.add_account"
},
{
"follow": "books.add_account",
"with": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"follow": "books.add_account",
"with": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-05",
"description": "Opening capital",
"no_party_reason": "opening balance"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "debit",
"amount": "10.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "3000",
"side": "credit",
"amount": "10.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
},
{
"expect_next_steps_contains": "books.show"
}
]
}
@@ -0,0 +1,96 @@
{
"description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"expect_next_steps_contains": "books.draft_entry"
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "January rent",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "40.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "40.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect_next_steps_contains": "books.attach_receipt"
},
{
"follow": "books.attach_receipt",
"with": {
"upload_id": {
"$upload": {
"name": "receipt.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 a receipt for rent",
"purpose": "receipt"
}
}
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
@@ -0,0 +1,148 @@
{
"description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
},
{
"call": "books.draft_entry",
"args": {
"date": "2026-01-05",
"description": "Opening capital",
"no_party_reason": "opening balance"
},
"save": {
"entry": "/entry_id"
}
},
{
"call": "books.add_line",
"args": {
"entry_id": {
"$var": "entry"
},
"account": "1000",
"side": "debit",
"amount": "10.00"
}
},
{
"call": "books.add_line",
"args": {
"entry_id": {
"$var": "entry"
},
"account": "3000",
"side": "credit",
"amount": "10.00"
}
},
{
"call": "books.post_entry",
"args": {
"entry_id": {
"$var": "entry"
},
"confirmed": true
}
},
{
"call": "books.file_document",
"args": {
"category": "statement",
"file_name": "jan.pdf",
"mime_type": "application/pdf",
"upload_id": {
"$upload": {
"name": "jan.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 January statement",
"purpose": "document"
}
}
},
"save": {
"statement": "/attachment_id"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"expect_next_steps_contains": "books.record_statement"
},
{
"follow": "books.record_statement",
"with": {
"attachment_id": {
"$var": "statement"
},
"account": "1000",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"opening_balance": "0.00",
"closing_balance": "10.00"
}
},
{
"follow": "books.mark_cleared",
"with": {
"entry_id": {
"$var": "entry"
},
"line_index": 0,
"cleared": true
}
},
{
"follow": "books.reconcile",
"with": {
"account": "1000",
"statement_date": "2026-01-31",
"statement_balance": "10.00",
"statement_attachment": {
"$var": "statement"
}
}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.reconcile",
"with": {}
},
{
"expect": {
"/status": "reconciled"
}
}
]
}
@@ -0,0 +1,94 @@
{
"description": "A posted entry reversed: previewed, confirmed, never edited.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "Coffee beans",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "10.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "10.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
},
{
"follow": "books.reverse_entry",
"with": {
"date": "2026-01-11",
"description": "Reverse: entered in error"
}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.reverse_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
+545
View File
@@ -0,0 +1,545 @@
//! The agent surface over the wire (plan P5): `books.start` v2, the
//! journal in words, source corrections, the prompts and the resource
//! templates. Every call goes through a real books server as an agent
//! reaches it (`common.rs`).
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the
//! journal in words (P5)", "The result envelope", "Prompts", "Resources")
//! and plan 4.2, 4.4, 4.5.
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
use std::path::Path;
const SETTINGS: &str = "[company]\nname = \"Surface 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"),
("6000", "Rent", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A posted entry of `amount` rent paid from cash (under the receipt
/// threshold); its id.
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let id = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap()["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();
}
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
id
}
/// `books.journal_history` reads the journal in order, in words; filters
/// by subject and type; pages with `more`; refuses a bad subject; and a
/// refusal changes nothing.
#[test]
fn journal_history_traces_what_the_books_did() {
let books = Books::with("p5-history", "USD", Some(SETTINGS));
let mut w = books.session("p5");
chart(&mut w);
let first = post(&mut w, "2026-01-05", "January rent", "10.00");
let second = post(&mut w, "2026-01-06", "Printer paper", "11.00");
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
let lines = all["lines"].as_array().unwrap();
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
assert!(
seqs.windows(2).all(|p| p[0] < p[1]),
"journal order: {seqs:?}"
);
assert_eq!(all["head_seq"], *seqs.last().unwrap());
assert_eq!(all["more"], false);
let posted = lines
.iter()
.find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first))
.unwrap_or_else(|| panic!("no entry.posted for {first}: {all}"));
assert!(
posted["summary"].as_str().unwrap().contains("January rent"),
"{posted}"
);
// A subject keeps the lines about it, and only those.
let about = answer_of(&w.call(
"books.journal_history",
json!({"subject": format!("entry:{second}")}),
))
.unwrap();
let about = about["lines"].as_array().unwrap();
assert!(about.len() >= 3, "created, lines, posted: {about:?}");
assert!(
about.iter().all(|l| l["entry_id"] == json!(second)),
"{about:?}"
);
// A type group, and an exact type.
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
assert!(
group["lines"]
.as_array()
.unwrap()
.iter()
.all(|l| l["type"].as_str().unwrap().starts_with("entry."))
);
let exact =
answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap();
assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
// Paging: two at a time, from where the last page ended.
let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap();
assert_eq!(page["lines"].as_array().unwrap().len(), 2);
assert_eq!(page["more"], true);
let after = page["lines"][1]["seq"].as_u64().unwrap();
let next = answer_of(&w.call(
"books.journal_history",
json!({"after_seq": after, "limit": 2}),
))
.unwrap();
assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1);
// A bad subject is refused with the shapes; the journal is unchanged.
let before = books.journal();
let err =
answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
assert!(err.contains("entry:12"), "{err}");
assert_eq!(books.journal(), before);
// It is a read: no head in the envelope.
assert!(all.get("head").is_none(), "{all}");
}
/// A source-only correction over the wire: the three tools take `reason`
/// (or `revises` with it), the words stay, the journal says so, and a
/// change of the words is not a correction.
#[test]
fn a_source_only_revision_over_the_wire() {
let books = Books::with("p5-source", "USD", Some(SETTINGS));
let mut w = books.session("p5");
let convention = |by: &str, body: &str| {
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
};
answer_of(&w.call(
"books.set_convention",
convention("Owner", "Reconcile, then close."),
))
.unwrap();
let same = answer_of(&w.call(
"books.set_convention",
convention("Pat Smith", "Reconcile, then close."),
))
.unwrap_err();
assert!(
same.contains("already says exactly that") && same.contains("reason"),
"{same}"
);
let mut fixed = convention("Pat Smith", "Reconcile, then close.");
fixed["reason"] = json!("the CPA decided it");
fixed["citation"] = json!("IRS Pub. 538");
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap();
assert_eq!(
out["convention"]["source"]["decided_by"], "Pat Smith",
"{out}"
);
assert_eq!(out["convention"]["revision"], 1, "the words did not change");
let mut other = convention("Pat Smith", "Close first.");
other["reason"] = json!("x");
let err = answer_of(&w.call("books.set_convention", other)).unwrap_err();
assert!(err.contains("more than its source"), "{err}");
// A decision and an open item.
let decision = answer_of(&w.call(
"books.record_decision",
json!({"subject": "Materiality", "decision_md": "Five hundred dollars.",
"decided_by": ["Owner"], "decided_on": "2026-10-20"}),
))
.unwrap()["id"]
.clone();
let fixed_decision = json!({"revises": decision, "reason": "both decided it",
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
let d = answer_of(&w.call("books.decisions", json!({}))).unwrap();
assert_eq!(
d["decisions"][0]["decided_by"],
json!(["Owner", "Pat Smith"]),
"{d}"
);
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
let no_reason = answer_of(&w.call(
"books.record_decision",
json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}),
))
.unwrap_err();
assert!(no_reason.contains("reason"), "{no_reason}");
let item = answer_of(&w.call(
"books.raise_item",
json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}),
))
.unwrap()["id"]
.clone();
answer_of(&w.call(
"books.raise_item",
json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith",
"decided_on": "2026-10-21"}),
))
.unwrap();
let items = answer_of(&w.call("books.open_items", json!({}))).unwrap();
assert_eq!(items["items"][0]["title"], "Ask the CPA");
assert_eq!(
items["items"][0]["raised"]["decided_by"], "Pat Smith",
"{items}"
);
// The journal says what happened, in order.
let h = answer_of(&w.call(
"books.journal_history",
json!({"types": ["convention.", "decision.", "open_item."]}),
))
.unwrap();
let types: Vec<&str> = h["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect();
assert_eq!(
types,
[
"convention.set",
"convention.source_revised",
"decision.recorded",
"decision.source_revised",
"open_item.raised",
"open_item.source_revised"
]
);
// A restart reads the same books.
books.restart();
let mut w = books.session("p5");
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
assert_eq!(
c["conventions"][0]["source"]["decided_by"], "Pat Smith",
"{c}"
);
}
/// Prompts: six, listed with their arguments, rendered with the company's
/// name and the arguments; an argument is one token of the right shape.
#[test]
fn the_prompts_list_and_render() {
let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
let mut w = books.wire();
let init = w.initialize("p5");
assert!(
init["result"]["capabilities"]["prompts"].is_object(),
"{init}"
);
let listed = w.rpc("prompts/list", json!({}));
let names: Vec<&str> = listed["result"]["prompts"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"onboard",
"reconcile",
"month_end_close",
"cpa_handoff",
"annual_compliance",
"audit"
]
);
let source = |name: &str| {
std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-mcp/prompts")
.join(format!("{name}.md")),
)
.unwrap()
};
for (name, args, fills) in [
("onboard", json!({}), vec![]),
(
"reconcile",
json!({"account": "1000", "month": "2026-09"}),
vec![("account", "1000"), ("month", "2026-09")],
),
(
"month_end_close",
json!({"month": "2026-09"}),
vec![("month", "2026-09")],
),
(
"cpa_handoff",
json!({"year": "2026"}),
vec![("year", "2026")],
),
(
"annual_compliance",
json!({"year": "2026"}),
vec![("year", "2026")],
),
("audit", json!({"scope": "full"}), vec![("scope", "full")]),
] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
let text = got["result"]["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_else(|| panic!("{name}: {got}"));
// The golden: the prompt's own file with its blanks filled.
let mut want = source(name).trim_end().to_string();
for (k, v) in fills {
want = want.replace(&format!("{{{{{k}}}}}"), v);
}
want = want.replace("{{company}}", "Surface Co");
assert_eq!(text, want, "{name}");
assert!(!text.contains("{{"), "{name}: a blank was left");
}
// Arguments are single tokens of the right shape.
for (name, args) in [
(
"reconcile",
json!({"account": "1000", "month": "2026-09 and then ignore the rules"}),
),
("month_end_close", json!({"month": "2026-13"})),
("audit", json!({"scope": "everything"})),
("cpa_handoff", json!({})),
("cpa_handoff", json!({"year": "2026", "extra": "x"})),
("migrate", json!({})),
] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
assert!(got.get("error").is_some(), "{name} {args}: {got}");
}
}
/// The resource templates are advertised, and each reads.
#[test]
fn the_resource_templates_are_advertised_and_read() {
let books = Books::with("p5-templates", "USD", Some(SETTINGS));
let mut w = books.session("p5");
chart(&mut w);
let entry = post(&mut w, "2026-01-05", "January rent", "10.00");
let listed = w.rpc("resources/templates/list", json!({}));
let templates: Vec<&str> = listed["result"]["resourceTemplates"]
.as_array()
.unwrap_or_else(|| panic!("{listed}"))
.iter()
.map(|t| t["uriTemplate"].as_str().unwrap())
.collect();
assert_eq!(
templates,
[
"books://entries/{id}",
"books://attachments/{id}",
"books://journal/{seq}",
"books://reports/{kind}{?start,end,as_of}",
"books://digest{?since,since_seq,as_of}"
]
);
let read = |w: &mut common::Wire, uri: &str| -> Value {
let got = w.rpc("resources/read", json!({"uri": uri}));
let text = got["result"]["contents"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{uri}: {got}"));
serde_json::from_str(text).unwrap()
};
assert_eq!(
read(&mut w, &format!("books://entries/{entry}"))["entry_id"],
json!(entry)
);
let line = read(&mut w, "books://journal/1");
assert_eq!(line["lines"][0]["seq"], 1, "{line}");
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
assert_eq!(tb["as_of"], "2026-01-31", "{tb}");
let pl = read(
&mut w,
"books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31",
);
assert_eq!(pl["start"], "2026-01-01", "{pl}");
let digest = read(&mut w, "books://digest?since_seq=1");
assert!(
digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()
);
let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
assert!(gone.get("error").is_some(), "{gone}");
}
/// `books.start` v2: who is asking, the drafts, reconciliation, backup,
/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo
/// company; the agent's self-report goes to the access log only.
#[test]
fn books_start_v2_is_whole_and_fits() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
let books = Books::placed("p5-start-v2", &fixture);
let mut w = books.session("claude-code");
let raw = w.call(
"books.start",
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
);
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert!(start["book"]["id"].is_string());
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp"));
books.home.assert_hidden(text);
assert_eq!(start["you"]["kind"], "agent", "{start}");
assert_eq!(start["you"]["client_id"], "claude-code");
assert!(!start["you"]["scopes"].as_array().unwrap().is_empty());
assert!(start["drafts"]["building"].is_u64());
assert!(start["drafts"]["awaiting_evidence"].is_array());
for k in ["tracked", "never_reconciled", "behind"] {
assert!(start["reconciliation"][k].is_array(), "{k}");
}
assert!(start["backup"]["stale"].is_boolean());
assert_eq!(start["prompts"].as_array().unwrap().len(), 6);
assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12);
assert!(start["needs_attention"]["counts"].is_object());
for s in start["next_steps"].as_array().unwrap() {
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
}
// The envelope: a read has no head of the envelope's (its own stays).
assert!(start["head"]["seq"].is_u64());
// The self-report is in the access log, beside the call.
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
let agent = log["records"]
.as_array()
.unwrap()
.iter()
.find(|r| r["subject"] == "books.start" && r.get("agent").is_some())
.unwrap_or_else(|| panic!("no agent in the log: {log}"));
assert_eq!(agent["agent"]["model"], "opus");
assert_eq!(agent["agent"]["harness"], "claude-code");
// And nowhere else: not in the answer.
assert!(!text.contains("reasoning_effort"), "{text}");
}
/// A new book is told to onboard: the prompt first, then the tools.
#[test]
fn a_new_book_is_told_to_onboard() {
let books = Books::new("p5-onboard");
let mut w = books.session("p5");
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
assert_eq!(start["state"], "needs_onboarding");
let steps = start["next_steps"].as_array().unwrap();
assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}");
assert!(steps.iter().any(|s| s["tool"] == "books.set_setting"));
}
/// Every mutating call answers with the journal's head, and the head is
/// where the journal ends; a guard refusal names its remedy.
#[test]
fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
let books = Books::with("p5-head", "USD", Some(SETTINGS));
let mut w = books.session("p5");
let added = answer_of(&w.call(
"books.add_account",
json!({"code": "7000", "name": "Misc", "account_type": "expense"}),
))
.unwrap();
let head = added["head"]["seq"].as_u64().unwrap();
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
assert_eq!(
start["head"]["seq"],
json!(head),
"the head the call answered is the journal's"
);
assert!(!added["next_steps"].as_array().unwrap().is_empty());
// A confirm with no preview is refused as stale_preview, naming the
// tool that previews.
chart(&mut w);
let draft = answer_of(&w.call(
"books.draft_entry",
json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.clone();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}),
))
.unwrap();
}
let refused = w.call_raw(
"books.post_entry",
json!({"entry_id": draft, "confirmed": true}),
);
assert_eq!(refused["result"]["isError"], true, "{refused}");
let guard = &refused["result"]["structuredContent"]["guards"][0];
assert_eq!(guard["code"], "stale_preview", "{refused}");
assert_eq!(guard["remedy_tool"], "books.post_entry");
}
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
/// (the books the viewer ships with, a loose end of every kind), and says
/// what it cut.
#[test]
fn books_start_fits_8_kib_on_the_demo_books() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("p5-start-demo");
let source = tempfile::Builder::new()
.prefix("wb-p5-demo-")
.tempdir()
.unwrap();
ledger_gui::demo::build(source.path(), today).unwrap();
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
let mut wire = place.start(None);
wire.initialize("p5");
let raw = wire.call("books.start", json!({}));
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert_eq!(start["state"], "active");
assert!(
!start["needs_attention"]["items"]
.as_array()
.unwrap()
.is_empty(),
"the demo has loose ends: {start}"
);
assert!(
start["needs_attention"]["counts"]
.as_object()
.unwrap()
.len()
>= 2
);
}
+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}");
}
+419
View File
@@ -0,0 +1,419 @@
//! 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");
// 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});
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());
}
+7 -1
View File
@@ -205,7 +205,13 @@ impl Server {
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
assert_eq!(line, "ready\n", "the server didn't say ready");
if line != "ready\n" {
// Say why (the portable jobs run this on macOS and Windows,
// where the only evidence is what the server wrote).
let said =
std::fs::read_to_string(home.dir.path().join("server.log")).unwrap_or_default();
panic!("the server didn't say ready (got {line:?}); its log:\n{said}");
}
Self { child }
}
+265
View File
@@ -0,0 +1,265 @@
//! The re-audit's engineering mirror (plan P10; `docs/runbooks/re-audit.md`):
//! every defect the 2026-10-16 audit found (1 to 20), P0 (21, 22) and P3
//! (23, 24) has a named regression test somewhere in the workspace, and the
//! runbook names only tools and commands that exist.
//!
//! The list below is the single place that says which test stands guard for
//! which defect. A test renamed or deleted fails here, so a defect's
//! reproduction cannot be lost quietly. This proves the tests exist, are
//! real `#[test]`s and are not ignored; that they pass is `cargo test`'s
//! job (the runbook's step 4 runs them by name).
//!
//! SOURCE: plan/served.md 8.0 ("defect n") and P10 step 2.
use std::path::{Path, PathBuf};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
fn read(rel: &str) -> String {
std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
/// `(defect, test name, file)`. Defect 2 is closed by D-12 and D-47 (the
/// rule it now shows is that a reversal needs no receipt of its own);
/// defect 20 (the migration scripts) was retired at the cutover and is
/// checked separately below.
const DEFECTS: [(u32, &str, &str); 23] = [
(
1,
"defect1_two_member_llc_first_year_regression",
"crates/ledger-core/tests/spec_cash_flow.rs",
),
(
2,
"a_reversal_needs_no_receipt_of_its_own",
"crates/ledger-cli/tests/drafts.rs",
),
(
3,
"get_receipt_default_has_text_and_no_bytes",
"crates/ledger-cli/tests/evidence.rs",
),
(
4,
"same_bytes_one_record_many_links",
"crates/ledger-store/tests/attachments.rs",
),
(
5,
"a_draft_waits_for_its_receipt_and_posts",
"crates/ledger-cli/tests/drafts.rs",
),
(
6,
"digest_since_seq_is_posting_time",
"crates/ledger-cli/tests/drafts.rs",
),
(
7,
"the_readme_lists_what_the_iif_writes_differently",
"crates/ledger-package/tests/package.rs",
),
(
8,
"add_account_warns_without_tax_category_defect_8",
"crates/ledger-cli/tests/spec_tax_policy.rs",
),
(
9,
"defect9_a_new_schedule_spreads_by_the_day",
"crates/ledger-core/tests/schedules.rs",
),
(
10,
"annotations_replay_and_project",
"crates/ledger-store/tests/attachments.rs",
),
(
11,
"defect11_the_directory_shows_a_members_tin_last4",
"crates/ledger-cli/tests/spec_vault.rs",
),
(
12,
"payment_methods_round_trip_and_classify_payments",
"crates/ledger-cli/tests/spec_1099.rs",
),
(
13,
"four_contributed_devices_each_take_the_lower_of_value_and_basis",
"crates/ledger-core/tests/spec_asset_basis.rs",
),
(
14,
"defect14_party_totals_take_a_basis",
"crates/ledger-cli/tests/spec_party_totals.rs",
),
(
15,
"rename_account_end_to_end_defect_15",
"crates/ledger-cli/tests/mcp_journeys.rs",
),
(
16,
"one_default_period_everywhere_defect_16",
"crates/ledger-cli/tests/mcp_journeys.rs",
),
(
17,
"the_audit_odd_cent_never_piles_up_across_closes",
"crates/ledger-core/tests/odd_cent.rs",
),
(
18,
"undecided_is_not_not_reportable",
"crates/ledger-core/tests/form1099.rs",
),
(
19,
"no_tool_takes_a_path",
"crates/ledger-mcp/src/server_tests.rs",
),
(
21,
"as_of_package_iif_stops_at_as_of",
"crates/ledger-package/tests/package.rs",
),
(
22,
"defect22_depreciation_through_mid_final_month_is_prorated_not_plugged",
"crates/ledger-core/tests/spec_assets_payroll.rs",
),
(
23,
"a_vendor_marked_no_1099_is_not_undecided",
"crates/ledger-cli/tests/spec_1099.rs",
),
(
24,
"defect24_an_accrual_through_another_liability_counts_once",
"crates/ledger-core/tests/spec_party_totals.rs",
),
];
/// Defects 1 to 24 each have a named test that exists, is a test, and is
/// not ignored (defect 20 is the retired scripts, below).
#[test]
fn every_defect_reproduction_is_a_named_test() {
let mut covered: Vec<u32> = DEFECTS.iter().map(|d| d.0).collect();
covered.push(20);
covered.sort_unstable();
assert_eq!(
covered,
(1..=24).collect::<Vec<u32>>(),
"a defect is missing"
);
let mut missing = Vec::new();
for (defect, name, file) in DEFECTS {
let text = read(file);
let lines: Vec<&str> = text.lines().collect();
let needle = format!("fn {name}(");
let Some(at) = lines.iter().position(|l| {
l.trim_start().starts_with(&needle)
|| l.trim_start().starts_with(&format!("pub {needle}"))
}) else {
missing.push(format!("defect {defect}: no `fn {name}` in {file}"));
continue;
};
// The attributes and doc lines above the function, up to the
// previous blank line or item.
let above: String = lines[at.saturating_sub(8)..at].join("\n");
let in_proptest = text.contains("proptest!");
if !above.contains("#[test]") && !in_proptest {
missing.push(format!(
"defect {defect}: `{name}` in {file} is not a #[test]"
));
}
if above.contains("#[ignore") {
missing.push(format!("defect {defect}: `{name}` in {file} is ignored"));
}
}
assert!(missing.is_empty(), "{}", missing.join("\n"));
}
/// Defect 20, the migration scripts, was retired at the cutover: there is
/// no tool for them, and none may come back as a way past the journal.
/// The defect 19 mirror (`import_local_file` and every path parameter) is
/// `no_tool_takes_a_path` above.
#[test]
fn defect_20_no_migration_tool_exists() {
let scopes = read("crates/ledger-mcp/src/scopes.rs");
for line in scopes.lines().filter(|l| l.contains("(\"books.")) {
for banned in [
"migrate",
"import_local_file",
"import_sheet",
"verify_sheet",
] {
assert!(!line.contains(banned), "a retired tool is back: {line}");
}
}
}
/// The runbook names only tools and commands that exist: each
/// `books.<name>` is in the scopes table (the 8.0.3 list of tools), each
/// `wanderbooks <command>` is a command `main.rs` dispatches, and each
/// test file it names is a file.
#[test]
fn the_re_audit_runbook_names_only_what_exists() {
let runbook = read("docs/runbooks/re-audit.md");
let scopes = read("crates/ledger-mcp/src/scopes.rs");
let main = read("crates/ledger-cli/src/main.rs");
let mut tools = Vec::new();
let mut rest = runbook.as_str();
while let Some(i) = rest.find("books.") {
let tail = &rest[i..];
let end = tail
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
.unwrap_or(tail.len());
let name = tail[..end].trim_end_matches('.');
// Prose such as "books.start's" ends at the apostrophe; a bare
// "books." (the company's books followed by a full stop) is not a name.
if name.len() > "books.".len() && !tools.contains(&name.to_string()) {
tools.push(name.to_string());
}
rest = &tail[end.max(1)..];
}
assert!(
tools.len() > 20,
"the runbook should name its tools: {tools:?}"
);
let unknown: Vec<&String> = tools
.iter()
.filter(|t| !scopes.contains(&format!("(\"{t}\"")))
.collect();
assert!(
unknown.is_empty(),
"tools the runbook names that do not exist: {unknown:?}"
);
for cmd in ["gui", "connect"] {
if runbook.contains(&format!("wanderbooks {cmd}")) {
assert!(main.contains(&format!("\"{cmd}\"")), "no command {cmd}");
}
}
assert!(
runbook.contains("wanderbooks gui"),
"step 6 names the viewer"
);
for step in 1..=8 {
assert!(
runbook.contains(&format!("## Step {step}.")),
"the runbook lost step {step}"
);
}
// The runbook must not point at the data root or tell anyone to read it.
assert!(!runbook.contains("wanderbooks.v"), "names a database file");
}
+15 -15
View File
@@ -496,18 +496,18 @@ fn terms_fall_back_in_order() {
assert_eq!(bill["terms"], "Net 10", "{bill}");
}
/// `party_id` is the old spelling of `customer`/`vendor` and still names
/// the same record (until P5); a number that is one record's id and
/// A customer given by id and by name is the same record (the old
/// `party_id` spelling is gone, P5); a number that is one record's id and
/// another's name is refused, never guessed.
#[test]
fn party_id_alias_still_works() {
fn a_customer_by_id_and_by_name_is_one_record() {
let books = Books::new("documents-alias");
let mut m = Mcp::spawn(&books);
let harbor = m.customer("Harbor Coffee");
let line = json!([{"description": "Work", "amount": "10.00"}]);
let by_alias = m.call(
let by_id = m.call(
"books.create_invoice",
json!({"party_id": harbor, "date": "2026-02-01", "terms": "Net 30",
json!({"customer": (harbor).to_string(), "date": "2026-02-01", "terms": "Net 30",
"lines": line, "confirmed": true}),
);
let by_name = m.call(
@@ -520,8 +520,8 @@ fn party_id_alias_still_works() {
.iter()
.map(|e| e["payload"]["customer_id"].as_u64().unwrap())
.collect();
assert_eq!(parties, [harbor, harbor], "one customer, two spellings");
assert_eq!(by_alias["total"], by_name["total"]);
assert_eq!(parties, [harbor, harbor], "one customer, an id and a name");
assert_eq!(by_id["total"], by_name["total"]);
// A customer named "2" that is not customer 2: the number is
// ambiguous, and is refused rather than guessed.
@@ -730,7 +730,7 @@ fn mark_sent_records_who_and_how() {
.unwrap();
let invoice = common::answer_of(&wire.call(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-03-01", "due_date": "2026-03-31",
json!({"customer": (customer).to_string(), "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "Design", "amount": "1200.00"}], "confirmed": true}),
))
.unwrap()["id"]
@@ -876,7 +876,7 @@ mod paying_on_terms {
ok(
&mut self.wire,
"books.create_invoice",
json!({"party_id": self.customer, "date": date, "due_date": due,
json!({"customer": (self.customer).to_string(), "date": date, "due_date": due,
"lines": [{"description": "Consulting", "amount": amount}],
"confirmed": true}),
)["id"]
@@ -897,7 +897,7 @@ mod paying_on_terms {
ok(
&mut self.wire,
"books.create_bill",
json!({"party_id": self.vendor, "date": date, "due_date": due,
json!({"vendor": (self.vendor).to_string(), "date": date, "due_date": due,
"vendor_ref": format!("TH-{}", self.bills),
"lines": [{"description": "Paper", "amount": amount, "account": "6999"}],
"upload_id": file, "confirmed": true}),
@@ -1258,7 +1258,7 @@ mod bad_debts {
wire,
"books.create_invoice",
json!({
"party_id": customer,
"customer": (customer).to_string(),
"date": "2026-01-05",
"due_date": "2026-02-04",
"lines": [{ "description": "Design", "amount": "100.00" }],
@@ -1286,10 +1286,10 @@ mod bad_debts {
&mut wire,
"books.receive_payment",
json!({"confirmed": true,
"document_id": inv,
"invoice": inv,
"date": "2026-01-20",
"amount": "40.00",
"cash_account": "1000",
"deposit_to": "1000",
}),
);
// The write-off's evidence, uploaded as an agent uploads it (D-46).
@@ -1361,10 +1361,10 @@ mod bad_debts {
&mut wire,
"books.receive_payment",
json!({"confirmed": true,
"document_id": inv,
"invoice": inv,
"date": "2026-03-02",
"amount": "60.00",
"cash_account": "1000",
"deposit_to": "1000",
}),
);
let doc = ok(
+28 -2
View File
@@ -767,7 +767,7 @@ fn books_p2_served_cut_over_whole() {
let invoice = a
.call(
"books.create_invoice",
json!({"party_id": 1, "date": "2026-01-05", "due_date": "2026-02-04",
json!({"customer": (1).to_string(), "date": "2026-01-05", "due_date": "2026-02-04",
"lines": [{"description": "Design", "amount": "400.00"}], "confirmed": true}),
)
.unwrap()["id"]
@@ -789,7 +789,7 @@ fn books_p2_served_cut_over_whole() {
let unpaid = a
.call(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-01-10", "due_date": "2026-02-09",
json!({"customer": (customer).to_string(), "date": "2026-01-10", "due_date": "2026-02-09",
"lines": [{"description": "Survey", "amount": "300.00"}], "confirmed": true}),
)
.unwrap()["id"]
@@ -812,8 +812,24 @@ fn books_p2_served_cut_over_whole() {
)
.unwrap();
}
// An open item P2 reads: the correction of its source is
// source_revisions' (P5), and waits.
let item = a
.call(
"books.raise_item",
json!({"title": "Ask the CPA about the vehicle", "decided_by": "Avery Quill"}),
)
.unwrap()["id"]
.clone();
let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![
// A source correction (source_revisions, P5): an older version
// refuses the type.
(
"books.raise_item",
json!({"revises": item, "reason": "it was the CPA who raised it",
"decided_by": "Pat Smith"}),
),
// A draft's state and its discard (drafts_v2, P4): an older
// version refuses the types.
(
@@ -893,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();
+3 -3
View File
@@ -1017,13 +1017,13 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
),
(
"books.create_invoice",
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
json!({"customer": (client).to_string(), "date": "2026-05-01", "due_date": "2026-06-01",
"lines": [{"description": "a", "amount": MAX},
{"description": "b", "amount": "1"}], "confirmed": true}),
),
(
"books.create_bill",
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
json!({"vendor": (acme).to_string(), "date": "2026-05-01", "due_date": "2026-06-01",
"vendor_ref": "H-1",
"lines": [{"description": "a", "amount": MAX, "account": "6000"},
{"description": "b", "amount": "1", "account": "6000"}],
@@ -1031,7 +1031,7 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
),
(
"books.create_invoice",
json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31",
json!({"customer": (client).to_string(), "date": "2026-05-01", "due_date": "+262142-12-31",
"lines": [{"description": "a", "amount": "1"}], "confirmed": true}),
),
(
+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(), 133, "expected 133 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"));
+6 -3
View File
@@ -230,8 +230,8 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
server.tool("books.add_project", json!({"name": "Heron"}));
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
let doc = |party: &Value, date: &str, lines: Value| {
json!({"party_id": party, "date": date, "due_date": date, "lines": lines,
let doc = |key: &str, party: &Value, date: &str, lines: Value| {
json!({key: party.to_string(), "date": date, "due_date": date, "lines": lines,
"confirmed": true})
};
server.tool(
@@ -241,6 +241,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
let inv = server.tool(
"books.create_invoice",
doc(
"customer",
&cedar,
"2026-02-02",
json!([
@@ -253,6 +254,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
let inv2 = server.tool(
"books.create_invoice",
doc(
"customer",
&cedar,
"2026-05-04",
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
@@ -262,6 +264,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
.wire
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
let mut bill_args = doc(
"vendor",
&dana,
"2026-03-09",
json!([
@@ -274,7 +277,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
let bill = server.tool("books.create_bill", bill_args);
server.tool(
"books.receive_payment",
json!({"confirmed": true, "document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-02-20", "amount": "1830.00", "deposit_to": "1000"}),
);
let b = data::load_once(&Source {
data_dir: data.clone(),
+3 -1
View File
@@ -606,10 +606,12 @@ fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() {
let p1 = m.ok("books.dispose_asset", by_id.clone());
let p2 = m.ok("books.dispose_asset", by_tag.clone());
// (The digest names the arguments as sent, so it differs by id and
// by tag; everything it shows is the same.)
// by tag, and so does the confirm step the envelope offers, which
// repeats them; everything the preview shows is the same.)
let (mut p1, mut p2) = (p1, p2);
for p in [&mut p1, &mut p2] {
p.as_object_mut().unwrap().remove("preview");
p.as_object_mut().unwrap().remove("next_steps");
}
assert_eq!(p1, p2, "same preview by id and by tag");
let mut both = by_id.clone();
+2 -2
View File
@@ -424,7 +424,7 @@ fn a_file_links_to_every_kind_of_subject() {
let rent_invoice = m.upload(b"rent invoice", "rent.pdf", "application/pdf", "receipt");
let bill = m.ok(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
json!({"vendor": (vendor).to_string(), "date": "2026-02-01", "due_date": "2026-02-28",
"vendor_ref": "RENT-2",
"lines": [{"description": "Rent", "amount": "300.00", "account": "6000"}],
"upload_id": rent_invoice, "confirmed": true}),
@@ -432,7 +432,7 @@ fn a_file_links_to_every_kind_of_subject() {
.clone();
let invoice = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Work", "amount": "500.00"}],
"confirmed": true}),
)["id"]
+41 -17
View File
@@ -911,7 +911,7 @@ fn happy_path(name: &str) -> Mcp {
// A/R and A/P.
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}],
"confirmed": true}),
)["id"]
@@ -919,7 +919,7 @@ fn happy_path(name: &str) -> Mcp {
.unwrap();
m.ok(
"books.receive_payment",
json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
json!({"confirmed": true, "invoice": inv, "date": "2026-02-10", "amount": "200.00", "deposit_to": "1000"}),
);
let vendor_invoice = m.upload(
b"vendor invoice",
@@ -929,7 +929,7 @@ fn happy_path(name: &str) -> Mcp {
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
json!({"vendor": (vendor).to_string(), "date": "2026-02-01", "due_date": "2026-02-28",
"vendor_ref": "V-1001",
"lines": [{"description": "Office rent", "amount": "300.00", "account": "6000"}],
"upload_id": vendor_invoice, "confirmed": true}),
@@ -938,7 +938,7 @@ fn happy_path(name: &str) -> Mcp {
.unwrap();
m.ok(
"books.pay_bill",
json!({"confirmed": true, "document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}),
json!({"confirmed": true, "bill": bill, "date": "2026-02-05", "amount": "300.00", "paid_from": "1000"}),
);
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
@@ -946,7 +946,7 @@ fn happy_path(name: &str) -> Mcp {
// P4b S1: a document voided, and the control accounts' tie-out.
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
json!({"customer": (customer).to_string(), "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
"confirmed": true}),
)["id"]
@@ -972,7 +972,7 @@ fn happy_path(name: &str) -> Mcp {
// P4b S7: a bad debt, its evidence uploaded and filed with it.
let bad = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-03", "due_date": "2026-03-03",
json!({"customer": (customer).to_string(), "date": "2026-02-03", "due_date": "2026-03-03",
"lines": [{"description": "Consulting, never paid", "amount": "400.00"}],
"confirmed": true}),
)["id"]
@@ -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!({}));
@@ -1294,6 +1316,8 @@ fn happy_path(name: &str) -> Mcp {
// The access log, the backups' status, and a backup now (none is set
// up: it says so).
m.ok("books.access_log", json!({}));
// P5: the journal in words.
m.ok("books.journal_history", json!({"limit": 5}));
m.ok("books.backup", json!({}));
m.ok("books.back_up_now", json!({}));
@@ -1685,15 +1709,15 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
vendor_ref,
} => (
"books.create_bill",
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
json!({"vendor": (party.resolve(&k.vendors)).to_string(), "date": "2026-03-01",
"due_date": "2026-03-31", "vendor_ref": vendor_ref,
"lines": [{"description": "thing", "amount": amount, "account": "6000"}],
"existing_attachment_id": k.vendor_invoice, "confirmed": true}),
),
Op::PayBill { doc, amount } => (
"books.pay_bill",
json!({"confirmed": true, "document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
"cash_account": "1000"}),
json!({"confirmed": true, "bill": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
"paid_from": "1000"}),
),
Op::Payout { deposit } => (
"books.record_payout",
@@ -1834,7 +1858,7 @@ fn inv3_other_tools_refuse_ungrammatical_amounts() {
);
m.refuse(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-03-01", "due_date": "2026-03-31",
json!({"vendor": (vendor).to_string(), "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "x", "amount": bad}]}),
);
m.refuse(
@@ -2591,7 +2615,7 @@ fn assigned_roles_steer_invoices() {
.unwrap();
m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}],
"confirmed": true}),
);
@@ -2654,7 +2678,7 @@ fn documents_void_and_tie_out_over_the_wire() {
);
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}],
"confirmed": true}),
)["id"]
@@ -2662,8 +2686,8 @@ fn documents_void_and_tie_out_over_the_wire() {
.unwrap();
let paid = m.ok(
"books.receive_payment",
json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00",
"cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
json!({"confirmed": true, "invoice": inv, "date": "2026-02-10", "amount": "200.00",
"deposit_to": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
);
assert_eq!(paid["remaining"], "300.00", "{paid}");
assert_eq!(paid["status"], "partial", "{paid}");
@@ -2685,7 +2709,7 @@ fn documents_void_and_tie_out_over_the_wire() {
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
json!({"customer": (customer).to_string(), "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
"confirmed": true}),
)["id"]
@@ -2753,7 +2777,7 @@ fn documents_list_and_timeline_over_the_wire() {
let invoice = |m: &mut Mcp, date: &str, due: &str, amount: &str| {
m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": date, "due_date": due,
json!({"customer": (customer).to_string(), "date": date, "due_date": due,
"lines": [{"description": "Consulting", "amount": amount}],
"confirmed": true}),
)["id"]
@@ -2764,7 +2788,7 @@ fn documents_list_and_timeline_over_the_wire() {
let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00");
let pay = m.ok(
"books.receive_payment",
json!({"confirmed": true, "document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
json!({"confirmed": true, "invoice": late, "date": "2026-02-10", "amount": "200.00", "deposit_to": "1000"}),
)["entry_id"]
.as_u64()
.unwrap();
+6 -6
View File
@@ -835,15 +835,15 @@ fn party_totals_match_a_hand_computed_year() {
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31",
json!({"vendor": (v).to_string(), "date": "2026-07-01", "due_date": "2026-07-31",
"vendor_ref": "D-1041",
"lines": [{"description": "Logo", "amount": "800.00", "account": "6000"}],
"upload_id": logo_invoice, "confirmed": true}),
);
let pay = m.ok(
"books.pay_bill",
json!({"confirmed": true, "document_id": bill["id"], "date": "2026-07-15", "amount": "300.00",
"cash_account": "1000"}),
json!({"confirmed": true, "bill": bill["id"], "date": "2026-07-15", "amount": "300.00",
"paid_from": "1000"}),
)["entry_id"]
.as_u64()
.unwrap();
@@ -917,14 +917,14 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
.unwrap();
let inv = m.ok(
"books.create_invoice",
json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31",
json!({"customer": (c).to_string(), "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "Work", "amount": "1000.00"}],
"confirmed": true}),
);
let pay = m.ok(
"books.receive_payment",
json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "600.00",
"cash_account": "1000"}),
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "600.00",
"deposit_to": "1000"}),
)["entry_id"]
.as_u64()
.unwrap();
+18 -12
View File
@@ -1231,7 +1231,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
);
let inv = m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "Design", "amount": "3000.00", "class": "Zeta site"},
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
{"description": "Rush fee", "amount": "100.00"},
@@ -1264,7 +1264,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08",
json!({"vendor": (dana_vendor).to_string(), "date": "2026-03-09", "due_date": "2026-04-08",
"vendor_ref": "DANA-77",
"lines": [
{"description": "Illustration", "amount": "1200.00", "account": "6000", "class": "Zeta site"},
@@ -1284,11 +1284,11 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
// Payments are balance-sheet only: no project.
let got = m.ok(
"books.receive_payment",
json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}),
json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "3800.00", "deposit_to": "1000"}),
);
let paid = m.ok(
"books.pay_bill",
json!({"confirmed": true, "document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}),
json!({"confirmed": true, "bill": bill["id"], "date": "2026-03-30", "amount": "1250.00", "paid_from": "1000"}),
);
for e in [&got["entry_id"], &paid["entry_id"]] {
let e = e.as_u64().unwrap();
@@ -1353,10 +1353,13 @@ fn a_document_naming_an_unknown_project_is_refused_whole() {
m.ok("books.add_project", json!({"name": "Zeta site"}));
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
for (tool, key, party) in [
("books.create_invoice", "customer", &cedar),
("books.create_bill", "vendor", &v),
] {
let why = m.refuse(
tool,
json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
json!({key: (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00", "class": "Zeta site"},
{"description": "b", "amount": "10.00", "class": "Nowhere"},
]}),
@@ -1379,7 +1382,7 @@ fn a_document_for_a_finished_project_says_so() {
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let out = m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Final", "amount": "10.00", "class": "Harbor"},
{"description": "Again", "amount": "10.00", "class": "harbor"},
{"description": "Old", "amount": "10.00", "class": "Dock"},
@@ -1424,7 +1427,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
let out = m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Build", "amount": "10.00", "class": "Kestrel"},
{"description": "More", "amount": "5.00", "class": "kestrel"},
{"description": "Site", "amount": "7.00", "class": "Zeta"},
@@ -1450,7 +1453,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04",
json!({"vendor": (v).to_string(), "date": "2026-10-05", "due_date": "2026-11-04",
"vendor_ref": "V-512",
"lines": [
{"description": "Icons", "amount": "10.00", "account": "6000", "class": "Kestrel"},
@@ -1472,10 +1475,13 @@ fn books_without_the_feature_refuse_a_tagged_document() {
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
let head = m.journal();
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
for (tool, key, party) in [
("books.create_invoice", "customer", &cedar),
("books.create_bill", "vendor", &v),
] {
let why = m.refuse(
tool,
json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
json!({key: (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00", "class": "Zeta site"},
]}),
);
@@ -1487,7 +1493,7 @@ fn books_without_the_feature_refuse_a_tagged_document() {
assert_eq!(m.journal(), head, "nothing journaled");
m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00"},
], "confirmed": true}),
);
+2 -2
View File
@@ -278,7 +278,7 @@ fn require_tax_category_refuses_defect_8() {
);
let bill = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
json!({"vendor": (vendor["id"]).to_string(), "date": "2026-02-01", "due_date": "2026-03-01",
"vendor_ref": "CH-1",
"lines": [{"description": "hosting", "amount": "42.00", "account": "6150"}],
"upload_id": first_invoice, "confirmed": true}),
@@ -303,7 +303,7 @@ fn require_tax_category_refuses_defect_8() {
);
let again = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
json!({"vendor": (vendor["id"]).to_string(), "date": "2026-02-02", "due_date": "2026-03-02",
"vendor_ref": "CH-2",
"lines": [{"description": "hosting", "amount": "1.00", "account": "6150"}],
"upload_id": second_invoice, "confirmed": true}),
+3
View File
@@ -521,6 +521,9 @@ fn files_under(dir: &Path) -> Vec<PathBuf> {
let p = e.path();
if p.is_dir() {
out.extend(files_under(&p));
} else if p.extension().is_some_and(|x| x == "lock") {
// A lock file holds no data, and on Windows a held lock is
// mandatory: reading it is refused while the server runs.
} else {
out.push(p);
}
+255 -25
View File
@@ -4,7 +4,8 @@
//! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back,
//! unsigned_update_refused, bridge_survives_update,
//! update_waits_for_a_running_call, package_managed_install_refuses_update,
//! and the rollback laws (kept version, the format gate).
//! and the rollback laws (kept version, the format gate); and step 4's
//! daily check, staged update and apply-at-next-start.
#![cfg(target_os = "linux")]
mod common;
@@ -16,8 +17,7 @@ use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400";
/// The platform key a manifest names, as the program computes it.
fn platform() -> String {
@@ -76,6 +76,10 @@ fn base_program() -> &'static Path {
struct Home {
dir: tempfile::TempDir,
exe: PathBuf,
/// A pretend AppImage (P11a step 4): the programs run from `exe`, as
/// from an image's mount, and `APPIMAGE` names this other file, the
/// one an update replaces.
image: Option<PathBuf>,
}
impl Home {
@@ -95,7 +99,22 @@ impl Home {
std::fs::create_dir(&bin).unwrap();
let exe = bin.join("wanderbooks");
std::fs::copy(base_program(), &exe).unwrap();
let home = Self { dir, exe };
let home = Self {
dir,
exe,
image: None,
};
home.runnable();
home
}
/// A home whose program is an AppImage: the same program twice, one
/// copy to run (the mount's) and one the image file `APPIMAGE` names.
fn new_image(tag: &str) -> Self {
let mut home = Self::new(tag);
let image = home.dir.path().join("Wanderbooks.AppImage");
std::fs::copy(base_program(), &image).unwrap();
home.image = Some(image);
home.runnable();
home
}
@@ -103,25 +122,35 @@ impl Home {
/// Wait until the copy can be run (a child another test thread forked
/// while it was written may hold it open for a moment).
fn runnable(&self) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match Command::new(&self.exe)
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
assert!(Instant::now() < deadline, "the copy stayed busy");
std::thread::sleep(Duration::from_millis(20));
for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match Command::new(exe)
.arg("--help")
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
{
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => {
assert!(Instant::now() < deadline, "the copy stayed busy");
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => panic!("the copy can't be run: {e}"),
Ok(_) => break,
}
Err(e) => panic!("the copy can't be run: {e}"),
Ok(_) => return,
}
}
}
/// The environment of the pretend AppImage, if this is one.
fn image_env(&self, c: &mut Command) {
if let Some(image) = &self.image {
c.env("APPIMAGE", image);
}
}
fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks")
}
@@ -139,11 +168,13 @@ impl Home {
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", self.dir.path());
self.image_env(&mut c);
c
}
/// The program that updates replace: the image, if there is one.
fn program(&self) -> Vec<u8> {
std::fs::read(&self.exe).unwrap()
std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap()
}
fn create(&self, id: &str) {
@@ -180,6 +211,7 @@ impl Home {
.env("PATH", "/usr/bin:/bin")
.env("HOME", self.dir.path())
.stdin(Stdio::null());
self.image_env(&mut c);
c.output().unwrap()
}
@@ -655,7 +687,11 @@ impl Drop for Agent {
/// host and server run the new file afterwards, and the head is the one
/// the writes made.
fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) {
let home = Home::new(tag);
update_under_writes_in(Home::new(tag))
}
fn update_under_writes_in(home: Home) -> (Home, Agent, Vec<String>) {
let tag = "under-writes";
home.create("acme");
let release = Release::new();
let next = newer(&home, tag);
@@ -673,18 +709,21 @@ fn update_under_writes(tag: &str) -> (Home, Agent, Vec<String>) {
release.dir.path().join("manifest.json").display()
);
let key = release.dir.path().join("release.pub");
let image = home.image.clone();
std::thread::spawn(move || {
let _keep = &release;
Command::new(exe)
.args(["update", "--root"])
let mut c = Command::new(exe);
c.args(["update", "--root"])
.arg(root)
.args(["--feed", &feed, "--trusted-key"])
.arg(key)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", dir)
.output()
.unwrap()
.env("HOME", dir);
if let Some(image) = image {
c.env("APPIMAGE", image);
}
c.output().unwrap()
})
};
let mut written = vec!["4000".to_string()];
@@ -742,6 +781,39 @@ fn bridge_survives_update() {
assert!(in_books >= written.len(), "{in_books} < {}", written.len());
}
#[test]
fn a_replaced_appimage_restarts_the_books() {
// The programs run from one file (an image's mount) while `APPIMAGE`
// names another (the image). Replacing the image alone must restart
// the host onto it, with the session and every write intact.
let home = Home::new_image("appimage");
let image = home.image.clone().unwrap();
home.create("acme");
let release = Release::new();
let next = newer(&home, "appimage");
release.publish("9.0.0", &next, |_| {});
let mut agent = Agent::start(&home, "acme");
agent.add_account("4000").unwrap();
let host_before = wait_host(&home);
let out = home.run("update", Some(&release));
assert!(out.status.success(), "{}", text(&out));
assert_eq!(std::fs::read(&image).unwrap(), next);
// The mount's copy is untouched; only the image changed.
assert_ne!(std::fs::read(&home.exe).unwrap(), next);
assert!(
wait_for(60, || {
home.pids("host").len() == 1 && home.pids("host")[0] != host_before
}),
"the host never restarted onto the replaced image"
);
let host = wait_host(&home);
assert_eq!(
std::fs::read_link(format!("/proc/{host}/exe")).unwrap(),
image
);
assert!(agent.add_account("4001").is_ok(), "the session carries on");
}
#[test]
fn update_waits_for_a_running_call() {
// No call is cut off or made twice: each account written across the
@@ -763,3 +835,161 @@ fn update_waits_for_a_running_call() {
"a write lost, or made twice, across the update"
);
}
/// Name `release` as this home's local feed, as the owner would
/// (`<root>/updates/feed`: the address, then the key file).
fn name_feed(home: &Home, release: &Release) {
std::fs::create_dir_all(home.updates()).unwrap();
std::fs::write(
home.updates().join("feed"),
format!(
"file://{}\n{}\n",
release.dir.path().join("manifest.json").display(),
release.dir.path().join("release.pub").display()
),
)
.unwrap();
}
fn staged_version(home: &Home) -> Option<String> {
let meta = std::fs::read(home.updates().join("staged/meta.json")).ok()?;
let v: Value = serde_json::from_slice(&meta).ok()?;
v["version"].as_str().map(str::to_string)
}
#[test]
fn the_host_stages_a_daily_update_and_the_next_start_applies_it() {
let home = Home::new("daily");
home.create("acme");
let old = home.program();
let release = Release::new();
let next = newer(&home, "daily");
release.publish("9.0.0", &next, |_| {});
name_feed(&home, &release);
let mut agent = Agent::start(&home, "acme");
agent.add_account("4000").unwrap();
let host = wait_host(&home);
// The host looks, downloads, verifies and stages; nothing running
// changes: not the file, not the host.
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
assert_eq!(home.program(), old, "the program was swapped while open");
assert!(!home.updates().join("previous").exists());
std::thread::sleep(Duration::from_millis(1200));
assert_eq!(home.pids("host"), vec![host], "the host restarted");
assert!(agent.add_account("4001").is_ok());
// The books close; the next opener applies it before the host starts.
drop(agent);
let out = home.cmd("close").output().unwrap();
assert!(out.status.success(), "{}", text(&out));
assert!(wait_for(20, || home.pids("host").is_empty()));
let mut agent = Agent::start(&home, "acme");
assert_eq!(home.program(), next, "the staged update wasn't applied");
assert!(!home.updates().join("staged").exists());
assert!(
home.updates()
.join("previous")
.join("wanderbooks")
.is_file()
);
assert_eq!(
std::fs::read(home.updates().join("previous/wanderbooks")).unwrap(),
old
);
let host = wait_host(&home);
assert!(
!std::fs::read_link(format!("/proc/{host}/exe"))
.unwrap()
.to_string_lossy()
.contains("(deleted)")
);
let chart = agent.call("books.chart", json!({})).unwrap();
let codes: Vec<&str> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["code"].as_str())
.collect();
assert!(codes.contains(&"4000") && codes.contains(&"4001"));
}
#[test]
fn a_daily_update_that_is_not_ours_is_not_staged() {
let home = Home::new("daily-bad");
home.create("acme");
let release = Release::new();
let next = newer(&home, "daily-bad");
let other = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
// Signed by another key; and, separately, a wrong checksum.
let sig = Release::b64(&Release::sign_with(&other, &next));
release.publish("9.0.0", &next, |e| e["signature"] = json!(sig));
name_feed(&home, &release);
let _agent = Agent::start(&home, "acme");
assert!(
wait_for(30, || home.updates().join("checked").exists()),
"no look"
);
std::thread::sleep(Duration::from_millis(500));
assert_eq!(staged_version(&home), None);
assert!(!home.updates().join("staged").exists());
release.publish("9.0.1", &next, |e| e["sha256"] = json!("00".repeat(32)));
std::fs::remove_file(home.updates().join("checked")).unwrap();
assert!(
wait_for(30, || home.updates().join("checked").exists()),
"no second look"
);
std::thread::sleep(Duration::from_millis(500));
assert_eq!(staged_version(&home), None);
}
#[test]
fn update_installs_the_copy_the_host_staged_without_downloading_again() {
let home = Home::new("daily-use");
home.create("acme");
let release = Release::new();
let next = newer(&home, "daily-use");
release.publish("9.0.0", &next, |_| {});
name_feed(&home, &release);
let _agent = Agent::start(&home, "acme");
assert!(wait_for(30, || staged_version(&home).is_some()));
// The program file is gone from the feed: only the staged copy is left.
std::fs::remove_file(release.dir.path().join("wanderbooks-9.0.0")).unwrap();
let out = home.run("update", None);
assert!(out.status.success(), "{}", text(&out));
assert_eq!(home.program(), next);
assert!(!home.updates().join("staged").exists());
}
#[test]
fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() {
let home = Home::new("daily-tamper");
home.create("acme");
let old = home.program();
let release = Release::new();
let next = newer(&home, "daily-tamper");
release.publish("9.0.0", &next, |_| {});
name_feed(&home, &release);
let agent = Agent::start(&home, "acme");
assert!(wait_for(30, || staged_version(&home).is_some()));
drop(agent);
let out = home.cmd("close").output().unwrap();
assert!(out.status.success(), "{}", text(&out));
assert!(wait_for(20, || home.pids("host").is_empty()));
// The staged file changes, its recorded checksum with it: the
// signature no longer holds.
let staged = home.updates().join("staged");
let mut bytes = std::fs::read(staged.join("wanderbooks")).unwrap();
bytes.extend_from_slice(b"tamper");
std::fs::write(staged.join("wanderbooks"), &bytes).unwrap();
let mut meta: Value =
serde_json::from_slice(&std::fs::read(staged.join("meta.json")).unwrap()).unwrap();
meta["sha256"] = json!(sha256(&bytes));
std::fs::write(staged.join("meta.json"), meta.to_string()).unwrap();
let _agent = Agent::start(&home, "acme");
assert_eq!(home.program(), old, "a tampered update was applied");
assert!(!staged.exists(), "and kept");
}
+4 -2
View File
@@ -26,8 +26,10 @@ proptest = "1"
# store (a dev-dependency only: the store depends on this crate), each in
# a directory of its own.
toml = "1"
bookspec-checker = { path = "../../bookspec/checker" }
bookspec-runner = { path = "../../bookspec/runner" }
# The specification lives in its own repository (P13 step 6, D-34, D-65),
# pinned to a commit.
bookspec-checker = { git = "https://git.swaits.com/swaits/bookspec", rev = "74f2853d5f0db0066147c17fed8033cfe3d83a24" }
bookspec-runner = { git = "https://git.swaits.com/swaits/bookspec", rev = "74f2853d5f0db0066147c17fed8033cfe3d83a24" }
ledger-package = { path = "../ledger-package" }
ledger-import = { path = "../ledger-import" }
ledger-store = { path = "../ledger-store" }
+871
View File
@@ -0,0 +1,871 @@
//! The audit's statements checks (plan P8; rules.md B-45): the books,
//! read as an auditor would, through a date, as a list of findings.
//!
//! ```text
//! GOAL: one deterministic list of what is wrong or unfinished in the
//! books through a date, from the ledger alone, so an audit run is
//! reproducible and its workpaper hashes the same twice.
//! SOURCE: IRS Pub. 583 (records that support entries); Rev. Proc. 98-25
//! §5.01 (the relationship between the records and the books must
//! be demonstrable); docs/spec/rules.md B-6 to B-14 (the
//! statements), B-38 (control accounts), B-43 (evidence);
//! DECISION D-9 (the audit and its bar), D-53 (a run, not a job).
//! INVARIANTS:
//! 1. Pure: reads the ledger and the facts [`AuditInput`] hands in,
//! changes nothing, reads no clock: the same books and input give
//! the same findings in the same order.
//! 2. One function per check, one finding code per check ([`CODES`]);
//! a check that finds nothing says nothing.
//! 3. Only entries dated on or before `through` count, drafts and
//! posted alike.
//! 4. A check that cannot be made is itself a finding, never a silent
//! pass (`cash_flow_unfooted` for a statement the engine refuses).
//! SCENARIOS:
//! 1. IF the books are whole: SHOULD give no Failure or Warning.
//! 2. IF a draft is dated on or before `through`: SHOULD give exactly
//! `draft_open`.
//! ```
use std::collections::BTreeSet;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::Serialize;
use crate::evidence::{Policy, Requirement};
use crate::settings::Settings;
use crate::{AccountType, DocStatus, EntryId, EntryStatus, Ledger, PeriodStatus, Side};
/// How much a finding matters.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
/// The books are wrong or cannot be shown right: an audit with one is
/// not clean.
Failure,
/// Unfinished work or a policy not met.
Warning,
/// Listed so the workpaper is whole; not a defect.
Info,
}
impl Severity {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
Severity::Failure => "failure",
Severity::Warning => "warning",
Severity::Info => "info",
}
}
}
/// What a finding is about.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
pub enum Subject {
/// The books as a whole.
Books,
/// An account.
Account(u64),
/// An entry.
Entry(u64),
/// An invoice.
Invoice(u64),
/// A bill.
Bill(u64),
/// An accounting period.
Period(u64),
/// An amortization schedule.
Schedule(u64),
/// A filed attachment.
Attachment(u64),
/// A vendor, by name.
Vendor(String),
/// A stored file or the access log, by name.
Named(String),
}
impl Subject {
/// In words: `entry 7`, `books`.
pub fn describe(&self) -> String {
match self {
Subject::Books => "books".into(),
Subject::Account(i) => format!("account {i}"),
Subject::Entry(i) => format!("entry {i}"),
Subject::Invoice(i) => format!("invoice {i}"),
Subject::Bill(i) => format!("bill {i}"),
Subject::Period(i) => format!("period {i}"),
Subject::Schedule(i) => format!("schedule {i}"),
Subject::Attachment(i) => format!("attachment {i}"),
Subject::Vendor(n) => format!("vendor {n}"),
Subject::Named(n) => n.clone(),
}
}
}
/// One thing an audit found.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Finding {
/// Its stable code ([`CODES`]).
pub code: &'static str,
/// How much it matters.
pub severity: Severity,
/// What it is about.
pub subject: Subject,
/// In words, with the exact figures.
pub message: String,
}
impl Finding {
/// A finding of the severity its code's row says.
pub fn new(code: &'static str, subject: Subject, message: impl Into<String>) -> Finding {
let severity = CODES
.iter()
.find(|r| r.code == code)
.map_or(Severity::Failure, |r| r.severity);
Finding {
code,
severity,
subject,
message: message.into(),
}
}
}
/// One row of the code table: a check's finding code, how much it matters
/// and the predicate it states.
#[derive(Clone, Copy, Debug)]
pub struct CodeRow {
/// The finding code.
pub code: &'static str,
/// Its severity.
pub severity: Severity,
/// The predicate: what is true when the code is reported.
pub predicate: &'static str,
}
/// Every statements check, by code (the integrity checks of the store have
/// their own table, `ledger_store::audit::CODES`). `audit_detects_each_
/// seeded_defect` seeds each row.
pub const CODES: &[CodeRow] = &[
CodeRow {
code: "trial_balance_unbalanced",
severity: Severity::Failure,
predicate: "total debits differ from total credits in the trial balance through the date",
},
CodeRow {
code: "equation_broken",
severity: Severity::Failure,
predicate: "assets differ from liabilities plus equity (current earnings included)",
},
CodeRow {
code: "pnl_not_articulated",
severity: Severity::Failure,
predicate: "net income since the first entry differs from current earnings plus what closes moved to equity",
},
CodeRow {
code: "cash_flow_unfooted",
severity: Severity::Failure,
predicate: "the statement of cash flows cannot be made, or its sections do not sum to the cash accounts' change",
},
CodeRow {
code: "control_difference",
severity: Severity::Failure,
predicate: "a control account's balance differs from its documents plus its unattached lines",
},
CodeRow {
code: "unattached_control_line",
severity: Severity::Warning,
predicate: "a line on a control account carries no document",
},
CodeRow {
code: "document_overpaid",
severity: Severity::Warning,
predicate: "a document has more paid against it than it is for",
},
CodeRow {
code: "close_split_drifts",
severity: Severity::Failure,
predicate: "a member's running share of the closes is more than a minor unit from its exact share",
},
CodeRow {
code: "schedule_behind",
severity: Severity::Warning,
predicate: "an amortization schedule has a month due through the date that is not posted",
},
CodeRow {
code: "depreciation_behind",
severity: Severity::Warning,
predicate: "the asset schedules say more depreciation is due than the books carry",
},
CodeRow {
code: "register_differs",
severity: Severity::Failure,
predicate: "the fixed asset register's cost differs from the cost accounts' balances",
},
CodeRow {
code: "evidence_missing",
severity: Severity::Failure,
predicate: "a posted entry the receipt policy covers has no receipt",
},
CodeRow {
code: "legacy_reversal_exempt",
severity: Severity::Info,
predicate: "a reversal the policy would cover has no receipt of its own; its original's evidence stands for it (D-47)",
},
CodeRow {
code: "draft_open",
severity: Severity::Warning,
predicate: "a draft is dated on or before the date",
},
CodeRow {
code: "tax_category_missing",
severity: Severity::Warning,
predicate: "a revenue or expense account has no tax category",
},
CodeRow {
code: "vendor_1099_undecided",
severity: Severity::Warning,
predicate: "a vendor paid in a way that could need a 1099 has no decision",
},
CodeRow {
code: "settings_problem",
severity: Severity::Warning,
predicate: "the settings name an account or role the chart cannot honor",
},
];
/// What the ledger cannot know by itself and the caller reads from the
/// books around it.
#[derive(Clone, Copy, Debug)]
pub struct AuditInput<'a> {
/// The books' settings (the receipt policy).
pub settings: &'a Settings,
/// The entries that have at least one receipt filed against them, not
/// withdrawn.
pub receipts: &'a BTreeSet<EntryId>,
/// The statement of cash flows tried from the first entry through the
/// date with the books' role settings: its footing, or why it could
/// not be made; `None` when the settings name no cash accounts or
/// there is nothing to state.
pub cash_flow: Option<&'a Result<crate::cashflow::CashFlowFooting, String>>,
/// What the settings check already says is wrong, in words.
pub settings_problems: &'a [String],
/// Vendors paid in a way that could need a 1099 with no decision, and
/// what they were paid.
pub undecided_1099: &'a [(String, Decimal)],
}
/// All the statements checks through `through`, in a fixed order:
/// failures first, then by code and subject.
pub fn statements(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec<Finding> {
let mut out = Vec::new();
out.extend(trial_balance(ledger, through));
out.extend(equation(ledger, through));
out.extend(articulation(ledger, through));
out.extend(check_cash_flow(input.cash_flow));
out.extend(control(ledger, through));
out.extend(closes(ledger, through));
out.extend(schedules(ledger, through));
out.extend(assets(ledger, through));
out.extend(evidence(ledger, input, through));
out.extend(drafts(ledger, through));
out.extend(tax_categories(ledger));
out.extend(vendors_1099(input.undecided_1099));
out.extend(settings_problems(input.settings_problems));
sort(&mut out);
out
}
/// Findings in their reporting order.
pub fn sort(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
(a.severity, a.code, &a.subject, &a.message)
.cmp(&(b.severity, b.code, &b.subject, &b.message))
});
}
fn trial_balance(ledger: &Ledger, through: NaiveDate) -> Option<Finding> {
let tb = crate::reports::trial_balance_as_of(ledger, through);
check_trial_balance(&tb)
}
/// The trial balance check on a built trial balance.
pub fn check_trial_balance(tb: &crate::reports::TrialBalance) -> Option<Finding> {
(!tb.balanced()).then(|| {
Finding::new(
"trial_balance_unbalanced",
Subject::Books,
format!(
"debits {} differ from credits {}",
tb.total_debits, tb.total_credits
),
)
})
}
fn equation(ledger: &Ledger, through: NaiveDate) -> Option<Finding> {
match crate::reports::balance_sheet_as_of(ledger, through) {
Ok(bs) => check_equation(&bs),
Err(e) => Some(Finding::new(
"equation_broken",
Subject::Books,
format!("the balance sheet cannot be made: {e}"),
)),
}
}
/// The accounting equation on a built balance sheet.
pub fn check_equation(bs: &crate::reports::BalanceSheetStatement) -> Option<Finding> {
(!bs.balanced()).then(|| {
Finding::new(
"equation_broken",
Subject::Books,
format!(
"assets {} differ from liabilities {} plus equity {}",
bs.total_assets, bs.total_liabilities, bs.total_equity
),
)
})
}
/// Net income moved to equity by the closes dated through `through`:
/// revenue closed less expenses closed, read off the closing entries'
/// lines.
fn closed_net(ledger: &Ledger, through: NaiveDate) -> Decimal {
let mut net = Decimal::ZERO;
for id in ledger.closing_entry_ids() {
let Some(e) = ledger.get_entry(id) else {
continue;
};
if e.status() != EntryStatus::Posted || e.date() > through {
continue;
}
for l in e.lines() {
let Some(a) = ledger.chart().get(l.account) else {
continue;
};
let signed = match l.side {
Side::Debit => l.amount.amount,
Side::Credit => -l.amount.amount,
};
if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) {
net += signed;
}
}
}
net
}
fn articulation(ledger: &Ledger, through: NaiveDate) -> Option<Finding> {
let first = ledger
.posted_entries()
.filter(|e| e.date() <= through)
.map(|e| e.date())
.min()?;
let pnl = crate::reports::profit_and_loss_for_period(ledger, first, through, None).ok()?;
let bs = crate::reports::balance_sheet_as_of(ledger, through).ok()?;
check_articulation(
pnl.net_income(),
bs.current_earnings,
closed_net(ledger, through),
)
}
/// The P&L ties to equity: net income since the first entry is current
/// earnings plus what the closes moved into equity.
pub fn check_articulation(
net_income: Decimal,
current_earnings: Decimal,
closed: Decimal,
) -> Option<Finding> {
(net_income != current_earnings + closed).then(|| {
Finding::new(
"pnl_not_articulated",
Subject::Books,
format!(
"net income {net_income} differs from current earnings {current_earnings} plus closed {closed}"
),
)
})
}
/// The statement of cash flows, tried by the caller from the books' role
/// settings: its footing, or why it could not be made.
pub fn check_cash_flow(
made: Option<&Result<crate::cashflow::CashFlowFooting, String>>,
) -> Option<Finding> {
match made? {
Ok(f) if f.difference.is_zero() => None,
Ok(f) => Some(Finding::new(
"cash_flow_unfooted",
Subject::Books,
format!(
"the sections sum to {} but the cash accounts changed by {}",
f.sections_sum, f.net_change
),
)),
Err(e) => Some(Finding::new(
"cash_flow_unfooted",
Subject::Books,
format!("the statement of cash flows cannot be made: {e}"),
)),
}
}
fn control(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
match ledger.control_check(through) {
Ok(checks) => check_control(&checks),
Err(e) => vec![Finding::new(
"control_difference",
Subject::Books,
format!("the control check cannot be made: {e}"),
)],
}
}
/// The control accounts' tie-out, as the engine read it.
pub fn check_control(checks: &[crate::arap::ControlCheck]) -> Vec<Finding> {
let mut out = Vec::new();
for c in checks {
if !c.difference.is_zero() {
out.push(Finding::new(
"control_difference",
Subject::Account(c.account),
format!(
"the {} account's balance {} differs from its documents {} by {}",
c.role, c.balance, c.documents_total, c.difference
),
));
}
for u in &c.unattached {
out.push(Finding::new(
"unattached_control_line",
Subject::Entry(u.entry),
format!(
"line {} of entry {} ({}), {} on the {} account, carries no document",
u.line, u.entry, u.date, u.amount, c.role
),
));
}
for a in c
.attention
.iter()
.filter(|a| a.status == DocStatus::Overpaid)
{
let subject = match a.doc {
crate::DocRef::Invoice(i) => Subject::Invoice(i),
crate::DocRef::Bill(b) => Subject::Bill(b),
};
out.push(Finding::new(
"document_overpaid",
subject,
format!("{} is overpaid: its total is {}", a.doc, a.total),
));
}
}
out
}
fn closes(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
let unit = Decimal::new(1, ledger.base_currency().minor_units().unwrap_or(2));
let mut out = Vec::new();
for p in ledger.list_periods() {
if p.status != PeriodStatus::Closed || p.end > through {
continue;
}
if let Some((_, split)) = ledger.member_close_split(p.id) {
out.extend(check_split(p.id, &p.name, &split, unit));
}
}
out
}
/// One close's members: each one's running total of the closes must be
/// within a minor unit of its exact share (the cumulative rule,
/// `kernel::apportion_cumulative`).
pub fn check_split(
period: u64,
name: &str,
split: &[crate::periods::MemberSplit],
unit: Decimal,
) -> Vec<Finding> {
split
.iter()
.filter(|m| (m.running - m.exact).abs() > unit)
.map(|m| {
Finding::new(
"close_split_drifts",
Subject::Period(period),
format!(
"after the close of {name}, account {} holds {} of the closes where its exact share is {}",
m.capital, m.running, m.exact
),
)
})
.collect()
}
fn schedules(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
let mut out = Vec::new();
for s in ledger.schedules() {
let months = match s.due(through) {
Ok(m) => m,
Err(e) => {
out.push(Finding::new(
"schedule_behind",
Subject::Schedule(s.id),
format!("\"{}\" cannot be planned: {e}", s.description),
));
continue;
}
};
if let Some((first, _)) = months.first() {
let total: Decimal = months.iter().map(|(_, a)| *a).sum();
out.push(Finding::new(
"schedule_behind",
Subject::Schedule(s.id),
format!(
"\"{}\" has {} month(s) due and not posted, from {first}, {total} in all",
s.description,
months.len()
),
));
}
}
out
}
fn assets(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
match crate::reports::fixed_asset_register(ledger, through) {
Ok(reg) => check_register(&reg),
Err(e) => vec![Finding::new(
"register_differs",
Subject::Books,
format!("the fixed asset register cannot be made: {e}"),
)],
}
}
/// The register against the books' cost and accumulated depreciation.
pub fn check_register(reg: &crate::reports::AssetRegister) -> Vec<Finding> {
let mut out = Vec::new();
if reg.unposted_depreciation() > Decimal::ZERO {
out.push(Finding::new(
"depreciation_behind",
Subject::Books,
format!(
"the schedules say {} of depreciation is due and the books carry {}",
reg.total_accumulated_scheduled, reg.ledger_accumulated
),
));
}
if !reg.unregistered_cost().is_zero() {
out.push(Finding::new(
"register_differs",
Subject::Books,
format!(
"the register holds {} of cost and the cost accounts {}",
reg.total_cost, reg.ledger_cost
),
));
}
out
}
fn evidence(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec<Finding> {
let policy = Policy::from_settings(input.settings);
let mut out = Vec::new();
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
if input.receipts.contains(&e.id()) {
continue;
}
match crate::evidence::requirement(ledger, e, input.settings) {
Requirement::Required { reason } => out.push(Finding::new(
"evidence_missing",
Subject::Entry(e.id()),
format!("entry {} ({}) has no receipt: {reason}", e.id(), e.date()),
)),
Requirement::Exempt { .. } if e.reversal_of().is_some() => {
if policy.receipt_required(ledger, e) {
out.push(Finding::new(
"legacy_reversal_exempt",
Subject::Entry(e.id()),
format!(
"entry {} reverses entry {}: its evidence is the original's",
e.id(),
e.reversal_of().unwrap_or_default()
),
));
}
}
Requirement::Exempt { .. } => {}
}
}
out
}
fn drafts(ledger: &Ledger, through: NaiveDate) -> Vec<Finding> {
ledger
.drafts()
.into_iter()
.filter(|e| e.date() <= through)
.map(|e| {
Finding::new(
"draft_open",
Subject::Entry(e.id()),
format!(
"draft {} ({}) \"{}\" is not posted",
e.id(),
e.date(),
e.description()
),
)
})
.collect()
}
fn tax_categories(ledger: &Ledger) -> Vec<Finding> {
crate::tax::uncategorized_accounts(ledger)
.into_iter()
.map(|a| {
Finding::new(
"tax_category_missing",
Subject::Account(a.id),
format!("account {} {} has no tax category", a.code, a.name),
)
})
.collect()
}
fn vendors_1099(undecided: &[(String, Decimal)]) -> Vec<Finding> {
undecided
.iter()
.map(|(name, paid)| {
Finding::new(
"vendor_1099_undecided",
Subject::Vendor(name.clone()),
format!(
"{name} was paid {paid} in a way that could need a 1099 and has no decision"
),
)
})
.collect()
}
fn settings_problems(problems: &[String]) -> Vec<Finding> {
problems
.iter()
.map(|p| Finding::new("settings_problem", Subject::Books, p.clone()))
.collect()
}
// ---------------------------------------------------------------------------
// Scope, method and the attestation bar (rules.md B-45; D-9)
// ---------------------------------------------------------------------------
/// What an audit covers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
/// A month's books, for its month-end attestation.
Month,
/// A fiscal year.
Annual,
/// The year, for the CPA handoff.
CpaHandoff,
/// Everything since the first entry.
Full,
}
impl Scope {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
Scope::Month => "month",
Scope::Annual => "annual",
Scope::CpaHandoff => "cpa_handoff",
Scope::Full => "full",
}
}
/// The scope a word names.
pub fn parse(s: &str) -> Option<Scope> {
match s.trim().to_ascii_lowercase().as_str() {
"month" => Some(Scope::Month),
"annual" => Some(Scope::Annual),
"cpa_handoff" => Some(Scope::CpaHandoff),
"full" => Some(Scope::Full),
_ => None,
}
}
/// Whether a period attested on this audit is held to the whole bar
/// (adversarial, several verifiers) and not the effort alone (D-9).
pub fn full_bar(self) -> bool {
!matches!(self, Scope::Month)
}
/// Whether an audit of this scope is one the CPA package may rest on.
pub fn serves_handoff(self) -> bool {
matches!(self, Scope::CpaHandoff | Scope::Full)
}
}
/// How hard the checker thought, as the client reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Effort {
/// low
Low,
/// medium
Medium,
/// high
High,
/// xhigh
Xhigh,
/// max
Max,
}
impl Effort {
/// Its wire name.
pub fn key(self) -> &'static str {
match self {
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::Xhigh => "xhigh",
Effort::Max => "max",
}
}
/// The effort a word names.
pub fn parse(s: &str) -> Option<Effort> {
match s.trim().to_ascii_lowercase().as_str() {
"low" => Some(Effort::Low),
"medium" => Some(Effort::Medium),
"high" => Some(Effort::High),
"xhigh" => Some(Effort::Xhigh),
"max" => Some(Effort::Max),
_ => None,
}
}
}
/// How an attestation was made, as the client says (self-reported: the
/// books record the claim, never prove it; rules.md B-45).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Method {
/// The model that checked, as it names itself.
pub model: Option<String>,
/// How hard it thought.
pub reasoning_effort: Option<Effort>,
/// Whether the check was adversarial: a verifier set to find what is
/// wrong, not to confirm.
pub adversarial: Option<bool>,
/// How many independent verifier agents checked.
pub verifier_agents: Option<u32>,
}
/// What counts as an attestation (`policy.attestation_bar`; D-9). The
/// default: reasoning effort high, two independent verifiers, adversarial.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct AttestationBar {
/// The least effort that counts, for every attestation.
pub reasoning_effort: Effort,
/// The fewest independent verifier agents a year-level attestation
/// needs.
pub verifier_agents: u32,
/// Whether a year-level attestation must be adversarial.
pub adversarial: bool,
}
impl Default for AttestationBar {
fn default() -> Self {
AttestationBar {
reasoning_effort: Effort::High,
verifier_agents: 2,
adversarial: true,
}
}
}
impl AttestationBar {
/// The bar a setting's text names: `key=value` words in any order,
/// separated by spaces or commas (`effort=high verifiers=2
/// adversarial=true`); a word left out keeps its default.
pub fn parse(s: &str) -> Result<AttestationBar, String> {
let mut bar = AttestationBar::default();
for word in s.split(|c: char| c.is_whitespace() || c == ',') {
if word.is_empty() {
continue;
}
let (k, v) = word.split_once('=').ok_or_else(|| {
format!("{word:?}: expected key=value (effort, verifiers, adversarial)")
})?;
match k {
"effort" | "reasoning_effort" => {
bar.reasoning_effort = Effort::parse(v).ok_or_else(|| {
format!("effort {v:?} is not low, medium, high, xhigh or max")
})?;
}
"verifiers" | "verifier_agents" => {
bar.verifier_agents = v
.parse::<u32>()
.ok()
.filter(|n| (1..=64).contains(n))
.ok_or_else(|| format!("verifiers {v:?} is not a count from 1 to 64"))?;
}
"adversarial" => {
bar.adversarial = match v {
"true" => true,
"false" => false,
_ => return Err(format!("adversarial {v:?} is not true or false")),
};
}
other => {
return Err(format!(
"{other:?} is not a part of the bar: effort, verifiers, adversarial"
));
}
}
}
Ok(bar)
}
/// The bar in its one canonical spelling.
pub fn describe(&self) -> String {
format!(
"effort={} verifiers={} adversarial={}",
self.reasoning_effort.key(),
self.verifier_agents,
self.adversarial
)
}
/// Whether an attestation made `method` counts. A claim that
/// disagrees with the books never does. Every attestation needs the
/// effort; one given on a year-level audit (`full`) needs the whole
/// bar. Evaluated when asked, against the bar as it stands (D-9: no
/// bar versioning).
pub fn counts(&self, method: &Method, full: bool, disagrees: bool) -> bool {
if disagrees {
return false;
}
if !method
.reasoning_effort
.is_some_and(|e| e >= self.reasoning_effort)
{
return false;
}
if !full {
return true;
}
(!self.adversarial || method.adversarial == Some(true))
&& method
.verifier_agents
.is_some_and(|n| n >= self.verifier_agents)
}
}
+79
View File
@@ -277,6 +277,30 @@ pub struct NewDecision {
pub projects: Vec<crate::ClassId>,
}
/// Where a decision comes from: who decided, when, and what it rests on.
/// A correction of these three is journaled on its own
/// (`decision.source_revised`), the decision itself unchanged.
#[derive(Clone, Debug, PartialEq)]
pub struct DecisionSource {
/// Who decided (names), at least one.
pub decided_by: Vec<String>,
/// When.
pub decided_on: NaiveDate,
/// The rule or document it rests on, if any.
pub citation: Option<String>,
}
impl NewDecision {
/// The decision's source.
pub fn source(&self) -> DecisionSource {
DecisionSource {
decided_by: self.decided_by.clone(),
decided_on: self.decided_on,
citation: self.citation.clone(),
}
}
}
/// A decision as the journal holds it.
#[derive(Clone, Debug, PartialEq)]
pub struct Decision {
@@ -645,6 +669,39 @@ impl Conventions {
Ok(())
}
/// Correct a convention's source as journaled (`convention.source_revised`):
/// who decided it, when and on what; its text, revision and line stay.
pub fn apply_convention_source(&mut self, id: &str, source: Source) -> Result<(), String> {
let c = self
.conventions
.get_mut(id)
.ok_or_else(|| format!("unknown convention {id:?}"))?;
c.source = source;
Ok(())
}
/// Correct a decision's source as journaled (`decision.source_revised`).
pub fn apply_decision_source(&mut self, id: u64, source: DecisionSource) -> Result<(), String> {
let d = self
.decisions
.get_mut(&id)
.ok_or_else(|| format!("unknown decision {id}"))?;
d.new.decided_by = source.decided_by;
d.new.decided_on = source.decided_on;
d.new.citation = source.citation;
Ok(())
}
/// Correct an open item's source as journaled (`open_item.source_revised`).
pub fn apply_open_item_source(&mut self, id: u64, source: Source) -> Result<(), String> {
let i = self
.open_items
.get_mut(&id)
.ok_or_else(|| format!("unknown open item {id}"))?;
i.new.source = source;
Ok(())
}
/// Record an approval as journaled.
pub fn apply_approval(&mut self, entry: EntryId, a: Approval) {
self.approvals.entry(entry).or_default().push(a);
@@ -800,6 +857,28 @@ pub fn check_decision(d: &NewDecision) -> Result<NewDecision, String> {
})
}
/// Check a decision's corrected source (the same rules as a new
/// decision's own).
pub fn check_decision_source(s: &DecisionSource) -> Result<DecisionSource, String> {
if s.decided_by.is_empty() {
return Err("decided_by names at least one person".into());
}
crate::calendar::check(s.decided_on).map_err(|e| e.to_string())?;
Ok(DecisionSource {
decided_by: s
.decided_by
.iter()
.map(|n| line_text("decided_by", n, MAX_TEXT))
.collect::<Result<_, _>>()?,
decided_on: s.decided_on,
citation: s
.citation
.as_deref()
.map(|c| long_text("citation", c, MAX_LONG_TEXT))
.transpose()?,
})
}
/// Check a new open item's text (INVARIANT 6).
pub fn check_open_item(i: &NewOpenItem) -> Result<NewOpenItem, String> {
i.source.check()?;
+129
View File
@@ -8753,6 +8753,135 @@ impl Ledger {
.map_err(LedgerError::InvalidConvention)
}
/// Check a correction of a convention's source before it is journaled
/// (P5; `convention.source_revised`): the convention is live, `c` says
/// exactly what it says now (title, text, structure; a change of any is
/// a revision, not a correction) and a source that differs from the
/// one it has. Returns the source as it will be journaled, with the
/// reason; changes nothing.
pub fn check_convention_source(
&self,
c: &crate::conventions::NewConvention,
reason: &str,
) -> Result<(crate::settings::Source, String), LedgerError> {
let bad = LedgerError::InvalidConvention;
let c = crate::conventions::check_new(c).map_err(bad)?;
let old = self.conventions.convention(&c.id).ok_or_else(|| {
bad(format!(
"unknown convention {:?}: books.conventions lists them",
c.id
))
})?;
if old.retired.is_some() {
return Err(bad(format!("convention {} is retired", c.id)));
}
if old.kind != c.kind {
return Err(bad(format!(
"convention {} is a {}; its kind can't change",
c.id,
old.kind.key()
)));
}
if old.title != c.title || old.body_md != c.body_md || old.structured != c.structured {
return Err(bad(format!(
"convention {} changes more than its source: revise it without a reason, or give the text it has now",
c.id
)));
}
if old.source == c.source {
return Err(bad(format!("convention {} already has that source", c.id)));
}
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?;
if reason.is_empty() {
return Err(bad("a source correction needs a reason".into()));
}
Ok((c.source, reason))
}
/// Apply a journaled `convention.source_revised`.
pub fn apply_convention_source(
&mut self,
id: &str,
source: crate::settings::Source,
) -> Result<(), LedgerError> {
self.conventions
.apply_convention_source(id, source)
.map_err(LedgerError::InvalidConvention)
}
/// Check a correction of a decision's source (`decision.source_revised`).
pub fn check_decision_source(
&self,
id: u64,
source: &crate::conventions::DecisionSource,
reason: &str,
) -> Result<(crate::conventions::DecisionSource, String), LedgerError> {
let bad = LedgerError::InvalidConvention;
let old = self.conventions.decision(id).ok_or_else(|| {
bad(format!(
"decision {id} doesn't exist: books.decisions lists them"
))
})?;
let source = crate::conventions::check_decision_source(source).map_err(bad)?;
if old.new.source() == source {
return Err(bad(format!("decision {id} already has that source")));
}
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?;
if reason.is_empty() {
return Err(bad("a source correction needs a reason".into()));
}
Ok((source, reason))
}
/// Apply a journaled `decision.source_revised`.
pub fn apply_decision_source(
&mut self,
id: u64,
source: crate::conventions::DecisionSource,
) -> Result<(), LedgerError> {
self.conventions
.apply_decision_source(id, source)
.map_err(LedgerError::InvalidConvention)
}
/// Check a correction of an open item's source (`open_item.source_revised`).
pub fn check_open_item_source(
&self,
id: u64,
source: &crate::settings::Source,
reason: &str,
) -> Result<(crate::settings::Source, String), LedgerError> {
let bad = LedgerError::InvalidConvention;
source.check().map_err(bad)?;
let old = self.conventions.open_item(id).ok_or_else(|| {
bad(format!(
"unknown open item {id}: books.open_items lists them"
))
})?;
if old.new.source == *source {
return Err(bad(format!("open item {id} already has that source")));
}
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
.map_err(bad)?;
if reason.is_empty() {
return Err(bad("a source correction needs a reason".into()));
}
Ok((source.clone(), reason))
}
/// Apply a journaled `open_item.source_revised`.
pub fn apply_open_item_source(
&mut self,
id: u64,
source: crate::settings::Source,
) -> Result<(), LedgerError> {
self.conventions
.apply_open_item_source(id, source)
.map_err(LedgerError::InvalidConvention)
}
/// Check a retirement before it is journaled: the convention is live,
/// the reason and source are sound.
pub fn check_retire_convention(
+1
View File
@@ -20,6 +20,7 @@ pub mod account;
pub mod analysis;
pub mod arap;
pub mod assets;
pub mod audit;
pub mod calendar;
pub mod cashflow;
pub mod classes;
+46
View File
@@ -146,6 +146,9 @@ pub enum Kind {
Terms,
/// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`.
PaymentMethods,
/// What counts as an attestation ([`crate::audit::AttestationBar`]),
/// in its one canonical spelling.
AttestationBar,
}
/// One setting the books know.
@@ -170,6 +173,7 @@ const PARTY_POLICY: &[(&str, &[&str])] =
const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])];
const TAX_CATEGORY_POLICY: &[(&str, &[&str])] =
&[("warn", &[]), ("refuse", &["required", "require"])];
const AUDIT_REQUIRED: &[(&str, &[&str])] = &[("refuse", &["required", "require"]), ("warn", &[])];
const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])];
/// A return's key as a setting keeps it.
@@ -401,6 +405,21 @@ pub static KEYS: &[Key] = &[
sensitivity: Sensitivity::Public,
doc: "off: approval_rule conventions are recorded but never block a post. enforce: an entry a rule covers posts only once its approver approves it (books.approve_entry). Unset: off.",
},
// P8: audits and attestations.
Key {
name: "policy.attestation_bar",
toml: ("books", "attestation_bar"),
kind: Kind::AttestationBar,
sensitivity: Sensitivity::Public,
doc: "What counts as an attestation (plan D-9): the least reasoning effort a checker reports (low, medium, high, xhigh, max), and for a year-level audit the fewest independent verifier agents and whether the check was adversarial, written like effort=high verifiers=2 adversarial=true (a word left out keeps its default). The client reports how it checked; the books record that claim and say whether it meets this bar when asked, so a change here changes what counts, earlier attestations too. Unset: effort=high verifiers=2 adversarial=true.",
},
Key {
name: "policy.cpa_package_requires_audit",
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 once the books hold an audit run, warn before.",
},
Key {
name: "backup.git_remote",
toml: ("backup", "git_remote"),
@@ -1103,6 +1122,11 @@ pub fn check(key: &Key, v: &Value) -> Result<Value, String> {
Ok(json!(terms.describe()))
}
Kind::PaymentMethods => payment_methods(name, want_list(name, v)?),
Kind::AttestationBar => {
let bar = crate::audit::AttestationBar::parse(want_str(name, v)?)
.map_err(|e| format!("{name}: {e}"))?;
Ok(json!(bar.describe()))
}
Kind::TimeZone => {
let s = want_str(name, v)?.trim();
let zone = jiff::tz::TimeZone::get(s).map_err(|_| {
@@ -1230,6 +1254,9 @@ pub fn shape(key: &Key, v: &Value) -> Result<(), String> {
.ok_or_else(|| format!("{name} {s:?} is not a decimal"))
}
Kind::Count => count_of(name, v).map(|_| ()),
Kind::AttestationBar => crate::audit::AttestationBar::parse(want_str(name, v)?)
.map(|_| ())
.map_err(|e| format!("{name}: {e}")),
Kind::PaymentMethods => {
for (i, m) in want_list(name, v)?.iter().enumerate() {
let n = format!("{name} method {}", i + 1);
@@ -1605,6 +1632,25 @@ impl Settings {
}
}
/// `policy.attestation_bar` (unset: the default bar, D-9).
pub fn attestation_bar(&self) -> crate::audit::AttestationBar {
self.str("policy.attestation_bar")
.and_then(|s| crate::audit::AttestationBar::parse(s).ok())
.unwrap_or_default()
}
/// `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.
pub fn backup_git_remote(&self) -> Option<&str> {
self.str("backup.git_remote")
+439
View File
@@ -0,0 +1,439 @@
//! The audit's statements checks (plan P8; rules.md B-45): one test per
//! finding code, each seeding exactly the defect the code names.
use std::collections::BTreeSet;
use chrono::NaiveDate;
use ledger_core::arap::{ControlCheck, DocAttention, UnattachedLine};
use ledger_core::audit::{
self, AttestationBar, AuditInput, CODES, Effort, Finding, Method, Severity,
};
use ledger_core::cashflow::CashFlowFooting;
use ledger_core::periods::MemberSplit;
use ledger_core::reports::{AssetRegister, TrialBalance};
use ledger_core::settings::Settings;
use ledger_core::{
AccountType, Currency, DocRef, DocStatus, EntryId, Ledger, Money, NewAsset, NewSchedule,
ScheduleConvention, ScheduleKind, Side,
};
use rust_decimal::Decimal;
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
struct Books {
l: Ledger,
cash: u64,
capital: u64,
expense: u64,
revenue: u64,
}
/// A bank, capital, an expense and a revenue account, all categorized.
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap();
let cash = a("1000", "Bank", AccountType::Asset);
let capital = a("3000", "Capital", AccountType::Equity);
let revenue = a("4000", "Sales", AccountType::Revenue);
let expense = a("6000", "Supplies", AccountType::Expense);
l.set_account_tax(revenue, Some("gross_receipts"), None)
.unwrap();
l.set_account_tax(expense, Some("supplies"), None).unwrap();
Books {
l,
cash,
capital,
expense,
revenue,
}
}
fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) -> EntryId {
let e = l.create_entry(date, "x");
l.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
l.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
l.post_entry(e).unwrap();
e
}
fn run(l: &Ledger, receipts: &BTreeSet<EntryId>, through: NaiveDate) -> Vec<Finding> {
let settings = Settings::default();
audit::statements(
l,
&AuditInput {
settings: &settings,
receipts,
cash_flow: None,
settings_problems: &[],
undecided_1099: &[],
},
through,
)
}
/// Every posted entry has its receipt.
fn receipted(l: &Ledger) -> BTreeSet<EntryId> {
l.posted_entries().map(|e| e.id()).collect()
}
fn codes(f: &[Finding]) -> Vec<&'static str> {
f.iter().map(|f| f.code).collect()
}
fn control(difference: &str) -> ControlCheck {
ControlCheck {
role: "receivables".into(),
account: 7,
balance: dec("100.00"),
documents_total: dec("100.00"),
unattached: vec![],
difference: dec(difference),
attention: vec![],
}
}
/// Every code's seed: the defect, run, and the codes it yields.
fn seeds() -> Vec<(&'static str, Vec<Finding>)> {
let mut out = Vec::new();
let none = BTreeSet::new();
out.push((
"trial_balance_unbalanced",
audit::check_trial_balance(&TrialBalance {
lines: vec![],
total_debits: dec("10.00"),
total_credits: dec("9.99"),
})
.into_iter()
.collect(),
));
let b = books();
let mut bs = ledger_core::reports::balance_sheet_as_of(&b.l, d(12, 31)).unwrap();
bs.total_assets = dec("1.00");
out.push((
"equation_broken",
audit::check_equation(&bs).into_iter().collect(),
));
out.push((
"pnl_not_articulated",
audit::check_articulation(dec("100.00"), dec("40.00"), dec("50.00"))
.into_iter()
.collect(),
));
// A statement the engine refuses (a liability named as cash) and one
// that does not foot are both unfooted.
let settings = Settings::default();
let refused: Result<CashFlowFooting, String> = Err("account 9 is not an asset".into());
let off: Result<CashFlowFooting, String> = Ok(CashFlowFooting {
sections_sum: dec("10.00"),
net_change: dec("9.00"),
difference: dec("1.00"),
});
let b = books();
for made in [&refused, &off] {
let f = audit::statements(
&b.l,
&AuditInput {
settings: &settings,
receipts: &none,
cash_flow: Some(made),
settings_problems: &[],
undecided_1099: &[],
},
d(12, 31),
);
assert_eq!(codes(&f), vec!["cash_flow_unfooted"]);
}
out.push((
"cash_flow_unfooted",
audit::check_cash_flow(Some(&off)).into_iter().collect(),
));
out.push((
"control_difference",
audit::check_control(&[control("0.01")]),
));
let mut c = control("0.00");
c.unattached = vec![UnattachedLine {
entry: 3,
line: 0,
date: d(2, 1),
amount: dec("5.00"),
}];
out.push(("unattached_control_line", audit::check_control(&[c])));
let mut c = control("0.00");
c.attention = vec![DocAttention {
doc: DocRef::Invoice(1),
status: DocStatus::Overpaid,
total: dec("10.00"),
outstanding: dec("-5.00"),
}];
out.push(("document_overpaid", audit::check_control(&[c])));
out.push((
"close_split_drifts",
audit::check_split(
2,
"2026-10",
&[MemberSplit {
capital: 9,
share: dec("0.5"),
part: dec("1.00"),
running: dec("1.00"),
exact: dec("1.05"),
}],
dec("0.01"),
),
));
// A prepaid plan with a month due and nothing posted.
let mut b = books();
let prepaid =
b.l.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
b.l.register_schedule(NewSchedule {
description: "Insurance".into(),
kind: ScheduleKind::PrepaidExpense,
total: usd("1200.00"),
start: d(1, 1),
months: 12,
balance_account: prepaid,
pl_account: b.expense,
convention: ScheduleConvention::MonthlyEven,
})
.unwrap();
out.push(("schedule_behind", run(&b.l, &receipted(&b.l), d(3, 31))));
// A registered asset, no depreciation run.
let mut b = books();
let (cost, accum, dep) = asset_accounts(&mut b);
post(&mut b.l, d(1, 2), cost, b.capital, "6000.00");
b.l.register_asset(NewAsset {
description: "Van".into(),
in_service_date: d(1, 15),
cost: usd("6000.00"),
salvage_value: usd("0"),
useful_life_months: 60,
cost_account: cost,
accum_depr_account: accum,
expense_account: dep,
})
.unwrap();
out.push(("depreciation_behind", run(&b.l, &receipted(&b.l), d(6, 30))));
// Cost posted to the asset's account that the register doesn't hold.
post(&mut b.l, d(2, 1), cost, b.capital, "100.00");
let f = run(&b.l, &receipted(&b.l), d(6, 30));
out.push((
"register_differs",
f.into_iter()
.filter(|f| f.code == "register_differs")
.collect(),
));
// A 40.00 hand entry (threshold 25.00) with no receipt, then reversed.
let mut b = books();
let e = post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00");
let f = run(&b.l, &none, d(12, 31));
out.push(("evidence_missing", f));
let r = b.l.reverse_entry(e, d(2, 2), "undo").unwrap();
let mut with_original = BTreeSet::new();
with_original.insert(e);
let f = run(&b.l, &with_original, d(12, 31));
assert!(f.iter().any(|f| f.subject == audit::Subject::Entry(r)));
out.push(("legacy_reversal_exempt", f));
let mut b = books();
let e = b.l.create_entry(d(3, 3), "draft");
b.l.add_line(e, b.expense, Side::Debit, usd("1.00"))
.unwrap();
b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap();
out.push(("draft_open", run(&b.l, &none, d(12, 31))));
let mut b = books();
b.l.add_account("4100", "Other sales", AccountType::Revenue, None, false)
.unwrap();
out.push(("tax_category_missing", run(&b.l, &none, d(12, 31))));
let b = books();
let settings = Settings::default();
out.push((
"vendor_1099_undecided",
audit::statements(
&b.l,
&AuditInput {
settings: &settings,
receipts: &none,
cash_flow: None,
settings_problems: &[],
undecided_1099: &[("Tobin Harlow Supply".into(), dec("900.00"))],
},
d(12, 31),
),
));
out.push((
"settings_problem",
audit::statements(
&b.l,
&AuditInput {
settings: &settings,
receipts: &none,
cash_flow: None,
settings_problems: &["books.close_into names 9999".into()],
undecided_1099: &[],
},
d(12, 31),
),
));
let _ = (b.revenue, AccountType::Asset, Side::Debit);
out
}
fn asset_accounts(b: &mut Books) -> (u64, u64, u64) {
let cost =
b.l.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum =
b.l.add_account(
"1590",
"Accumulated depreciation",
AccountType::Asset,
None,
false,
)
.unwrap();
let dep =
b.l.add_account("6500", "Depreciation", AccountType::Expense, None, false)
.unwrap();
b.l.set_account_tax(dep, Some("supplies"), None).unwrap();
(cost, accum, dep)
}
#[test]
fn audit_detects_each_seeded_defect() {
let seeds = seeds();
for row in CODES {
let (_, found) = seeds
.iter()
.find(|(c, _)| *c == row.code)
.unwrap_or_else(|| panic!("no seeding fixture for {}", row.code));
let mine: Vec<&Finding> = found.iter().filter(|f| f.code == row.code).collect();
assert!(!mine.is_empty(), "{} was not found", row.code);
assert!(
mine.iter().all(|f| f.severity == row.severity),
"{}",
row.code
);
// Exactly that finding: nothing else but what the seed implies.
let others: Vec<_> = found.iter().filter(|f| f.code != row.code).collect();
let implied = match row.code {
// A 40.00 reversal chain, or a draft's unbalanced side, leaves
// the neighbouring checks quiet.
"legacy_reversal_exempt" => vec!["legacy_reversal_exempt", "evidence_missing"],
_ => vec![],
};
for o in others {
assert!(
implied.contains(&o.code),
"{}: also found {} ({})",
row.code,
o.code,
o.message
);
}
}
for (c, _) in &seeds {
assert!(CODES.iter().any(|r| r.code == *c), "{c} is not in CODES");
}
}
#[test]
fn a_whole_book_gives_no_failure_or_warning() {
let mut b = books();
post(&mut b.l, d(1, 5), b.cash, b.capital, "1000.00");
post(&mut b.l, d(1, 6), b.cash, b.revenue, "20.00");
let f = run(&b.l, &receipted(&b.l), d(12, 31));
assert!(
f.iter().all(|f| f.severity == Severity::Info),
"{:?}",
codes(&f)
);
}
#[test]
fn findings_come_in_one_order() {
let mut b = books();
post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00");
let e = b.l.create_entry(d(3, 3), "draft");
b.l.add_line(e, b.expense, Side::Debit, usd("1.00"))
.unwrap();
b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap();
let a = run(&b.l, &BTreeSet::new(), d(12, 31));
let c = run(&b.l, &BTreeSet::new(), d(12, 31));
assert_eq!(a, c);
assert_eq!(codes(&a), vec!["evidence_missing", "draft_open"]);
let _ = AssetRegister::unposted_depreciation;
}
fn method(effort: Option<&str>, adversarial: Option<bool>, agents: Option<u32>) -> Method {
Method {
model: None,
reasoning_effort: effort.map(|e| Effort::parse(e).unwrap()),
adversarial,
verifier_agents: agents,
}
}
/// D-9: effort at the bar counts for a receipt or a month; a year-level
/// attestation also needs the verifiers and the adversarial check; less
/// than the bar, or no claim at all, never counts.
#[test]
fn below_bar_not_counted() {
let bar = AttestationBar::default();
assert_eq!(bar.describe(), "effort=high verifiers=2 adversarial=true");
assert!(bar.counts(&method(Some("high"), None, None), false, false));
assert!(bar.counts(&method(Some("max"), None, None), false, false));
assert!(!bar.counts(&method(Some("medium"), None, None), false, false));
assert!(!bar.counts(&method(None, Some(true), Some(3)), false, false));
assert!(bar.counts(&method(Some("high"), Some(true), Some(2)), true, false));
assert!(!bar.counts(&method(Some("high"), Some(true), Some(1)), true, false));
assert!(!bar.counts(&method(Some("high"), Some(false), Some(2)), true, false));
assert!(!bar.counts(&method(Some("high"), None, Some(2)), true, false));
// A bar change changes what counts, earlier claims too (no
// versioning, D-9).
let lax = AttestationBar::parse("effort=low verifiers=1 adversarial=false").unwrap();
assert!(lax.counts(&method(Some("low"), Some(false), Some(1)), true, false));
assert!(AttestationBar::parse("effort=wild").is_err());
assert!(AttestationBar::parse("verifiers=0").is_err());
assert!(AttestationBar::parse("nonsense").is_err());
assert_eq!(
AttestationBar::parse("adversarial=false, effort=xhigh")
.unwrap()
.describe(),
"effort=xhigh verifiers=2 adversarial=false"
);
}
/// A reading that disagrees with the books never counts, however hard the
/// checker says it thought.
#[test]
fn amount_mismatch_never_counts() {
let bar = AttestationBar::default();
let top = method(Some("max"), Some(true), Some(9));
assert!(bar.counts(&top, true, false));
assert!(!bar.counts(&top, false, true));
assert!(!bar.counts(&top, true, true));
}
+88 -30
View File
@@ -33,7 +33,7 @@
use std::borrow::Borrow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::path::Path;
use chrono::{Datelike, NaiveDate};
use ledger_core::calendar::{PeriodDefaults, period_defaults};
@@ -214,8 +214,14 @@ fn store_refusal(e: &StoreError) -> String {
}
}
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples")
/// The text of the bookspec example file `file`, carried by the pinned
/// `bookspec-checker` crate.
fn example_text(file: &str) -> &'static str {
bookspec_checker::examples()
.into_iter()
.find(|(name, _)| *name == file)
.unwrap_or_else(|| panic!("no bookspec example {file}"))
.1
}
fn date(s: &str, what: &str) -> NaiveDate {
@@ -3840,16 +3846,9 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> {
/// this proves the trait path, which is what another engine supplies.
#[test]
fn the_runner_reports_every_claimed_rule_green() {
let mut files: Vec<PathBuf> = std::fs::read_dir(examples_dir())
.expect("bookspec/examples")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
files.sort();
let mut examples = Vec::new();
for f in &files {
let text = std::fs::read_to_string(f).unwrap();
examples.push(Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display())));
for (name, text) in bookspec_checker::examples() {
examples.push(Example::from_toml(text).unwrap_or_else(|e| panic!("{name}: {e}")));
}
assert!(examples.len() >= 56, "the seed has at least 56 examples");
let report = bookspec_runner::run(&examples, bookspec_checker::rules(), Books::blank);
@@ -3877,19 +3876,13 @@ fn the_runner_reports_every_claimed_rule_green() {
#[test]
fn every_bookspec_example_holds() {
let mut files: Vec<PathBuf> = std::fs::read_dir(examples_dir())
.expect("bookspec/examples")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
files.sort();
let files = bookspec_checker::examples();
let mut ids = BTreeSet::new();
let mut checked: BTreeMap<String, usize> = BTreeMap::new();
for f in &files {
let text = std::fs::read_to_string(f).unwrap();
let ex = Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display()));
let stem = f.file_stem().unwrap().to_string_lossy();
assert_eq!(ex.id, stem, "{}: the file is named for its id", f.display());
for (name, text) in &files {
let ex = Example::from_toml(text).unwrap_or_else(|e| panic!("{name}: {e}"));
let stem = name.strip_suffix(".toml").unwrap();
assert_eq!(ex.id, stem, "{name}: the file is named for its id");
assert!(ids.insert(ex.id.clone()), "{}: id used twice", ex.id);
assert!(
!ex.title.is_empty() && !ex.rule.is_empty() && !ex.tags.is_empty(),
@@ -4038,19 +4031,16 @@ fn reverses_zero_is_refused() {
/// ignored: an example can't silently run with other roles than it says.
#[test]
fn an_unknown_key_is_refused() {
let text = std::fs::read_to_string(
examples_dir().join("cash-flow.reversed-property-contribution.toml"),
)
.unwrap()
.replace("cash_accounts = ", "cash_account = ");
let text = example_text("cash-flow.reversed-property-contribution.toml")
.replace("cash_accounts = ", "cash_account = ");
assert!(toml::from_str::<Example>(&text).is_err());
}
/// The example `file`'s text, changed by `edit`, loaded and its
/// expectations checked as `every_bookspec_example_holds` checks them.
fn run_edited(file: &str, edit: impl Fn(String) -> String) {
let text = edit(std::fs::read_to_string(examples_dir().join(file)).unwrap());
let before = std::fs::read_to_string(examples_dir().join(file)).unwrap();
let before = example_text(file).to_string();
let text = edit(before.clone());
assert_ne!(text, before, "the edit changed nothing");
let ex: Example = toml::from_str(&text).unwrap();
run(&ex);
@@ -4587,3 +4577,71 @@ fn an_invoice_lines_project_is_what_the_project_counts() {
},
);
}
/// Whitespace and typographic quotes made plain, so a pin copied from a
/// wrapped or curly-quoted source still matches.
fn squash(s: &str) -> String {
let plain = s
.replace(['\u{2019}', '\u{2018}'], "'")
.replace(['\u{201c}', '\u{201d}'], "\"");
plain.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Every vendored citation in the specification names a file in
/// `docs/spec/sources`, and each quoted pin is verbatim in it. (The
/// specification crate checks the citations' form; the vendored texts live
/// here.)
#[test]
fn every_vendored_pin_is_verbatim() {
let sources = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/sources");
let mut vendored = 0;
let mut bad = Vec::new();
for (name, text) in bookspec_checker::spec_files() {
for line in text.lines() {
let Some(rest) = line.strip_prefix(" - vendored `") else {
continue;
};
let (file, rest) = rest
.split_once("`:")
.unwrap_or_else(|| panic!("{name}: {line}"));
let path = sources.join(file.strip_prefix("sources/").unwrap_or(file));
let hay = squash(
&std::fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("{name}: {file} is not vendored")),
);
for q in rest.split('"').skip(1).step_by(2) {
if !hay.contains(&squash(q)) {
bad.push(format!("{name}: {q:?} is not in {file}"));
}
}
vendored += 1;
}
}
assert!(bad.is_empty(), "pins not found:\n{}", bad.join("\n"));
assert!(vendored >= 30, "vendored citations: {vendored}");
}
/// `docs/spec/rules.md` carries every stated rule's id under its heading,
/// and no longer calls a choice a decision (P13 step 3).
#[test]
fn rules_md_carries_every_id() {
let text = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/rules.md"),
)
.expect("docs/spec/rules.md");
for rule in bookspec_checker::rules()
.iter()
.filter(|r| r.status.is_some())
{
assert!(
text.contains(&format!("**bookspec:** `{}`", rule.id)),
"B-{} carries {}",
rule.b,
rule.id
);
}
assert!(
!text.contains("DECISION"),
"rules.md speaks bookspec's CHOICE"
);
}
+69
View File
@@ -449,6 +449,55 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Draft #{id} discarded: {}", s(p, "reason")),
None,
),
// Audits and attestations (plan P8): claims about the books, not
// figures.
"audit.run" => ev(
icon::MAGNIFYING_GLASS,
format!(
"Audit {} through {}",
s(p, "scope").replace('_', " "),
s(p, "through")
),
None,
),
"receipt.attested" => ev(
icon::CHECK_CIRCLE,
if p.get("disagrees").and_then(|v| v.as_bool()) == Some(true) {
format!(
"{} read {} against entry #{}: it disagrees",
s(p, "by"),
attachment_name(b, att),
p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0)
)
} else {
format!(
"{} attested {} for entry #{}",
s(p, "by"),
attachment_name(b, att),
p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0)
)
},
Some(Route::Document(att)),
),
"entry.reviewed" => ev(
icon::CHECK_CIRCLE,
format!(
"{} reviewed entry #{}",
s(p, "by"),
p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0)
),
p.get("entry_id").and_then(|v| v.as_u64()).map(Route::Entry),
),
"period.attested" => ev(
icon::CHECK_CIRCLE,
format!(
"{} attested period #{} on audit #{}",
s(p, "by"),
p.get("period_id").and_then(|v| v.as_u64()).unwrap_or(0),
p.get("audit_run_id").and_then(|v| v.as_u64()).unwrap_or(0)
),
None,
),
"entry.settles" => {
let settles: Vec<String> = p
.get("settles")
@@ -1172,6 +1221,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Open item #{id} resolved: {}", s(p, "resolution")),
Some(Route::Company),
),
"convention.source_revised" => ev(
icon::SCROLL,
format!(
"Source of convention {} corrected to {}: {}",
s(p, "id"),
s(&p["source"], "decided_by"),
s(p, "reason")
),
Some(Route::Company),
),
"decision.source_revised" => ev(
icon::GAVEL,
format!("Source of decision #{id} corrected: {}", s(p, "reason")),
Some(Route::Company),
),
"open_item.source_revised" => ev(
icon::CHECK_CIRCLE,
format!("Source of open item #{id} corrected: {}", s(p, "reason")),
Some(Route::Company),
),
"entry.approved" => ev(
icon::SEAL_CHECK,
format!(
+5 -1
View File
@@ -88,7 +88,11 @@ fn says_update(text: &str) {
#[test]
fn the_viewer_says_update_for_newer_books_and_corrupt_for_damaged_ones() {
for (tag, line_type, payload) in [
("type", "period.attested", json!({"key": "company.name"})),
(
"type",
"ledger.not_yet_written",
json!({"key": "company.name"}),
),
(
"format",
"journal.format_set",
+52 -6
View File
@@ -203,19 +203,39 @@ fn children_executable() -> PathBuf {
}
}
/// A program file's identity: what changes when the file is replaced.
/// A program file's identity: device and inode, size and last write. A
/// replaced file differs in the inode, and a rewritten one in the rest.
#[cfg(unix)]
type Identity = (u64, u64, u64, i64, i64);
/// Windows: size and last write (nanoseconds).
#[cfg(windows)]
type Identity = (u64, u64);
#[cfg(unix)]
fn identity_from(meta: &std::fs::Metadata) -> Identity {
(
meta.dev(),
meta.ino(),
meta.len(),
meta.mtime(),
meta.mtime_nsec(),
)
}
/// The running executable's file identity, and its file's now: they
/// differ once the file was replaced (INVARIANT 7).
/// differ once the file was replaced (INVARIANT 7). An AppImage is its
/// image file (`APPIMAGE`), not the program inside the mount.
#[cfg(unix)]
fn running_identity() -> Option<Identity> {
if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
return Some(identity_from(&std::fs::metadata(image).ok()?));
}
let running = if cfg!(target_os = "linux") {
std::fs::metadata("/proc/self/exe").ok()?
} else {
std::fs::metadata(std::env::current_exe().ok()?).ok()?
};
Some((running.dev(), running.ino()))
Some(identity_from(&running))
}
/// Windows can't replace a program that runs, so an update puts the new
@@ -232,12 +252,12 @@ fn file_identity() -> Option<Identity> {
identity_of(&crate::start::this_executable().ok()?)
}
/// The identity (device, inode) of the program at `path`: a file someone
/// may run; else None.
/// The identity of the program at `path`: a file someone may run; else
/// None.
#[cfg(unix)]
fn identity_of(path: &Path) -> Option<Identity> {
let meta = std::fs::metadata(path).ok()?;
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino()))
(meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta))
}
/// The identity (size, last write in nanoseconds) of the program at
@@ -521,6 +541,13 @@ impl Host {
let started_as = running_identity();
let mut exe_poll = tokio::time::interval(self.knobs.exe_poll);
exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// The daily look for a newer release (P11a step 4): the work is
// a thread of its own, one at a time, and only stages a file.
let mut update_poll =
tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10)));
update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
#[cfg(unix)]
let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mut idle_at = Some(Instant::now() + self.knobs.close_after);
let server_start = tokio::time::sleep(self.knobs.server_delay);
tokio::pin!(server_start);
@@ -678,6 +705,25 @@ impl Host {
}
}
}
_ = update_poll.tick(), if matches!(self.phase, Phase::Open) => {
// Staging an update replaces the program's file, which
// Windows forbids while it runs: P11a step 5.
#[cfg(unix)]
{
use std::sync::atomic::Ordering;
if !looking.swap(true, Ordering::SeqCst) {
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
std::thread::spawn(move || {
match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"),
Ok(None) => {}
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
}
looking.store(false, Ordering::SeqCst);
});
}
}
}
_ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => {
if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) {
eprintln!("wanderbooks host: a new version is installed: restarting");
+9 -1
View File
@@ -33,6 +33,9 @@ pub struct Knobs {
/// How long after books open the server first looks for a drill due
/// (60 s), passed to the server as `--drill-first`.
pub drill_first: Duration,
/// How long between the host's looks for a newer release (plan
/// P11a step 4: a day).
pub update_check: Duration,
}
impl Default for Knobs {
@@ -47,6 +50,7 @@ impl Default for Knobs {
open_wait: Duration::from_secs(120),
backup_quiet: Duration::from_secs(30),
drill_first: Duration::from_secs(60),
update_check: Duration::from_secs(24 * 60 * 60),
}
}
}
@@ -76,6 +80,7 @@ impl Knobs {
"open_wait" => k.open_wait = d,
"backup_quiet" => k.backup_quiet = d,
"drill_first" => k.drill_first = d,
"update_check" => k.update_check = d,
other => return Err(format!("{FLAG}: there is no knob {other:?}")),
}
}
@@ -97,6 +102,7 @@ impl Knobs {
("open_wait", self.open_wait, d.open_wait),
("backup_quiet", self.backup_quiet, d.backup_quiet),
("drill_first", self.drill_first, d.drill_first),
("update_check", self.update_check, d.update_check),
] {
if now != default {
parts.push(format!("{name}={}", now.as_millis()));
@@ -130,6 +136,7 @@ mod tests {
assert_eq!(k.open_wait, Duration::from_secs(120));
assert_eq!(k.backup_quiet, Duration::from_secs(30));
assert_eq!(k.drill_first, Duration::from_secs(60));
assert_eq!(k.update_check, Duration::from_secs(86_400));
assert_eq!(k.to_arg(), None);
assert!(k.args().is_empty());
assert_eq!(Knobs::parse("").unwrap(), k);
@@ -138,7 +145,7 @@ mod tests {
#[test]
fn knobs_pass_on_exactly() {
let k = Knobs::parse(
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50",
"close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50,update_check=60",
)
.unwrap();
assert_eq!(k.close_after, Duration::from_millis(500));
@@ -150,6 +157,7 @@ mod tests {
assert_eq!(k.open_wait, Duration::from_millis(70));
assert_eq!(k.backup_quiet, Duration::from_millis(40));
assert_eq!(k.drill_first, Duration::from_millis(50));
assert_eq!(k.update_check, Duration::from_millis(60));
let args = k.args();
assert_eq!(args[0], FLAG);
assert_eq!(Knobs::parse(&args[1]).unwrap(), k);
+51 -7
View File
@@ -13,7 +13,8 @@
//! it names a socket this account owns (the owner's ssh agent, so the
//! server's backups reach a private git repository, D-30). Nothing
//! else passes (a scope's own identifiers, such as INVOCATION_ID, are
//! systemd's).
//! systemd's). One more: APPIMAGE, only when it names an absolute
//! file, so the host knows its own AppImage (below).
//! 2. Its standard input and output are null, its errors go to
//! run/host.log (0600); it starts a new session with umask 077. Every
//! other descriptor is closed (std opens them close-on-exec, and the
@@ -23,7 +24,11 @@
//! scope couldn't be made (systemd-run failed without the host ever
//! writing its first line), or there is no such scope, directly. A
//! host that ran and stopped is never started a second time.
//! 4. It runs this executable's file as it is on disk now.
//! 4. It runs this executable's file as it is on disk now; for an
//! AppImage that is the image file (APPIMAGE), not the program inside
//! its mount, which goes away with the image it came from. The host
//! reads that path about itself and never tells it to an agent (I-2),
//! so a replaced image restarts the books like any replaced program.
//! 5. The opener doesn't wait for it, and reaps it when it ends.
//! ```
@@ -65,6 +70,9 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024;
/// replaced file reads as "… (deleted)"; the path, not the old file, is
/// what a new host runs.
pub fn this_executable() -> Result<PathBuf, String> {
if let Some(image) = own_appimage(std::env::var_os("APPIMAGE").as_deref()) {
return Ok(PathBuf::from(image));
}
let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?;
let text = exe.to_string_lossy();
if let Some(path) = text.strip_suffix(" (deleted)")
@@ -75,6 +83,13 @@ pub fn this_executable() -> Result<PathBuf, String> {
Ok(exe)
}
/// The AppImage this process runs from: `APPIMAGE` when it is an
/// absolute path to a file.
pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
let path = Path::new(value?);
(path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string())
}
/// This account's user ID.
#[cfg(unix)]
fn own_uid() -> u32 {
@@ -151,7 +166,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option<std::ffi::OsString> {
#[cfg(unix)]
/// The host's environment (INVARIANT 1), given the opener's
/// `SSH_AUTH_SOCK`.
fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> {
fn host_env(
home: &Path,
ssh_agent: Option<&std::ffi::OsStr>,
appimage: Option<&std::ffi::OsStr>,
) -> Vec<(String, std::ffi::OsString)> {
let mut env = vec![
("PATH".to_string(), PATH.into()),
("HOME".to_string(), home.as_os_str().to_os_string()),
@@ -165,6 +184,9 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st
if let Some(agent) = own_agent(ssh_agent) {
env.push(("SSH_AUTH_SOCK".into(), agent));
}
if let Some(image) = own_appimage(appimage) {
env.push(("APPIMAGE".into(), image));
}
env
}
@@ -209,6 +231,10 @@ pub fn start_host(
let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?;
let run = found.run_dir().map_err(|e| e.to_string())?;
let home = ledger_store::root::account_home().map_err(|e| e.to_string())?;
// A staged update is applied here and nowhere while books are open:
// no host runs now, so no call is cut off (update.rs INVARIANT 9). A
// failure leaves the program as it was.
let _ = crate::update::apply_staged(&found, crate::update::program_version());
let exe = this_executable()?;
let mut args: Vec<std::ffi::OsString> = vec!["host".into()];
// An explicit root goes on as found (absolute): the host starts in /.
@@ -253,6 +279,7 @@ pub fn start_host(
.envs(host_env(
&home,
std::env::var_os("SSH_AUTH_SOCK").as_deref(),
std::env::var_os("APPIMAGE").as_deref(),
))
.current_dir("/")
.stdin(Stdio::null())
@@ -518,7 +545,7 @@ mod tests {
#[test]
fn the_host_environment_carries_nothing_else() {
let env = host_env(Path::new("/home/someone"), None);
let env = host_env(Path::new("/home/someone"), None, None);
let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(&names[..2], ["PATH", "HOME"]);
for n in &names[2..] {
@@ -526,7 +553,8 @@ mod tests {
[
"XDG_RUNTIME_DIR",
"DBUS_SESSION_BUS_ADDRESS",
"SSH_AUTH_SOCK"
"SSH_AUTH_SOCK",
"APPIMAGE"
]
.contains(n),
"{n}"
@@ -536,6 +564,22 @@ mod tests {
assert_eq!(env[1].1, "/home/someone");
}
#[test]
fn only_an_absolute_file_passes_as_the_appimage() {
let dir = tempfile::tempdir().unwrap();
let image = dir.path().join("W.AppImage");
std::fs::write(&image, b"x").unwrap();
let home = Path::new("/home/someone");
let has = |v: Option<&std::ffi::OsStr>| {
host_env(home, None, v).iter().any(|(n, _)| n == "APPIMAGE")
};
assert!(has(Some(image.as_os_str())));
assert!(!has(None));
assert!(!has(Some("W.AppImage".as_ref())), "relative");
assert!(!has(Some(dir.path().as_os_str())), "a folder");
assert!(!has(Some(dir.path().join("gone").as_os_str())));
}
#[test]
fn only_an_own_ssh_agent_socket_passes() {
let dir = tempfile::tempdir().unwrap();
@@ -546,7 +590,7 @@ mod tests {
Some(sock.clone().into_os_string())
);
// It reaches the host's environment, once.
let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()));
let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()), None);
let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0].1, sock.as_os_str());
@@ -555,7 +599,7 @@ mod tests {
let link = dir.path().join("link.sock");
std::os::unix::fs::symlink(&sock, &link).unwrap();
assert_eq!(own_agent(Some(link.as_os_str())), None);
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()));
let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()), None);
assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK"));
// Named relative to here: not taken, even where it leads to one.
let here = std::env::current_dir().unwrap();
+256 -10
View File
@@ -36,7 +36,17 @@
//! newer one: the only way back is a restore, plan 3.9).
//! 7. A package-managed install is never touched.
//! 8. A program with no release key compiled in updates from nothing
//! but a local (`file://`) feed whose key the owner names.
//! but a local (`file://`) feed whose key the owner names: on the
//! command line, or in `<root>/updates/feed` (the feed's address on
//! the first line, the key file's path on the second).
//! 9. The host looks for a newer release at most once a day while the
//! books are open, and only downloads and verifies it (the same
//! checks as INVARIANT 1) into `<root>/updates/staged/`. It never
//! swaps: a staged update is applied only (a) by `wanderbooks
//! update`, the owner's say-so, or (b) by an opener just before it
//! starts a host, when no host is running, so no call is ever cut
//! off. A staged file is checked again against the key before it is
//! applied; one that fails, or does not start, is dropped.
//! SCENARIOS:
//! 1. IF the feed offers a higher version, signed by our key: SHOULD
//! replace the program and keep the old one.
@@ -44,6 +54,10 @@
//! is by another key or missing: SHOULD refuse, changing nothing.
//! 3. IF the new program can't start: SHOULD put the old one back.
//! 4. IF a book consented to a newer format: SHOULD refuse to roll back.
//! 5. IF the host's daily look finds a signed newer release: SHOULD
//! stage it and change nothing running; the next host start runs it.
//! 6. IF the daily look finds a bad signature or checksum: SHOULD stage
//! nothing.
//! ```
use serde::Deserialize;
@@ -72,6 +86,26 @@ const UPDATES: &str = "updates";
const PREVIOUS: &str = "previous";
const PROGRAM: &str = "wanderbooks";
const META: &str = "meta.json";
const STAGED: &str = "staged";
const FEED_FILE: &str = "feed";
const CHECKED: &str = "checked";
/// The version of the program being run, set by its `main` (this crate's
/// own is the fallback: the packages rewrite only the program's).
static PROGRAM_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
/// Say which version this program is.
pub fn set_program_version(version: &'static str) {
let _ = PROGRAM_VERSION.set(version);
}
/// This program's version.
pub fn program_version() -> &'static str {
PROGRAM_VERSION
.get()
.copied()
.unwrap_or(env!("CARGO_PKG_VERSION"))
}
const MAX_MANIFEST: usize = 1 << 20;
const MAX_PROGRAM: u64 = 1 << 30;
@@ -244,11 +278,18 @@ pub fn update(
Install::SelfUpdating(target) => target,
};
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?;
let feed = flags.feed.as_deref().unwrap_or(DEFAULT_FEED);
let key = trusted_key(feed, flags.key_file.as_deref())?;
let manifest_bytes = fetch(feed, MAX_MANIFEST as u64)?;
let manifest: Manifest = serde_json::from_slice(&manifest_bytes)
.map_err(|e| format!("the release manifest isn't one we can read: {e}"))?;
let found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok();
let local = found_root
.as_ref()
.and_then(|r| local_feed(&r.path().join(UPDATES)));
let (feed, key_file) = match (&flags.feed, &local) {
(Some(feed), _) => (feed.clone(), flags.key_file.clone()),
(None, Some((feed, key))) => (feed.clone(), Some(key.clone())),
(None, None) => (DEFAULT_FEED.to_string(), flags.key_file.clone()),
};
let feed = feed.as_str();
let key = trusted_key(feed, key_file.as_deref())?;
let manifest = fetch_manifest(feed)?;
let offered = parse_version(&manifest.version).ok_or_else(|| {
format!(
"the manifest's version {:?} isn't one of ours",
@@ -272,9 +313,14 @@ pub fn update(
.get(&platform)
.ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?;
if flags.check {
let ready = found_root
.as_ref()
.and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version))
.is_some();
println!(
"wanderbooks {} is available (this is {current}). Run `{}` to install it.",
"wanderbooks {} is available{} (this is {current}). Run `{}` to install it.",
manifest.version,
if ready { ", already downloaded" } else { "" },
prog.trim_end_matches(" --check")
);
if !manifest.notes.trim().is_empty() {
@@ -284,9 +330,14 @@ pub fn update(
}
let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?;
let updates = updates_dir(&root)?;
let _lock = take_lock(&updates)?;
let _lock = take_lock_within(&updates, Duration::from_secs(10))?;
let bytes = fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?;
// A copy the host already downloaded and verified is used as it is
// found (and verified again below); else the download.
let bytes = match staged_bytes(&updates, &manifest.version) {
Some(bytes) => bytes,
None => fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?,
};
verify(&bytes, entry, &key)?;
// Verified: from here the files are ours to write. Staged, then the
@@ -313,6 +364,7 @@ pub fn update(
.into());
}
let _ = std::fs::remove_dir_all(&staged_dir);
let _ = std::fs::remove_dir_all(updates.join(STAGED));
println!(
"Updated wanderbooks from {current} to {}.",
manifest.version
@@ -323,6 +375,182 @@ pub fn update(
Ok(())
}
/// The manifest at `feed`.
fn fetch_manifest(feed: &str) -> Result<Manifest, Failure> {
let bytes = fetch(feed, MAX_MANIFEST as u64)?;
serde_json::from_slice(&bytes)
.map_err(|e| format!("the release manifest isn't one we can read: {e}").into())
}
/// The feed the owner named for this root (INVARIANT 8): a `file://`
/// address, and the file of the key to check it against.
fn local_feed(updates: &Path) -> Option<(String, PathBuf)> {
let text = std::fs::read_to_string(updates.join(FEED_FILE)).ok()?;
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
let feed = lines.next()?;
let key = lines.next()?;
feed.starts_with("file://")
.then(|| (feed.to_string(), PathBuf::from(key)))
}
/// The staged program, if it is the one for `version` and whole.
fn staged_bytes(updates: &Path, version: &str) -> Option<Vec<u8>> {
let dir = updates.join(STAGED);
let meta: serde_json::Value =
serde_json::from_slice(&std::fs::read(dir.join(META)).ok()?).ok()?;
if meta["version"].as_str() != Some(version) {
return None;
}
let bytes = std::fs::read(dir.join(PROGRAM)).ok()?;
sha256_hex(&bytes)
.eq_ignore_ascii_case(meta["sha256"].as_str()?)
.then_some(bytes)
}
/// The version staged, if any.
pub fn staged_version(root: &ledger_store::root::Root) -> Option<String> {
let meta = std::fs::read(root.path().join(UPDATES).join(STAGED).join(META)).ok()?;
let meta: serde_json::Value = serde_json::from_slice(&meta).ok()?;
meta["version"].as_str().map(str::to_string)
}
/// INVARIANT 9, the host's daily look: stage a newer release if there is
/// one and `every` has passed since the last look. Returns the version
/// newly staged. Changes nothing that runs.
pub fn stage_if_due(
root: &ledger_store::root::Root,
current: &str,
every: Duration,
) -> Result<Option<String>, Failure> {
let Install::SelfUpdating(_) = this_install()? else {
return Ok(None);
};
let updates = updates_dir(root)?;
let checked = updates.join(CHECKED);
if std::fs::metadata(&checked)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age < every)
{
return Ok(None);
}
// Marked first: a failed look waits a day too.
std::fs::write(&checked, b"").map_err(|e| format!("can't note the look: {}", e.kind()))?;
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?;
let (feed, key_file) = match local_feed(&updates) {
Some((feed, key)) => (feed, Some(key)),
None => (DEFAULT_FEED.to_string(), None),
};
let key = trusted_key(&feed, key_file.as_deref())?;
let Ok(_lock) = take_lock(&updates) else {
return Ok(None);
};
let manifest = fetch_manifest(&feed)?;
let offered =
parse_version(&manifest.version).ok_or("the manifest's version isn't one of ours")?;
if offered <= current_v {
let _ = std::fs::remove_dir_all(updates.join(STAGED));
return Ok(None);
}
if staged_bytes(&updates, &manifest.version).is_some() {
return Ok(None);
}
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
let entry = manifest
.platforms
.get(&platform)
.ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?;
let bytes = fetch(&resolve(&feed, &entry.url)?, MAX_PROGRAM)?;
verify(&bytes, entry, &key)?;
let dir = updates.join(STAGED);
let _ = std::fs::remove_dir_all(&dir);
private_dir(&dir)?;
write_program(&dir.join(PROGRAM), &bytes)?;
let meta = serde_json::json!({
"version": manifest.version,
"sha256": sha256_hex(&bytes),
"signature": entry.signature,
"notes": manifest.notes,
});
std::fs::write(dir.join(META), meta.to_string())
.map_err(|e| format!("can't stage the update: {}", e.kind()))?;
Ok(Some(manifest.version))
}
/// INVARIANT 9, applying: swap in the staged program, if there is one
/// for a newer version. The caller guarantees no host is running (it is
/// about to start one) or is the owner running `update`. Returns the
/// version applied.
pub fn apply_staged(
root: &ledger_store::root::Root,
current: &str,
) -> Result<Option<String>, Failure> {
let updates = root.path().join(UPDATES);
let dir = updates.join(STAGED);
if !dir.join(META).is_file() {
return Ok(None);
}
let Install::SelfUpdating(target) = this_install()? else {
return Ok(None);
};
let Ok(_lock) = take_lock(&updates) else {
return Ok(None);
};
let meta: serde_json::Value = std::fs::read(dir.join(META))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let drop_staged = || {
let _ = std::fs::remove_dir_all(&dir);
};
let (Some(version), Some(sha256), Some(signature)) = (
meta["version"].as_str(),
meta["sha256"].as_str(),
meta["signature"].as_str(),
) else {
drop_staged();
return Ok(None);
};
let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?;
if parse_version(version).is_none_or(|v| v <= current_v) {
drop_staged();
return Ok(None);
}
let local = local_feed(&updates);
let key = trusted_key(
local.as_ref().map_or(DEFAULT_FEED, |(f, _)| f.as_str()),
local.as_ref().map(|(_, k)| k.as_path()),
)?;
let Some(bytes) = staged_bytes(&updates, version) else {
drop_staged();
return Err("the staged update is damaged: dropped".into());
};
let entry = Entry {
url: String::new(),
sha256: sha256.to_string(),
signature: signature.to_string(),
};
if let Err(e) = verify(&bytes, &entry, &key) {
drop_staged();
return Err(e);
}
drop(bytes);
keep_previous(&updates, &target, current)?;
swap_in(&target, &dir.join(PROGRAM))?;
if let Err(why) = healthy(&target) {
let back = swap_in(&target, &updates.join(PREVIOUS).join(PROGRAM));
drop_staged();
return Err(match back {
Ok(()) => format!("the staged version {version} didn't start ({why}); the old one is back"),
Err(e) => format!("the staged version {version} didn't start ({why}) and the old one couldn't be put back ({e})"),
}
.into());
}
drop_staged();
Ok(Some(version.to_string()))
}
/// `wanderbooks rollback`: go back to the version the last update
/// replaced.
pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<(), Failure> {
@@ -337,7 +565,7 @@ pub fn rollback(prog: &str, args: impl IntoIterator<Item = String>) -> Result<()
};
let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?;
let updates = updates_dir(&root)?;
let _lock = take_lock(&updates)?;
let _lock = take_lock_within(&updates, Duration::from_secs(10))?;
let previous = updates.join(PREVIOUS);
let meta: serde_json::Value = std::fs::read(previous.join(META))
.ok()
@@ -562,6 +790,24 @@ fn private_dir(dir: &Path) -> Result<(), Failure> {
/// One update or rollback at a time.
fn take_lock(updates: &Path) -> Result<std::fs::File, Failure> {
take_lock_within(updates, Duration::ZERO)
}
/// The same, waiting up to `wait` for another to finish (the host's
/// look at the feed is a short holder).
fn take_lock_within(updates: &Path, wait: Duration) -> Result<std::fs::File, Failure> {
let deadline = Instant::now() + wait;
loop {
match take_lock_once(updates) {
Err(e) if Instant::now() < deadline && e.to_string().contains("running") => {
std::thread::sleep(Duration::from_millis(50));
}
other => return other,
}
}
}
fn take_lock_once(updates: &Path) -> Result<std::fs::File, Failure> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
+2
View File
@@ -25,6 +25,7 @@ pub mod labels;
pub mod pdf;
pub mod report;
pub mod templates;
pub mod workpaper;
pub mod xlsx;
pub use asset_register::{asset_register_pdf, asset_register_xlsx};
@@ -43,4 +44,5 @@ pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
pub use pdf::{PdfError, statement_pdf};
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
pub use templates::{ChartTemplate, EntityType, chart_template};
pub use workpaper::{Workpaper, WorkpaperCheck, WorkpaperFinding, workpaper_pdf};
pub use xlsx::{XlsxReport, report_to_xlsx};
+145
View File
@@ -0,0 +1,145 @@
//! The audit workpaper as a PDF (plan P8; rules.md B-45).
//!
//! ```text
//! GOAL: one document a CPA or the owner can read that says what was
//! checked, through which date and state of the books, and every
//! finding; the same audit gives the same bytes.
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
//! trail); CONVENTION (the statements' type and page).
//! INVARIANTS:
//! 1. Deterministic bytes: no clock, no random id; every text is data,
//! never markup.
//! 2. The findings appear in the order given, each with severity, code,
//! subject and message; the checks table lists every check the
//! programme runs, found or not.
//! ```
use crate::pdf::PdfError;
use serde::Serialize;
use std::sync::OnceLock;
use typst::foundations::{Dict, IntoValue};
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
/// One finding as the page shows it.
#[derive(Clone, Debug, Serialize)]
pub struct WorkpaperFinding {
/// failure, warning or info.
pub severity: String,
/// The finding code.
pub code: String,
/// What it is about, in words.
pub subject: String,
/// What was found.
pub message: String,
}
/// One check the programme runs.
#[derive(Clone, Debug, Serialize)]
pub struct WorkpaperCheck {
/// The finding code.
pub code: String,
/// failure, warning or info.
pub severity: String,
/// When it is reported.
pub predicate: String,
}
/// Everything the page shows.
#[derive(Clone, Debug, Serialize)]
pub struct Workpaper {
/// The company's name.
pub company: String,
/// The page's title.
pub title: String,
/// The audit's scope.
pub scope: String,
/// The date it covers.
pub through: String,
/// The books' figure head.
pub through_seq: u64,
/// The programme's version.
pub program_version: String,
/// The trial balance's hash.
pub tb_hash: String,
/// The findings' hash.
pub findings_sha256: String,
/// One line: how many of each severity.
pub summary: String,
/// The findings.
pub findings: Vec<WorkpaperFinding>,
/// The checks.
pub checks: Vec<WorkpaperCheck>,
}
static TEMPLATE: &str = include_str!("../templates/workpaper.typ");
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
ENGINE.get_or_init(|| {
TypstEngine::builder()
.main_file(TEMPLATE)
.fonts(crate::fonts::typst_fonts())
.build()
})
}
/// Render the workpaper as PDF bytes.
pub fn workpaper_pdf(w: &Workpaper) -> Result<Vec<u8>, PdfError> {
let data = serde_json::to_string(w).map_err(|e| PdfError::Compile(e.to_string()))?;
let mut inputs = Dict::new();
inputs.insert("data".into(), data.into_value());
let doc = engine()
.compile_with_input(inputs)
.output
.map_err(|e| PdfError::Compile(format!("{e:?}")))?;
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
.map_err(|e| PdfError::Save(format!("{e:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Workpaper {
Workpaper {
company: "Wanderland Labs LLC".into(),
title: "Audit workpaper".into(),
scope: "month".into(),
through: "2026-10-31".into(),
through_seq: 412,
program_version: "audit-0.1.0-1".into(),
tb_hash: "ab".repeat(32),
findings_sha256: "cd".repeat(32),
summary: "1 failure, 0 warnings".into(),
findings: vec![WorkpaperFinding {
severity: "failure".into(),
code: "evidence_missing".into(),
subject: "entry 7".into(),
message: "entry 7 (2026-10-02) has no receipt: *not markup* #strong[x]".into(),
}],
checks: vec![WorkpaperCheck {
code: "evidence_missing".into(),
severity: "failure".into(),
predicate: "a posted entry the policy covers has no receipt".into(),
}],
}
}
/// The same workpaper renders to the same bytes, a PDF.
#[test]
fn workpaper_pdf_is_deterministic() {
let a = workpaper_pdf(&sample()).unwrap();
let b = workpaper_pdf(&sample()).unwrap();
assert!(a.starts_with(b"%PDF"));
assert_eq!(a, b);
}
/// A different finding is a different document.
#[test]
fn a_different_finding_is_a_different_document() {
let mut w = sample();
let a = workpaper_pdf(&w).unwrap();
w.findings.clear();
assert_ne!(a, workpaper_pdf(&w).unwrap());
}
}
@@ -0,0 +1,77 @@
// An audit workpaper: what was checked, through which date and which
// books' state, and every finding with its code and severity.
//
// Everything arrives as data in sys.inputs.data (JSON), built by
// ledger-import::workpaper. The template only typesets: no arithmetic, and
// no text is ever parsed as markup. It holds no clock: the same data gives
// the same bytes.
//
// Shape: { company, title, scope, through, through_seq, program_version,
// tb_hash, findings_sha256, summary: str,
// findings: [{ severity, code, subject, message }],
// checks: [{ code, severity, predicate }] }
#let d = json(bytes(sys.inputs.data))
#let ink = luma(25)
#let quiet = luma(110)
#let hair = 0.5pt + luma(160)
#set document(title: d.company + " — " + d.title, author: d.company)
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 9pt, fill: ink, number-width: "tabular", hyphenate: false)
#set page(
paper: "us-letter",
margin: (x: 0.8in, top: 0.8in, bottom: 0.8in),
footer: context {
set text(7.5pt, fill: quiet)
grid(
columns: (1fr, auto),
[#d.program_version · head #d.through_seq],
align(right)[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#align(center)[
#text(15pt, fill: luma(45), d.company) \
#v(4pt)
#text(10.5pt, weight: "bold", d.title) \
#v(2pt)
Through #d.through · scope #d.scope
]
#v(12pt)
#grid(
columns: (auto, 1fr),
row-gutter: 5pt,
column-gutter: 12pt,
text(fill: quiet)[Books' figure head], [#d.through_seq],
text(fill: quiet)[Trial balance hash], text(size: 7.5pt)[#d.tb_hash],
text(fill: quiet)[Findings hash], text(size: 7.5pt)[#d.findings_sha256],
text(fill: quiet)[Result], [#d.summary],
)
#v(12pt)
#text(10pt, weight: "bold")[Findings]
#v(4pt)
#if d.findings.len() == 0 [
No findings.
] else [
#table(
columns: (auto, auto, auto, 1fr),
stroke: (x: none, y: hair),
inset: (x: 4pt, y: 4pt),
table.header([*Severity*], [*Code*], [*About*], [*Finding*]),
..d.findings.map(f => (f.severity, f.code, f.subject, f.message)).flatten(),
)
]
#v(14pt)
#text(10pt, weight: "bold")[What was checked]
#v(4pt)
#table(
columns: (auto, auto, 1fr),
stroke: (x: none, y: hair),
inset: (x: 4pt, y: 4pt),
table.header([*Code*], [*Severity*], [*True when it is reported*]),
..d.checks.map(c => (c.code, c.severity, c.predicate)).flatten(),
)
+4
View File
@@ -5,3 +5,7 @@
- Tag what members paid for the company with the card or the cash they used (books.set_entry_instrument): their card is excluded, their own check counts. A card that only funded a friends-and-family transfer is their cash. Tag what a payment platform's balance paid the same way: goods and services excluded, friends-and-family counts; untagged, both stay undetermined.
- Card and payment-network payments are excluded (the processor's Form 1099-K), whoever's card it was. A bank payment with no method recorded counts: tag a debit-card payment with the card.
- books.vendor_1099 is the year-end worksheet for the CPA. undetermined means "tell me how it was paid" or "decide this vendor". With include_tins: true it carries the full TINs from the vault; csv: true also hands it over as a CSV file (run its download command to save it).
## How the worksheet is built (books.vendor_1099)
Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). It lists those payments, the W-9s missing, and each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold is not known is refused: pass threshold from the IRS. For the CPA to confirm.
@@ -0,0 +1,33 @@
# Attestations and audit
An audit is the books read as an auditor reads them, through a day, in one run that gives the same findings for the same books. An attestation is a claim by a checker that it looked at something and found it right. The audit finds; the attestations stand behind it. The CPA package waits for both.
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 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.
- Books that have not declared audits refuse a confirmed audit and every attestation with format_upgrade_pending until the owner's upgrade (books.start says so). Nothing else about them changes.
## Attesting
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: 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.
method is your own claim: model, reasoning_effort (low, medium, high, xhigh, max), adversarial (true when the verifier was set to find what is wrong), verifier_agents (how many independent ones checked). The books record it and cannot verify it; say what is true.
## What counts
books.attestations lists every claim with counted: whether it meets policy.attestation_bar as the bar stands when you ask. The default bar is reasoning effort high; a year-level audit (annual, cpa_handoff, full) also needs the check to be adversarial and at least two independent verifier agents. A bar change changes what counts, earlier claims too: nothing is versioned. A claim whose receipt reading disagrees with the entry never counts.
## The CPA package
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.
@@ -5,3 +5,9 @@
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, a project's name as its QuickBooks class, the memos grouped); tell the human. Each line's project is its CLASS, the projects declared first as QuickBooks classes. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
- With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, a prepaid contract handed over at its book amount for the CPA to confirm, and the reconciliation from book to tax capital). A line the books can't know (the share of income as figured for tax, always; tax depreciation; a disposal's tax gain; the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in.
## What the package holds (books.export_cpa_package)
A zip, handed over as a download (run the command the result gives; never file it into the books). It holds: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), the statement of changes in members' equity (multi-member LLCs), the tax summary and the trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits; warnings, and the README, say what is written differently from the books); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, beginning capital, contributions in cash and in property, share of income, distributions and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its section 723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, is left open and named); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash.
By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins false gives last fours only. A locked vault or a missing TIN does not stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (for example ["1000"]) is required, since the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.
@@ -5,3 +5,11 @@
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
- A file goes out by download alone. Files the books make (books.export in every format, books.asset_labels, books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
- Receipts and documents come back out through books.get_receipt view bytes, handed over the same way: run its download command to save a copy in a folder you name.
## Importing a bank CSV (books.import_bank_csv)
A QuickBooks-style bank or credit-card CSV: 3 columns Date, Description, Amount (payments negative), or 4 columns Date, Description, money out, money in (Intuit heads them Credit, Debit). Slash dates must settle month-first or day-first from the file itself or from date_order ("month_first" for mm/dd, "day_first" for dd/mm); ask the human when the tool says the dates read either way. It creates one DRAFT entry per transaction, all or none: the bank account on one side, a categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).
## Exporting a report (books.export)
Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).
+1 -1
View File
@@ -8,4 +8,4 @@
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
- This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before.
- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), and `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books.
- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft), and `source_revisions` (P5: correcting who decided a convention, a decision or an open item, and when). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books.
@@ -13,3 +13,15 @@
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9; never books.file_document or books.attach_receipt: the form shows the TIN).
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
## Tagging an entry with several parties (books.set_entry_party)
Pass party (an id or a name), or parties: [{party, amount? | percent?}] for several. Each share is an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their company.members share). Once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to company.members never re-splits posted history. Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven.
Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that do not add up to what it moved. A party with a vendor record also tags the entry's vendor; a party without one, or clearing, clears the vendor tag. Metadata only: amounts never change. An entry and its reversal carry the same parties (tagging either tags both), so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). The result is the entry as books.get_entry gives it, each party's share resolved, with its direction and what of it was money or property in kind.
## Recording a W-9 (books.set_w9)
Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to "wanderbooks vault". The CPA package carries the full TIN to the CPA.
Give the signed form (a PDF, PNG or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. The result is the party (the TIN's last four only).
@@ -4,3 +4,7 @@
- Never confirm by forcing the numbers; find the difference.
- books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again.
- books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.
## What books.reconcile does
Give statement_attachment (a statement recorded with books.record_statement: its account, period end and closing balance), or account, statement_date and statement_balance for a quick check against any balance. It returns the book balance, the cleared balance, the statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed it is a preview and records nothing. With confirmed and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.
+4
View File
@@ -3,3 +3,7 @@
- The human usually has the read-only viewer open beside you. When they ask to see something, and after you post, call books.show so it's on their screen: an entry, an account register, a report for any dates (by month, quarter or year, against the prior period or year), a receipt or document, an asset, a party, a reconciliation, or a search.
- books.show says which dates it showed (without dates, the fiscal year to date; a register opens on it too, with "Show all dates" one click away for the human). It refuses a start after the end, and options its target ignores (dates for an entry, a split for the trial balance, a start for a report as of a day, a split into more than 240 columns): pass only what the target takes.
- The viewer never changes the books; every change goes through these tools.
## What books.show can open
Exactly one target: an entry (with its lines, receipt and history); an account's register (optionally for start/end dates); a report (optionally for start/end dates, split into month, quarter or year columns, compared with the prior period or prior year); a stored receipt or document; a fixed asset (by tag or id); a party (by id or name: who they are and everything the company did with them); a project (by id or name); a reconciliation (by id: its account with its statements and reconciliations); a search of the transactions; or a page. Work out dates yourself ("last quarter" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. It changes nothing in the books.
@@ -0,0 +1,8 @@
Go through the year-end compliance work for {{year}} for {{company}}.
1. books.start, and books.open_items: what is already open, with its due dates.
2. Read books.handbook sections 1099, parties-and-tins and rules-that-change. Work out the calendar for this entity type and list the filings and dates that apply; where you are unsure of a rule, raise it with books.raise_item for the human or the CPA rather than guessing.
3. 1099 work: books.vendor_1099 for {{year}}; collect missing W-9s (books.set_w9; a full TIN goes only into the vault, never into your messages).
4. Fixed assets: books.asset_schedule and books.run_depreciation for the year, previewed.
5. Every month of {{year}} closed (the month_end_close prompt) and reconciled.
6. Record each decision the human makes with books.record_decision, and each follow-up with books.raise_item.
+8
View File
@@ -0,0 +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.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.
+8
View File
@@ -0,0 +1,8 @@
Prepare the hand-off to the CPA for {{year}}.
1. books.start, then books.verify: the journal's chain, the replay and every stored file must be clean before anything leaves.
2. Check each of the year's months is closed (books.list_periods) and reconciled (books.reconciliations); list what is not and stop if any is missing.
3. books.control_check, and the 1099 work: books.vendor_1099 for the year, with W-9s in the vault (books.handbook section 1099).
4. Read books.handbook section cpa-handoff, then tell the human what the package holds and whether the full TINs go in it (they go in by default; the human decides).
5. books.export_cpa_package for the year. The package is handed over as a download: run the command the result gives, in a folder the human names, within 15 minutes.
6. Offer books.digest for the year.
@@ -0,0 +1,11 @@
Close the month {{month}} for {{company}}. The close is data-driven: do each step in order, show the human what each one did, and stop at the first one that is not clean.
1. books.start for the rules, then books.conventions with kind close_step: this company's own close steps, in the order it keeps them. Follow them.
2. Reconcile every tracked account for the month (the reconcile prompt, one account at a time); books.start lists the accounts that are behind.
3. Review the financing accounts and the open drafts: books.list_drafts. A draft is posted, with its evidence, or discarded; none may stand in a closed month.
4. Run what the month is owed: books.run_schedules, books.run_recurring and books.run_depreciation, each previewed first.
5. Check the figures: books.trial_balance, books.profit_and_loss and books.balance_sheet for the month; books.control_check for the receivable and payable controls; books.verify for the chain.
6. Only then books.close_period for the month, previewed and confirmed by the human.
7. Offer books.digest of the month.
A closed month is never edited: a correction is a reversing entry in an open month.
+11
View File
@@ -0,0 +1,11 @@
You are setting up the books for {{company}} with the human. Work in this order and show the human each preview before anything is confirmed.
1. Call books.start and read the rules. If state is active, stop: these books are already set up; ask what the human wants instead.
2. Ask for the company's facts (legal name, entity type, fiscal year end, time zone, base currency) and record them with books.set_setting; each call is previewed, then confirmed.
3. Choose the chart of accounts: books.chart_template lists the templates by entity type; import a chart from a spreadsheet with books.import_chart_csv if the human has one. Read books.handbook section entries first.
4. Record the members or owners, and the evidence policy (which entries need a receipt: books.handbook section evidence-and-receipts), the parties setting, and the attestation bar the human wants.
5. Enter the opening balances as one dated entry through books.draft_entry and books.add_line, preview it with books.post_entry, and post it only after the human confirms.
6. Check the opening trial balance with books.trial_balance against the human's own figures; every difference is explained or fixed before going on.
7. Offer the viewer (books.handbook section viewer) and a backup (books.handbook section backups).
Never guess a figure, a date or a name: ask. Amounts are decimal strings. Anything you cannot find in the handbook, ask the human.
+10
View File
@@ -0,0 +1,10 @@
Reconcile account {{account}} for {{month}} against the bank's statement.
1. Call books.start, then books.reconciliations to see what is already reconciled for this account.
2. Ask the human for the statement (the period's opening and closing balances and the statement file). Upload it as books.handbook section files-in-out says; never paste a file's contents.
3. Record it with books.record_statement. If the statement's opening balance is not the last reconciled balance, stop and find out why before going on.
4. Mark each transaction that appears on the statement as cleared with books.mark_cleared, matching by amount and date. Transactions on the statement with no entry are drafted (books.draft_entry), not forced.
5. Run books.reconcile for the statement. It succeeds only when the cleared items agree with the statement's closing balance exactly. If it does not, list the difference and the candidates (uncleared entries, statement lines with no entry); do not plug it.
6. Show the human the result with books.show, and offer books.digest.
A mistaken reconciliation is voided with books.void_reconciliation, never edited.
+13
View File
@@ -86,6 +86,7 @@ pub(crate) struct Call {
action: &'static str,
subject: String,
args_digest: Option<String>,
agent: Option<ledger_store::access::Agent>,
}
/// Why a call wasn't run, for its record and its answer.
@@ -124,6 +125,7 @@ impl Call {
action,
subject: subject.to_string(),
args_digest: None,
agent: None,
}
}
@@ -140,6 +142,16 @@ impl Call {
self.args_digest = args_digest;
}
/// This call, with what the agent said about itself (`books.start`).
pub(crate) fn with_agent(&mut self, agent: Option<ledger_store::access::Agent>) {
self.agent = agent;
}
/// The client program's name, as it named itself at `initialize`.
pub(crate) fn client_name(&self) -> Option<String> {
self.client.as_ref().map(|c| c.name.clone())
}
/// The call's caller, if the door named one.
pub(crate) fn caller(&self) -> Option<&Caller> {
self.caller.as_ref()
@@ -172,6 +184,7 @@ impl Call {
args_digest: self.args_digest.clone(),
result,
head_after,
agent: self.agent.clone(),
};
match self.log.record(&access) {
Ok(_) => Ok(()),
+180
View File
@@ -0,0 +1,180 @@
//! 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());
}
}
+4
View File
@@ -87,6 +87,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
),
("1099", include_str!("../handbook/1099.md")),
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
(
"attestations-and-audit",
include_str!("../handbook/attestations-and-audit.md"),
),
(
"settings-and-conventions",
include_str!("../handbook/settings-and-conventions.md"),
+3
View File
@@ -1121,6 +1121,7 @@ async fn refused_at_door(
args_digest: None,
result: Outcome::Refused(code.into()),
head_after: None,
agent: None,
};
let written = tokio::task::spawn_blocking(move || access.record(&record)).await;
if !matches!(written, Ok(Ok(_))) {
@@ -1350,6 +1351,7 @@ async fn upload(
args_digest: None,
result: outcome.clone(),
head_after: None,
agent: None,
};
let recorded = access.record(&record).is_ok();
if !recorded && received.is_ok() {
@@ -1524,6 +1526,7 @@ async fn fetch(
Err((_, code, _)) => Outcome::Refused((*code).into()),
},
head_after: None,
agent: None,
};
let recorded = access.record(&record).is_ok();
(found, recorded)
+605
View File
@@ -0,0 +1,605 @@
//! The result envelope: what every tool result carries besides its own
//! fields (plan 4.6).
//!
//! ```text
//! GOAL: an agent never has to guess what to do after a call: every result
//! says what follows (`next_steps`), what refused it and which tool
//! remedies that (`guards`), what to tell the human (`warnings`),
//! and, on a tool that can change the books, where the journal ends
//! now (`head`).
//! SOURCE: plan/served.md 4.6 and P5 step 1; DECISION (P5).
//! INVARIANTS:
//! 1. The envelope is applied in one place (`apply`, called by
//! `call_tool`), so no tool can forget it.
//! 2. Every result of a tool that is not read-only has a `head` (the
//! journal after the call's last write) and at least one next step; a
//! read has `next_steps` when something follows it and nothing else (a
//! resource and its tool agree). `guards` is on a refusal, and a
//! success without warnings has no `warnings` key.
//! 3. A tool's own fields are never overwritten: a payload that already
//! has `next_steps`, `guards`, `warnings` or `head` keeps it.
//! 4. A refused call has no head and no steps; a refusal that is a
//! guard (guards.rs) carries it as `structuredContent.guards`.
//! 5. Next steps from the books' attention list are the viewer's own
//! items (ledger_package::attention), filtered to the subject the
//! call touched.
//! ```
use super::BooksServer;
use super::guards;
use ledger_package::{Integrity, Severity, Target};
use rmcp::model::{CallToolResponse, ContentBlock};
use serde_json::{Map, Value, json};
use std::collections::HashSet;
use std::sync::OnceLock;
/// One thing to do next.
#[derive(Clone, Debug)]
pub(crate) struct Step {
/// The tool to call.
pub tool: Option<String>,
/// Or the prompt to run.
pub prompt: Option<String>,
/// Or a command for the human's computer (a download).
pub command: Option<String>,
/// Its arguments.
pub args: Option<Value>,
/// Why.
pub why: String,
/// Whether the work is not done without it.
pub required: bool,
}
impl Step {
pub(crate) fn tool(tool: &str, args: Option<Value>, why: &str, required: bool) -> Step {
Step {
tool: Some(tool.into()),
prompt: None,
command: None,
args,
why: why.into(),
required,
}
}
fn to_json(&self) -> Value {
let mut o = Map::new();
if let Some(t) = &self.tool {
o.insert("tool".into(), json!(t));
}
if let Some(p) = &self.prompt {
o.insert("prompt".into(), json!(p));
}
if let Some(c) = &self.command {
o.insert("command".into(), json!(c));
}
if let Some(a) = &self.args {
o.insert("args".into(), a.clone());
}
o.insert("why".into(), json!(self.why));
o.insert("required".into(), json!(self.required));
Value::Object(o)
}
}
/// The tools that are not read-only: their results carry a `head`.
pub(crate) fn mutating_tools() -> &'static HashSet<String> {
static SET: OnceLock<HashSet<String>> = OnceLock::new();
SET.get_or_init(|| {
BooksServer::tool_router()
.list_all()
.into_iter()
.filter(|t| {
!t.annotations
.as_ref()
.and_then(|a| a.read_only_hint)
.unwrap_or(false)
})
.map(|t| t.name.to_string())
.collect()
})
}
/// The steps a tool's own result implies (no books needed): the confirm
/// after a preview, and what follows a particular act.
pub(crate) fn tool_steps(
tool: &str,
args: Option<&Map<String, Value>>,
result: &Value,
) -> Vec<Step> {
let mut steps = Vec::new();
let confirmed = args
.and_then(|a| a.get("confirmed"))
.and_then(Value::as_bool)
.unwrap_or(false);
let entry = result
.get("entry_id")
.or_else(|| args.and_then(|a| a.get("entry_id")))
.and_then(Value::as_u64);
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 || already_run,
result.get("preview").and_then(Value::as_str),
args,
) {
if tool == "books.post_entry"
&& result.get("receipt_missing").and_then(Value::as_bool) == Some(true)
&& let Some(id) = entry
{
steps.push(Step::tool(
"books.attach_receipt",
with_entry(id),
"This entry needs a receipt before it posts: attach the file (upload it first).",
true,
));
steps.push(Step::tool(
"books.set_draft_state",
Some(json!({ "entry_id": id, "state": "awaiting_evidence" })),
"Or leave the draft waiting, with a note saying what for, until the receipt arrives.",
false,
));
}
let mut confirm = args.clone();
confirm.insert("confirmed".into(), json!(true));
confirm.insert("preview".into(), json!(preview));
steps.push(Step::tool(
tool,
Some(Value::Object(confirm)),
"Show this preview to the human; only when they agree, make the same call with confirmed true and this preview.",
true,
));
return steps;
}
match tool {
"books.set_setting" | "books.add_account" => {
steps.push(Step::tool(
"books.add_account",
None,
"Add the accounts the human's chart needs, one at a time.",
false,
));
steps.push(Step::tool(
"books.draft_entry",
None,
"Or record a transaction: draft it, add its lines, preview it, then post.",
false,
));
}
"books.draft_entry" => {
if let Some(id) = entry {
steps.push(Step::tool(
"books.add_line",
with_entry(id),
"Add the draft's lines: each is an account, a side and an amount; debits must equal credits.",
true,
));
}
}
"books.add_line" => {
if let Some(id) = entry {
steps.push(Step::tool(
"books.add_line",
with_entry(id),
"Add the next line of the draft.",
false,
));
steps.push(Step::tool(
"books.post_entry",
with_entry(id),
"When the lines balance, preview the post for the human.",
false,
));
}
}
"books.set_draft_state" => {
if let Some(id) = entry {
steps.push(Step::tool(
"books.attach_receipt",
with_entry(id),
"When the document arrives, upload and attach it to this draft.",
false,
));
}
}
"books.attach_receipt" => {
if let Some(id) = entry {
steps.push(Step::tool(
"books.post_entry",
with_entry(id),
"The evidence is attached: preview the post again for the human.",
false,
));
}
}
"books.post_entry" | "books.reverse_entry" => {
if let Some(id) = entry {
steps.push(Step::tool(
"books.show",
with_entry(id),
"Show the human the entry and what it moved.",
false,
));
if tool == "books.post_entry" {
steps.push(Step::tool(
"books.reverse_entry",
with_entry(id),
"Only if this was posted in error: reverse it (posted entries are never edited).",
false,
));
}
}
}
"books.draft_from_template" => {
if let Some(id) = entry {
steps.push(Step::tool(
"books.post_entry",
with_entry(id),
"Preview the draft for the human, who then confirms it.",
false,
));
}
}
"books.record_statement" => {
steps.push(Step::tool(
"books.mark_cleared",
None,
"Mark each transaction the statement shows as cleared.",
true,
));
let (account, end, close, attachment) = (
result.get("account_code"),
result.get("period_end"),
result.get("closing_balance"),
result.get("attachment_id"),
);
if let (Some(account), Some(end), Some(close), Some(attachment)) =
(account, end, close, attachment)
{
steps.push(Step::tool(
"books.reconcile",
Some(json!({
"account": account,
"statement_date": end,
"statement_balance": close,
"statement_attachment": attachment,
})),
"When every line is marked, preview the reconciliation against the statement.",
false,
));
}
}
"books.mark_cleared" => steps.push(Step::tool(
"books.reconcile",
None,
"Mark the rest, then preview the reconciliation against the statement.",
false,
)),
"books.reconcile" => steps.push(Step::tool(
"books.reconciliations",
None,
"See the reconciliation among the account's others.",
false,
)),
"books.list_periods" => {
for p in result
.get("periods")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|p| p.get("status").and_then(Value::as_str) == Some("open"))
.take(3)
{
if let Some(id) = p.get("id").and_then(Value::as_u64) {
steps.push(Step::tool(
"books.close_period",
Some(json!({ "period_id": id })),
"Once everything in this period is recorded and reconciled, preview its close.",
false,
));
}
}
}
"books.close_period" => {
let period = result
.get("period_id")
.or_else(|| args.and_then(|a| a.get("period_id")))
.and_then(Value::as_u64);
// The chain first (cheap, and what every close has always
// offered: the month_end_close eval follows it), then the
// audit the attestation rests on (P8).
steps.push(Step::tool(
"books.verify",
None,
"Verify the journal's chain and the projection after the close.",
false,
));
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 {
tool: None,
prompt: None,
command: Some(cmd.to_string()),
args: None,
why: "Run this to save the package in a folder you name, within 15 minutes."
.into(),
required: true,
});
}
}
_ => {}
}
steps
}
/// The attention items about the subject of the call, as steps.
fn attention_steps(
server: &BooksServer,
subject: Subject,
store: &ledger_store::Store,
) -> Vec<Step> {
let cfg = server.settings();
let settings = ledger_package::Settings {
company: &cfg.company,
cash_accounts: &cfg.roles.cash,
financing_accounts: &cfg.roles.financing,
investing_accounts: &cfg.roles.investing,
};
let today = ledger_core::settings::today_in(cfg.timezone.as_deref());
let items = ledger_package::find_with(store, today, &settings, &Integrity::default());
items
.into_iter()
.filter(|a| match (subject, a.target) {
(Subject::Entry(s), Target::Entry(t)) => s == t,
(Subject::Invoice(s), Target::Invoice(t)) => s == t,
_ => false,
})
.filter_map(|a| {
let (tool, args) = a.tool?;
let args = if args.is_null() {
match (a.target, tool.as_str()) {
(
Target::Entry(id),
"books.attach_receipt"
| "books.set_entry_party"
| "books.set_entry_settles",
) => Some(json!({ "entry_id": id })),
_ => None,
}
} else {
Some(args)
};
let title: String = a.title.chars().take(160).collect();
Some(Step::tool(
&tool,
args,
&title,
a.severity == Severity::Problem,
))
})
.take(4)
.collect()
}
/// What a call touched, for the attention filter.
#[derive(Clone, Copy)]
enum Subject {
Entry(u64),
Invoice(u64),
}
fn subject_of(args: Option<&Map<String, Value>>, result: &Value) -> Option<Subject> {
let pick = |k: &str| {
result
.get(k)
.or_else(|| args.and_then(|a| a.get(k)))
.and_then(Value::as_u64)
};
pick("entry_id")
.map(Subject::Entry)
.or_else(|| pick("invoice_id").map(Subject::Invoice))
}
/// The fallback of a tool that changes things and has nothing particular
/// to follow it.
fn digest_step() -> Step {
Step::tool(
"books.digest",
None,
"See what changed and offer it to the human.",
false,
)
}
/// Put the envelope on a tool's answer (INVARIANT 1). `wrote` is the head
/// after the call's last write, when it wrote.
pub(crate) fn apply(
server: &BooksServer,
tool: &str,
args: Option<&Map<String, Value>>,
out: Result<CallToolResponse, rmcp::ErrorData>,
) -> Result<CallToolResponse, rmcp::ErrorData> {
let Ok(CallToolResponse::Complete(mut r)) = out else {
return out;
};
if r.is_error == Some(true) {
// INVARIANT 4.
let text = r
.content
.iter()
.find_map(|c| match c {
ContentBlock::Text(t) => Some(t.text.as_str()),
_ => None,
})
.unwrap_or("");
let guards = guards::from_refusal(tool, text);
if !guards.is_empty() && r.structured_content.is_none() {
r.structured_content = Some(json!({ "guards": guards }));
}
return Ok(CallToolResponse::Complete(r));
}
let Some(Value::Object(mut body)) = r.structured_content.take() else {
return Ok(CallToolResponse::Complete(r));
};
let before = Value::Object(body.clone());
let mutating = mutating_tools().contains(tool);
let result = Value::Object(body.clone());
let mut steps = tool_steps(tool, args, &result);
let head = if mutating {
server.lock().ok().map(|store| {
let (seq, hash) = store.head();
if let Some(subject) = subject_of(args, &result)
&& steps.iter().all(|s| !s.required)
{
steps.extend(attention_steps(server, subject, &store));
}
json!({ "seq": seq, "hash": hash })
})
} else {
None
};
if mutating && steps.is_empty() {
steps.push(digest_step());
}
// A tool that can change the books always says what follows; a read
// says it only when something does, so what it says is what a resource
// of the same name says (`books://settings` is `books.settings`).
// `guards` is on a refusal (above) and `warnings` is the tool's own:
// a success that has none says none, as the tools always did.
if !body.contains_key("next_steps") && (mutating || !steps.is_empty()) {
body.insert(
"next_steps".into(),
Value::Array(steps.iter().map(Step::to_json).collect()),
);
}
if let Some(head) = head {
body.entry("head").or_insert(head);
}
let after = Value::Object(body);
// The same JSON in the text block (server.rs INVARIANT 1).
for c in &mut r.content {
if let ContentBlock::Text(t) = c
&& serde_json::from_str::<Value>(&t.text).is_ok_and(|v| v == before)
{
t.text = after.to_string();
}
}
r.structured_content = Some(after);
Ok(CallToolResponse::Complete(r))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_preview_gets_its_confirm_step() {
let args = json!({"entry_id": 3}).as_object().cloned().unwrap();
let steps = tool_steps(
"books.post_entry",
Some(&args),
&json!({"entry_id": 3, "preview": "abc", "status": "preview"}),
);
assert_eq!(steps.len(), 1);
let a = steps[0].args.as_ref().unwrap();
assert_eq!(a["confirmed"], true);
assert_eq!(a["preview"], "abc");
assert!(steps[0].required);
}
#[test]
fn every_step_a_tool_names_exists() {
let names: HashSet<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
let result = json!({"entry_id": 1, "file": {"download": "x"}});
for tool in mutating_tools() {
for s in tool_steps(tool, Some(&Map::new()), &result)
.into_iter()
.chain([digest_step()])
{
if let Some(t) = s.tool {
assert!(
names.contains(&t),
"{tool}: step names {t}, which is no tool"
);
}
}
}
}
}
+2
View File
@@ -25,6 +25,7 @@ pub(crate) fn capabilities() -> ServerCapabilities {
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.enable_prompts()
.build()
}
@@ -85,5 +86,6 @@ mod tests {
);
assert!(r["capabilities"]["tools"].is_object());
assert!(r["capabilities"]["resources"].is_object());
assert!(r["capabilities"]["prompts"].is_object());
}
}
+11 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 133] = [
pub const TABLE: [(&str, Needs); 140] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -200,7 +200,17 @@ pub const TABLE: [(&str, Needs); 133] = [
("books.list_drafts", R),
("books.set_draft_state", W),
("books.discard_draft", C),
// P5
("books.journal_history", R),
("books.preview_entry", R),
// P8: audits and attestations (an audit previews, then runs; a claim
// that someone checked is a write, not a post).
("books.audit", C),
("books.workpaper", R),
("books.attest_receipt", W),
("books.review_entry", W),
("books.attest_period", W),
("books.attestations", R),
];
/// What `tool` needs, given its arguments; None for a tool this server
+191 -9
View File
@@ -584,6 +584,8 @@ impl BooksServer {
+ Self::tax_router()
+ Self::write_offs_router()
+ Self::drafts_router()
+ Self::journal_router()
+ Self::audit_router()
}
/// The settings in force for this request: the books' own, taken from
@@ -1264,6 +1266,64 @@ fn resource_list() -> Vec<Resource> {
resources
}
/// The advertised resource templates (plan 4.5; RFC 6570): the URIs a
/// client can build, each read by `resource_body`.
fn resource_templates() -> Vec<rmcp::model::ResourceTemplate> {
use rmcp::model::ResourceTemplate;
let mk = |uri: &str, name: &str, title: &str, description: &str| {
ResourceTemplate::new(uri, name)
.with_title(title)
.with_description(description)
.with_mime_type("application/json")
};
vec![
mk(
"books://entries/{id}",
"entry",
"An entry",
"One entry with its lines, receipts and history (books.get_entry).",
),
mk(
"books://attachments/{id}",
"attachment",
"A stored file",
"A receipt or document: its metadata and the start of its text (books.get_receipt); its pages are books://attachments/{id}/page/{n}.",
),
mk(
"books://journal/{seq}",
"journal-line",
"A journal line",
"One journal line in words (books.journal_history).",
),
mk(
"books://reports/{kind}{?start,end,as_of}",
"report",
"A report",
"trial-balance, balance-sheet or profit-and-loss, for a day (as_of) or a range (start, end); without them as of today, or the year to date.",
),
mk(
"books://digest{?since,since_seq,as_of}",
"digest",
"The activity digest",
"What changed, since a date, since a journal line (since_seq), as of a day; bare, this month so far.",
),
]
}
/// Prompts are listed and read by a token that may read the books.
fn prompt_reader(context: &RequestContext<RoleServer>) -> Result<(), rmcp::ErrorData> {
match crate::access::caller(context) {
None => Err(rmcp::ErrorData::invalid_request(
crate::access::UNAUTHORIZED,
None,
)),
Some(c) if !c.may(ledger_store::auth::Scope::Read) => Err(
rmcp::ErrorData::invalid_request(crate::access::INSUFFICIENT_SCOPE, None),
),
Some(_) => Ok(()),
}
}
/// The subject kinds, for messages.
const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company";
@@ -1321,6 +1381,13 @@ thread_local! {
/// The journal's head after the last write made by the request
/// running on this thread (StoreGuard's drop), for its access record.
static WROTE: std::cell::RefCell<Option<(u64, String)>> = const { std::cell::RefCell::new(None) };
/// Who the request running on this thread is, as `books.start` shows
/// it: `{principal, kind, scopes, client_id}`. The token's own facts
/// and the client's name; set at the start of the request.
static YOU: std::cell::RefCell<Option<serde_json::Value>> = const { std::cell::RefCell::new(None) };
/// What the agent said about itself in a `books.start` this request is
/// running (`agent`), for the call's access record.
static AGENT_NOTE: std::cell::RefCell<Option<ledger_store::access::Agent>> = const { std::cell::RefCell::new(None) };
/// The company's time zone for the request running on this thread
/// (set at its start from the settings in force: one version per
/// request), so every "today" in it agrees.
@@ -1358,6 +1425,16 @@ fn today() -> NaiveDate {
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
}
/// A resource URI's query parameter `key`, when given and not empty.
fn query_param(query: &str, key: &str) -> Option<String> {
query
.split('&')
.find_map(|kv| kv.strip_prefix(&format!("{key}=")))
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
/// or with an empty `since=`, the first day of `today`'s month (this
/// month so far).
@@ -1397,12 +1474,20 @@ mod access_tools;
mod backup_tools;
#[path = "tools/files.rs"]
mod files;
#[path = "guards.rs"]
mod guards;
#[path = "tools/instruments.rs"]
mod instruments;
#[path = "tools/journal.rs"]
mod journal_tools;
#[path = "tools/kept.rs"]
mod kept;
#[path = "next_steps.rs"]
mod next_steps;
#[path = "tools/projects.rs"]
mod projects;
#[path = "tools/prompts.rs"]
mod prompts;
pub use kept::SettingKeyOut;
// The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a
@@ -1412,6 +1497,8 @@ pub use kept::SettingKeyOut;
mod assets;
#[path = "tools/attachments.rs"]
mod attachments;
#[path = "tools/audit.rs"]
mod audit_tools;
#[path = "tools/chart.rs"]
mod chart;
#[path = "tools/documents.rs"]
@@ -1500,14 +1587,17 @@ impl BooksServer {
}
"books://reports/trial-balance" => {
let Json(out) = self
.trial_balance(Parameters(TrialBalanceParams::default()))
.trial_balance(Parameters(TrialBalanceParams {
as_of: query_param(query, "as_of"),
..TrialBalanceParams::default()
}))
.await?;
json(&out)
}
"books://reports/balance-sheet" => {
let Json(out) = self
.balance_sheet(Parameters(BalanceSheetParams {
as_of: None,
as_of: query_param(query, "as_of"),
strict: None,
}))
.await?;
@@ -1516,8 +1606,8 @@ impl BooksServer {
"books://reports/profit-and-loss" => {
let Json(out) = self
.profit_and_loss(Parameters(ProfitAndLossParams {
start: None,
end: None,
start: query_param(query, "start"),
end: query_param(query, "end"),
class: None,
strict: None,
}))
@@ -1525,16 +1615,56 @@ impl BooksServer {
json(&out)
}
"books://digest" => {
let since = digest_since(query, today());
// `since_seq` (what was posted since a journal line) wins
// over `since`; with neither, this month so far.
let since_seq = query_param(query, "since_seq")
.map(|n| {
n.parse::<u64>()
.map_err(|_| format!("bad since_seq in {uri}"))
})
.transpose()?;
let since = if since_seq.is_some() {
None
} else {
Some(digest_since(query, today()))
};
let Json(out) = self
.digest(Parameters(DigestParams {
since: Some(since),
since_seq: None,
as_of: None,
since,
since_seq,
as_of: query_param(query, "as_of"),
}))
.await?;
json(&out)
}
_ if path.starts_with("books://entries/") => {
let id: u64 = path["books://entries/".len()..]
.parse()
.map_err(|_| format!("bad entry resource URI: {uri}"))?;
let Json(out) = self
.get_entry(Parameters(GetEntryParams { entry_id: id }))
.await?;
json(&out)
}
_ if path.starts_with("books://journal/") => {
let seq: u64 = path["books://journal/".len()..]
.parse()
.map_err(|_| format!("bad journal resource URI: {uri}"))?;
if seq == 0 {
return Err("journal seqs start at 1".into());
}
let Json(out) = self
.journal_history(Parameters(journal_tools::JournalHistoryParams {
after_seq: Some(seq - 1),
limit: Some(1),
..Default::default()
}))
.await?;
if out.lines.first().is_none_or(|l| l.seq != seq) {
return Err(format!("the journal has no line {seq}"));
}
json(&out)
}
handbook::URI => {
let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?;
json(&out)
@@ -1704,6 +1834,8 @@ impl ServerHandler for BooksServer {
let running_call = call.clone();
// Kept for the record of a call the server stopped before running.
let request_args = request.arguments.clone();
let tool_name = request.name.to_string();
let envelope_args = request.arguments.clone();
let result = run_blocking(&self.store, move || {
Box::pin(async move {
WROTE.with(|w| w.borrow_mut().take());
@@ -1712,6 +1844,17 @@ impl ServerHandler for BooksServer {
.caller()
.map(|c| (c.token.id.clone(), c.token.name.clone()));
CALLER.with(|c| *c.borrow_mut() = caller);
AGENT_NOTE.with(|a| a.borrow_mut().take());
YOU.with(|y| {
*y.borrow_mut() = running_call.caller().map(|c| {
serde_json::json!({
"principal": c.token.name,
"kind": "agent",
"scopes": c.token.scopes.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
"client_id": running_call.client_name(),
})
})
});
// Counted as running by now (run_blocking): hashed here.
let digest = Call::digest_of(request.arguments.as_ref());
let tcc =
@@ -1740,8 +1883,14 @@ impl ServerHandler for BooksServer {
std::panic::resume_unwind(panic)
}
};
// The result envelope (next_steps.rs INVARIANT 1).
let out = next_steps::apply(&server, &tool_name, envelope_args.as_ref(), out);
// Who asked is the request's alone: a later request on this
// thread (a resource read) must not see it.
YOU.with(|y| y.borrow_mut().take());
let mut c = running_call;
c.digested(digest);
c.with_agent(AGENT_NOTE.with(|a| a.borrow_mut().take()));
let recorded = c.record_blocking(outcome_of(&out), wrote);
(out, recorded)
})
@@ -1825,6 +1974,36 @@ impl ServerHandler for BooksServer {
Ok(ListResourcesResult::with_all_items(resource_list()))
}
async fn list_resource_templates(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListResourceTemplatesResult, rmcp::ErrorData> {
Ok(rmcp::model::ListResourceTemplatesResult::with_all_items(
resource_templates(),
))
}
async fn list_prompts(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::ListPromptsResult, rmcp::ErrorData> {
prompt_reader(&context)?;
Ok(Self::prompt_list())
}
async fn get_prompt(
&self,
request: rmcp::model::GetPromptRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::GetPromptResponse, rmcp::ErrorData> {
prompt_reader(&context)?;
self.prompt_get(&request)
.map(Into::into)
.map_err(|e| rmcp::ErrorData::invalid_params(e, None))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
@@ -1922,7 +2101,10 @@ impl BooksServer {
let server = self.clone();
let at = uri.clone();
let body = run_blocking(&self.store, move || {
Box::pin(async move { in_timezone_async(timezone, server.resource_body(&at)).await })
Box::pin(async move {
YOU.with(|y| y.borrow_mut().take());
in_timezone_async(timezone, server.resource_body(&at)).await
})
})
.await?;
// A refusal quotes the URI it was given: masked like a tool's.
+131 -17
View File
@@ -9,6 +9,25 @@
use super::*;
use crate::server::test_support::*;
/// Plan 4.2 (P5 step 2): `books.start` is loaded up front by clients that
/// defer tools, and no other tool asks for that.
#[test]
fn books_start_is_always_loaded() {
for tool in BooksServer::tool_router().list_all() {
let always = tool
.meta
.as_ref()
.and_then(|m| m.0.get("anthropic/alwaysLoad"))
.and_then(serde_json::Value::as_bool);
assert_eq!(
always == Some(true),
tool.name == "books.start",
"{}",
tool.name
);
}
}
/// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline
/// (D-46).
#[test]
@@ -42,7 +61,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 133] = [
let table: [(&str, Hints); 140] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -178,7 +197,15 @@ fn annotations_complete() {
("books.list_drafts", R),
("books.set_draft_state", W),
("books.discard_draft", D),
("books.journal_history", R),
("books.preview_entry", R),
// P8: audits and attestations.
("books.audit", W),
("books.workpaper", R),
("books.attest_receipt", W),
("books.review_entry", W),
("books.attest_period", W),
("books.attestations", R),
];
let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len());
@@ -701,23 +728,10 @@ fn tool_text_lint() {
}
}
/// Tool descriptions are at most 700 characters (plan I-12). The
/// ones over it today are listed, each to be shortened in P5 when its
/// text moves into the handbook; no new one may join them, and one
/// that shrinks under the cap must leave the list.
/// Tool descriptions are at most 700 characters (plan I-12); the long
/// text lives in the handbook (P5 moved the last eight there).
#[test]
fn tool_descriptions_fit_700_characters() {
// Shorten in P5.
const OVER: &[&str] = &[
"books.export",
"books.export_cpa_package",
"books.import_bank_csv",
"books.reconcile",
"books.set_entry_party",
"books.set_w9",
"books.show",
"books.vendor_1099",
];
let mut over: Vec<String> = BooksServer::tool_router()
.list_all()
.iter()
@@ -725,7 +739,10 @@ fn tool_descriptions_fit_700_characters() {
.map(|t| t.name.to_string())
.collect();
over.sort();
assert_eq!(over, OVER, "tool descriptions over 700 characters");
assert!(
over.is_empty(),
"tool descriptions over 700 characters: {over:?}"
);
}
/// The tools docs/mcp-interface.md documents: a line (or a bullet)
@@ -1528,3 +1545,100 @@ fn a_tool_list_is_cached_only_for_its_caller() {
}
});
}
/// Plan 4.6 (P5 step 1): the envelope is put on every result in one
/// place; a tool that can change the books answers with the journal's
/// `head` and at least one next step, whatever it said itself, and the
/// text block says the same as the structured content.
#[test]
fn every_mutating_result_has_head_and_next_steps() {
use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock};
let dir = unit_dir("envelope");
let server = served(store(&dir));
let (seq, hash) = server.lock().unwrap().head();
let mutating = next_steps::mutating_tools();
assert!(mutating.contains("books.post_entry") && !mutating.contains("books.trial_balance"));
let mut read_only = 0;
for tool in BooksServer::tool_router().list_all() {
let name = tool.name.to_string();
let answer = CallToolResponse::Complete(CallToolResult::structured(
serde_json::json!({"status": "ok"}),
));
let CallToolResponse::Complete(r) =
next_steps::apply(&server, &name, None, Ok(answer)).expect("an answer stays an answer")
else {
panic!("{name}: not a complete answer");
};
let body = r.structured_content.expect("structured");
assert!(
body.get("guards").is_none() && body.get("warnings").is_none(),
"{name}: a success without them says none"
);
let ContentBlock::Text(t) = &r.content[0] else {
panic!("{name}: no text block");
};
assert_eq!(
serde_json::from_str::<serde_json::Value>(&t.text).unwrap(),
body,
"{name}: the text says what the structure says"
);
if mutating.contains(&name) {
assert_eq!(body["head"]["seq"], seq, "{name}");
assert_eq!(body["head"]["hash"], hash.as_str(), "{name}");
let steps = body["next_steps"].as_array().expect("next_steps");
assert!(!steps.is_empty(), "{name}: no next step");
for s in steps {
assert!(s["why"].is_string() && s["required"].is_boolean(), "{name}");
}
} else {
read_only += 1;
assert!(body.get("head").is_none(), "{name}: a read has no head");
}
}
assert!(read_only > 20);
}
/// A tool's own `next_steps`, `warnings` and `head` are kept (the
/// envelope fills only what the result lacks), and a refusal that is a
/// guard carries it.
#[test]
fn the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard() {
use rmcp::model::{CallToolResponse, CallToolResult};
let dir = unit_dir("envelope-keeps");
let server = served(store(&dir));
let own = serde_json::json!({
"next_steps": [{"tool": "books.digest", "why": "mine", "required": false}],
"warnings": ["careful"],
"head": {"seq": 0, "hash": "x"},
});
let CallToolResponse::Complete(r) = next_steps::apply(
&server,
"books.post_entry",
None,
Ok(CallToolResponse::Complete(CallToolResult::structured(
own.clone(),
))),
)
.unwrap() else {
panic!()
};
let body = r.structured_content.unwrap();
assert_eq!(body["next_steps"], own["next_steps"]);
assert_eq!(body["warnings"], own["warnings"]);
assert_eq!(body["head"], own["head"]);
let refused = CallToolResponse::Complete(CallToolResult::error(vec![
rmcp::model::ContentBlock::text(
serde_json::json!({"code": "approval_required", "message": "needs approval", "remedy_tool": "books.approve_entry"})
.to_string(),
),
]));
let CallToolResponse::Complete(r) =
next_steps::apply(&server, "books.post_entry", None, Ok(refused)).unwrap()
else {
panic!()
};
assert_eq!(r.is_error, Some(true));
let g = &r.structured_content.unwrap()["guards"][0];
assert_eq!(g["code"], "approval_required");
assert_eq!(g["remedy_tool"], "books.approve_entry");
}
+780
View File
@@ -0,0 +1,780 @@
//! The audit group (plan P8; rules.md B-45): an audit run over the books,
//! its workpaper, and the claims that someone checked.
//!
//! ```text
//! GOAL: the whole re-audit through the tools: one programme of checks
//! that gives the same findings and the same workpaper for the same
//! books, and attestations that rest on a head that has not moved.
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
//! (an audit is a run, not a job), IZ-1k (the head).
//! INVARIANTS:
//! 1. `books.audit` previews (what it will check; nothing changes),
//! then with the preview's digest runs the checks, files the
//! workpaper (JSON and PDF) as documents and journals `audit.run`.
//! 2. The same books (the figure head), day, scope and programme give
//! the run that exists: nothing is filed or journaled twice.
//! 3. An attestation is a claim by the caller named by its token; the
//! method it gives is self-reported and the books say so.
//! 4. `counted` is read against `policy.attestation_bar` as it stands
//! when asked, never stored.
//! 5. A period is attested only on an audit at the current head.
//! 6. Nothing here moves a figure.
//! ```
use super::*;
use ledger_core::audit::{self, AttestationBar, AuditInput, Effort, Finding, Method, Scope};
use ledger_store::audit::{self as saudit, Attested, AuditRun, BackupStanding, Claim};
use sha2::{Digest, Sha256};
fn sha_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
/// The caller's token name, else the default token's.
fn caller_name() -> String {
CALLER
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string())
}
fn method_of(m: Option<AttestMethodParam>) -> Result<Method, String> {
let m = m.unwrap_or_default();
let reasoning_effort = match m.reasoning_effort.as_deref() {
None => None,
Some(e) => Some(Effort::parse(e).ok_or_else(|| {
format!("reasoning_effort {e:?} is not low, medium, high, xhigh or max")
})?),
};
if m.verifier_agents == Some(0) {
return Err("verifier_agents is at least 1".into());
}
Ok(Method {
model: m
.model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
reasoning_effort,
adversarial: m.adversarial,
verifier_agents: m.verifier_agents,
})
}
fn method_out(m: &Method) -> AttestMethodParam {
AttestMethodParam {
model: m.model.clone(),
reasoning_effort: m.reasoning_effort.map(|e| e.key().to_string()),
adversarial: m.adversarial,
verifier_agents: m.verifier_agents,
}
}
fn finding_out(f: &Finding) -> AuditFindingOut {
AuditFindingOut {
severity: f.severity.key().into(),
code: f.code.into(),
subject: f.subject.describe(),
message: f.message.clone(),
}
}
/// Every check the programme runs.
fn checks() -> Vec<AuditCheckOut> {
audit::CODES
.iter()
.chain(saudit::CODES)
.map(|r| AuditCheckOut {
code: r.code.into(),
severity: r.severity.key().into(),
predicate: r.predicate.into(),
})
.collect()
}
fn run_out(store: &Store, run: &AuditRun) -> Result<AuditRunOut, String> {
Ok(AuditRunOut {
id: run.id,
through: run.through.to_string(),
through_seq: run.through_seq,
scope: run.scope.key().into(),
program_version: run.program_version.clone(),
tb_hash: run.tb_hash.clone(),
findings_sha256: run.findings_sha256.clone(),
workpaper_attachment: run.workpaper_attachment,
workpaper_pdf: run.workpaper_pdf,
at_current_head: store.audit_is_current(run).map_err(err)?,
})
}
fn summary(findings: &[Finding]) -> String {
let n = |s: audit::Severity| findings.iter().filter(|f| f.severity == s).count();
format!(
"{} failure(s), {} warning(s), {} note(s)",
n(audit::Severity::Failure),
n(audit::Severity::Warning),
n(audit::Severity::Info)
)
}
/// Why an attestation does not count at this bar, in words.
fn why_not(bar: &AttestationBar, m: &Method, full: bool, disagrees: bool) -> Option<String> {
if bar.counts(m, full, disagrees) {
return None;
}
if disagrees {
return Some(
"the amount or date read off the receipt differs from the entry's: it is recorded and never counts"
.into(),
);
}
let mut why = Vec::new();
if !m
.reasoning_effort
.is_some_and(|e| e >= bar.reasoning_effort)
{
why.push(format!(
"reasoning_effort must be at least {}",
bar.reasoning_effort.key()
));
}
if full {
if bar.adversarial && m.adversarial != Some(true) {
why.push("the check must be adversarial".into());
}
if !m.verifier_agents.is_some_and(|n| n >= bar.verifier_agents) {
why.push(format!(
"at least {} independent verifier agents",
bar.verifier_agents
));
}
}
Some(format!(
"below policy.attestation_bar ({}): {}",
bar.describe(),
why.join("; ")
))
}
/// An attestation as the tools list it.
fn attestation_out(
store: &Store,
a: &saudit::Attestation,
bar: &AttestationBar,
) -> Result<AttestationOut, String> {
let mut out = AttestationOut {
seq: a.seq,
kind: a.kind().into(),
by: a.by.clone(),
at: a.at.clone(),
attachment_id: None,
entry_id: None,
period_id: None,
audit_run_id: None,
amount_checked: None,
date_checked: None,
disagrees: false,
method: method_out(&a.method),
note: a.note.clone(),
counted: false,
};
let (full, disagrees) = match &a.what {
Attested::Receipt {
attachment_id,
entry_id,
amount_checked,
date_checked,
disagrees,
} => {
out.attachment_id = Some(*attachment_id);
out.entry_id = Some(*entry_id);
out.amount_checked = Some(amount_checked.to_string());
out.date_checked = Some(date_checked.to_string());
out.disagrees = *disagrees;
(false, *disagrees)
}
Attested::Entry { entry_id } => {
out.entry_id = Some(*entry_id);
(false, false)
}
Attested::Period {
period_id,
audit_run_id,
} => {
out.period_id = Some(*period_id);
out.audit_run_id = Some(*audit_run_id);
let scope = store
.audit_run(*audit_run_id)
.map_err(err)?
.map(|r| r.scope);
(scope.is_some_and(Scope::full_bar), false)
}
};
out.counted = bar.counts(&a.method, full, disagrees);
Ok(out)
}
/// What an attestation's tool answers.
fn attest_out(store: &Store, a: &saudit::Attestation) -> Result<AttestOut, String> {
let bar = store.ledger().settings().attestation_bar();
let o = attestation_out(store, a, &bar)?;
let full = o
.audit_run_id
.and_then(|id| store.audit_run(id).ok().flatten())
.is_some_and(|r| r.scope.full_bar());
let why_not_counted = why_not(&bar, &a.method, full, o.disagrees);
Ok(AttestOut {
attestation: o,
bar: bar.describe(),
why_not_counted,
})
}
impl BooksServer {
/// Whether the CPA package may be made (plan P8 step 4, D-9): on books
/// that declared `audits` and a policy of refuse, an audit for the
/// handoff (scope cpa_handoff or full) at the current head must have a
/// period attested on it at the bar. `Ok(None)` when it may; the
/// refusal's words otherwise.
pub(crate) fn cpa_audit_standing(&self, store: &Store) -> Result<Option<String>, String> {
if !store
.journal_format()
.has_feature(ledger_store::format::AUDITS)
{
return Ok(None);
}
let settings = store.ledger().settings();
let bar = settings.attestation_bar();
let attestations = store.attestations().map_err(err)?;
for run in store.audit_runs().map_err(err)?.iter().rev() {
if !run.scope.serves_handoff() || !store.audit_is_current(run).map_err(err)? {
continue;
}
let counted = attestations.iter().any(|a| {
matches!(a.what, Attested::Period { audit_run_id, .. } if audit_run_id == run.id)
&& bar.counts(&a.method, run.scope.full_bar(), false)
});
if counted {
return Ok(None);
}
}
Ok(Some(format!(
"no audit for the handoff (books.audit with scope cpa_handoff or full) stands at the books' current head with a period attested at the bar ({}); run the audit, have it verified (books.attest_receipt, books.review_entry, books.attest_period), or pass without_audit_reason",
bar.describe()
)))
}
/// The findings the programme gives the books through `through`: the
/// statements, then what only the store, the backups and the settings
/// can see. Reads only.
fn audit_findings(
&self,
store: &Store,
through: NaiveDate,
) -> Result<(Vec<Finding>, String), String> {
let ledger = store.ledger();
let cfg = self.settings();
let ps = package_settings(&cfg);
let receipts = store.entries_with_receipts().map_err(err)?;
// The statement of cash flows, from the first entry, with the
// books' role settings; none when they name no cash accounts.
let first = ledger
.posted_entries()
.filter(|e| e.date() <= through)
.map(|e| e.date())
.min();
let cash = match first {
Some(start) if !ps.cash_accounts.is_empty() => Some(
ledger_package::cash_flow_for(
ledger,
&ps,
Some(&start.to_string()),
Some(&through.to_string()),
&[],
&[],
&[],
today(),
)
.map(|s| s.reconciles),
),
_ => None,
};
// Vendors paid in a way that could need a 1099 and undecided; a
// year whose threshold isn't known is the stale-rule item's.
use chrono::Datelike;
let undecided: Vec<(String, rust_decimal::Decimal)> =
ledger_package::vendor_1099_report(ledger, &ps, through.year(), None, Some(through))
.map(|r| {
r.vendors
.iter()
.filter(|v| v.decision_needed)
.map(|v| (v.name.clone(), v.paid))
.collect()
})
.unwrap_or_default();
let problems = settings_problems(ledger, &cfg);
let mut findings = audit::statements(
ledger,
&AuditInput {
settings: ledger.settings(),
receipts: &receipts,
cash_flow: cash.as_ref(),
settings_problems: &problems,
undecided_1099: &undecided,
},
through,
);
findings.extend(store.integrity_findings());
let standing = self.backups.standing(store);
findings.extend(saudit::backup_findings(
&standing
.targets
.iter()
.map(|t| BackupStanding {
target: format!("{} {}", t.target, t.at),
stale: t.stale,
last_error: t.last_error.clone(),
drill_error: t.drill_error.clone(),
})
.collect::<Vec<_>>(),
));
audit::sort(&mut findings);
let tb = ledger_core::reports::trial_balance_as_of(ledger, through);
let tb_hash = sha_hex(&serde_json::to_vec(&tb).map_err(err)?);
Ok((findings, tb_hash))
}
}
#[tool_router(router = audit_router, vis = "pub(super)")]
impl BooksServer {
/// ```text
/// GOAL: the whole programme of checks over the books through a day,
/// its workpaper filed, the run journaled.
/// SOURCE: plan/served.md P8; B-45; D-9, D-53.
/// INVARIANTS:
/// 1. Without `confirmed`, a preview that lists the checks and
/// changes nothing; with it, the preview's digest is required.
/// 2. Idempotent on the figure head, day, scope and programme.
/// 3. The workpaper is JSON and PDF, filed as documents (category
/// workpaper), the PDF with deterministic bytes.
/// SCENARIOS:
/// 1. IF the books haven't declared audits: the run is refused with
/// format_upgrade_pending; the preview still lists the checks.
/// 2. IF the same audit is asked at the same head: SHOULD return
/// the first run, status already_run.
/// ```
#[tool(
name = "books.audit",
title = "Audit the books",
description = "Audit the books through a day (through, default today) at a scope: month, annual, cpa_handoff (the year, for the CPA package) or full. It checks the statements, the A/R and A/P control accounts, closes, schedules, assets, each posted entry against the receipt policy, open drafts, tax categories, 1099 decisions and settings, then the journal, stored files, access log and backups. Without confirmed, a preview of the checks, changing nothing. With confirmed and the preview, it files the workpaper (books.workpaper) and journals the run; the same books, day and scope give the run that exists. Attestations rest on a run only while no entry, document, close, settings or role change follows it",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn audit(&self, params: Parameters<AuditParams>) -> Result<Json<AuditOut>, String> {
let p = params.0;
let scope = Scope::parse(&p.scope).ok_or_else(|| {
format!(
"bad scope {:?}: expected month, annual, cpa_handoff or full",
p.scope
)
})?;
let through = self.as_of(p.through.as_deref())?;
let confirmed = p.confirmed.unwrap_or(false);
let mut store = self.lock()?;
let head = store.figure_head().map_err(err)?;
let digest = self.preview_of(&store, "books.audit", confirmed, p.preview.as_deref())?;
if let Some(run) = store.find_audit_run(through, head, scope).map_err(err)? {
let findings = self.stored_findings(&store, &run)?;
return Ok(Json(AuditOut {
status: "already_run".into(),
preview: Some(digest),
head,
checks: checks(),
summary: Some(summary_of(&findings)),
run: Some(run_out(&store, &run)?),
findings,
}));
}
if !confirmed {
return Ok(Json(AuditOut {
status: "preview".into(),
preview: Some(digest),
head,
checks: checks(),
run: None,
findings: Vec::new(),
summary: None,
}));
}
store
.ensure_feature(ledger_store::format::AUDITS)
.map_err(err)?;
let (findings, tb_hash) = self.audit_findings(&store, through)?;
let findings_sha256 = sha_hex(&serde_json::to_vec(&findings).map_err(err)?);
let summary = summary(&findings);
let company = self.settings().company.name.clone();
let rows: Vec<AuditFindingOut> = findings.iter().map(finding_out).collect();
let check_rows = checks();
let paper = serde_json::json!({
"company": company,
"title": "Audit workpaper",
"scope": scope.key(),
"through": through.to_string(),
"through_seq": head,
"program_version": saudit::PROGRAM_VERSION,
"tb_hash": tb_hash,
"findings_sha256": findings_sha256,
"summary": summary,
"findings": rows,
"checks": check_rows,
});
let json_bytes = serde_json::to_vec_pretty(&paper).map_err(err)?;
let pdf_bytes = ledger_import::workpaper_pdf(&ledger_import::Workpaper {
company,
title: "Audit workpaper".into(),
scope: scope.key().into(),
through: through.to_string(),
through_seq: head,
program_version: saudit::PROGRAM_VERSION.into(),
tb_hash: tb_hash.clone(),
findings_sha256: findings_sha256.clone(),
summary: summary.clone(),
findings: rows
.iter()
.map(|f| ledger_import::WorkpaperFinding {
severity: f.severity.clone(),
code: f.code.clone(),
subject: f.subject.clone(),
message: f.message.clone(),
})
.collect(),
checks: check_rows
.iter()
.map(|c| ledger_import::WorkpaperCheck {
code: c.code.clone(),
severity: c.severity.clone(),
predicate: c.predicate.clone(),
})
.collect(),
})
.map_err(err)?;
let stem = format!("workpaper-{}-{through}-{head}", scope.key());
let describe = format!(
"Audit workpaper, scope {}, through {through}, books at figure head {head} ({}).",
scope.key(),
saudit::PROGRAM_VERSION
);
let (wp_json, _, _) = store
.file_document(
"workpaper",
&format!("{stem}.json"),
"application/json",
&json_bytes,
Some(&describe),
)
.map_err(err)?;
let (wp_pdf, _, _) = store
.file_document(
"workpaper",
&format!("{stem}.pdf"),
"application/pdf",
&pdf_bytes,
Some(&describe),
)
.map_err(err)?;
// A month's or a year's workpaper belongs with its period.
if matches!(scope, Scope::Month | Scope::Annual)
&& let Some(period) = store
.ledger()
.list_periods()
.into_iter()
.filter(|q| q.start <= through && through <= q.end)
.min_by_key(|q| (q.end - q.start).num_days())
.map(|q| q.id)
{
for a in [wp_json, wp_pdf] {
let _ = store.link_attachment(a, ledger_store::Subject::Period(period));
}
}
let run = store
.record_audit_run(saudit::NewAuditRun {
through,
through_seq: head,
scope,
tb_hash,
findings_sha256,
workpaper_attachment: Some(wp_json),
workpaper_pdf: Some(wp_pdf),
})
.map_err(err)?;
Ok(Json(AuditOut {
status: "audited".into(),
preview: Some(digest),
head,
checks: check_rows,
summary: Some(summary),
run: Some(run_out(&store, &run)?),
findings: rows,
}))
}
/// ```text
/// GOAL: an audit run's workpaper, whole.
/// SOURCE: plan/served.md P8 step 3; B-45.
/// INVARIANTS:
/// 1. Read-only; the JSON is the one filed, the PDF is handed over.
/// ```
#[tool(
name = "books.workpaper",
title = "An audit's workpaper",
description = "The workpaper of an audit run (books.audit's run.id): what was checked, through which day and state of the books, and every finding, as the JSON that was filed, with the PDF handed over (run its download command to save it in a folder). Changes nothing.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn workpaper(
&self,
params: Parameters<WorkpaperParams>,
) -> Result<Json<WorkpaperOut>, String> {
let store = self.lock()?;
let run = store.audit_run(params.0.id).map_err(err)?.ok_or_else(|| {
format!(
"no audit run {}: books.attestations lists the ones attested",
params.0.id
)
})?;
let json_id = run
.workpaper_attachment
.ok_or("this audit run filed no workpaper")?;
let bytes = store.attachment_bytes(json_id).map_err(err)?;
let workpaper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
let pdf = match run.workpaper_pdf {
Some(id) => {
let bytes = store.attachment_bytes(id).map_err(err)?;
Some(self.hand_over(
&book_name(&store),
&format!(
"workpaper-{}-{}-{}.pdf",
run.scope.key(),
run.through,
run.through_seq
),
"application/pdf",
bytes,
)?)
}
None => None,
};
Ok(Json(WorkpaperOut {
run: run_out(&store, &run)?,
workpaper,
pdf,
}))
}
/// ```text
/// GOAL: a checker's claim that a receipt supports an entry.
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
/// INVARIANTS:
/// 1. The reading is compared with the entry's amount and date; a
/// difference is recorded, marked, and never counts.
/// 2. The method is the client's claim, self-reported.
/// ```
#[tool(
name = "books.attest_receipt",
title = "Attest a receipt",
description = "Record that you read a receipt and it supports a posted entry: amount and date as you read them off it (get_receipt text, then page_image), and how you checked (method: model, reasoning_effort, adversarial, verifier_agents; your own claim, which the books record but cannot verify). The books compare the reading with the entry's amounts and date; a difference is recorded as disagrees and never counts. Whether it counts is weighed against policy.attestation_bar when asked.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn attest_receipt(
&self,
params: Parameters<AttestReceiptParams>,
) -> Result<Json<AttestOut>, String> {
let p = params.0;
let amount = parse_amount(&p.amount)?;
let date = NaiveDate::parse_from_str(p.date.trim(), "%Y-%m-%d")
.map_err(|_| format!("{:?} isn't a date: give YYYY-MM-DD", p.date))?;
let claim = Claim {
by: caller_name(),
method: method_of(p.method)?,
note: p.note,
};
let mut store = self.lock()?;
let a = store
.attest_receipt(p.attachment_id, p.entry_id, amount, date, claim)
.map_err(err)?;
Ok(Json(attest_out(&store, &a)?))
}
/// ```text
/// GOAL: a checker's claim that it reviewed an entry whole.
/// SOURCE: plan/served.md P8 step 4; B-45.
/// ```
#[tool(
name = "books.review_entry",
title = "Review an entry",
description = "Record that you reviewed a posted entry whole: its lines, accounts, party and evidence, and how you checked (method, as for books.attest_receipt; your own claim). Changes no figure.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn review_entry(
&self,
params: Parameters<ReviewEntryParams>,
) -> Result<Json<AttestOut>, String> {
let p = params.0;
let claim = Claim {
by: caller_name(),
method: method_of(p.method)?,
note: p.note,
};
let mut store = self.lock()?;
let a = store.review_entry(p.entry_id, claim).map_err(err)?;
Ok(Json(attest_out(&store, &a)?))
}
/// ```text
/// GOAL: a checker's claim that a period is right, resting on an
/// audit run at the books' current head.
/// SOURCE: plan/served.md P8 step 4; B-45; D-9; IZ-1k.
/// INVARIANTS:
/// 1. Refused unless an audit through the period's end stands at
/// the current head.
/// ```
#[tool(
name = "books.attest_period",
title = "Attest a period",
description = "Record that you checked a period (its id or name from books.list_periods), resting on an audit (books.audit) through its end that still stands: refused when an entry, document, close, settings or role change has been made since the audit, or no audit covers it. Say how you checked (method, as for books.attest_receipt; your own claim). A year-level audit (annual, cpa_handoff, full) is held to the whole policy.attestation_bar: reasoning effort, adversarial, and the number of independent verifier agents.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn attest_period(
&self,
params: Parameters<AttestPeriodParams>,
) -> Result<Json<AttestOut>, String> {
let p = params.0;
let claim = Claim {
by: caller_name(),
method: method_of(p.method)?,
note: p.note,
};
let mut store = self.lock()?;
let named = p.period.trim();
let period = store
.ledger()
.list_periods()
.into_iter()
.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(|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)?))
}
/// ```text
/// GOAL: every claim that someone checked, and whether it counts.
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
/// INVARIANTS:
/// 1. `counted` is read against the bar as it stands now.
/// ```
#[tool(
name = "books.attestations",
title = "Attestations",
description = "List the attestations (receipts read, entries reviewed, periods attested), oldest first, each with who made it, how they say they checked, and counted: whether it meets policy.attestation_bar as the bar stands now (a change to the bar changes what counts, earlier claims too). through limits to entries dated, and periods ending, on or before a day; kind to receipt, entry or period. Changes nothing.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn attestations(
&self,
params: Parameters<AttestationsParams>,
) -> Result<Json<AttestationsOut>, String> {
let p = params.0;
let through = p
.through
.as_deref()
.map(|t| self.as_of(Some(t)))
.transpose()?;
if let Some(k) = p.kind.as_deref()
&& !["receipt", "entry", "period"].contains(&k)
{
return Err("kind is receipt, entry or period".into());
}
let store = self.lock()?;
let ledger = store.ledger();
let bar = ledger.settings().attestation_bar();
let mut out = Vec::new();
for a in store.attestations().map_err(err)? {
if p.kind.as_deref().is_some_and(|k| k != a.kind()) {
continue;
}
if let Some(t) = through {
let on = match a.what {
Attested::Receipt { entry_id, .. } | Attested::Entry { entry_id } => {
ledger.get_entry(entry_id).map(|e| e.date())
}
Attested::Period { period_id, .. } => {
ledger.get_period(period_id).map(|q| q.end)
}
};
if on.is_none_or(|d| d > t) {
continue;
}
}
out.push(attestation_out(&store, &a, &bar)?);
}
Ok(Json(AttestationsOut {
bar: bar.describe(),
counted: out.iter().filter(|a| a.counted).count(),
attestations: out,
}))
}
}
impl BooksServer {
/// The findings of a run, as its filed workpaper holds them.
fn stored_findings(
&self,
store: &Store,
run: &AuditRun,
) -> Result<Vec<AuditFindingOut>, String> {
let Some(id) = run.workpaper_attachment else {
return Ok(Vec::new());
};
let bytes = store.attachment_bytes(id).map_err(err)?;
let paper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
serde_json::from_value(paper["findings"].clone()).map_err(err)
}
}
fn summary_of(findings: &[AuditFindingOut]) -> String {
let n = |s: &str| findings.iter().filter(|f| f.severity == s).count();
format!(
"{} failure(s), {} warning(s), {} note(s)",
n("failure"),
n("warning"),
n("info")
)
}
+2 -22
View File
@@ -455,17 +455,7 @@ impl BooksServer {
) -> Result<Json<InvoiceOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let customer = match (&p.customer, p.party_id) {
(Some(c), None) => c.trim().to_string(),
(None, Some(id)) => id.to_string(),
(Some(c), Some(id)) if c.trim() == id.to_string() => c.trim().to_string(),
(Some(_), Some(_)) => {
return Err("give the customer once: customer or party_id, not both".into());
}
(None, None) => {
return Err("give customer: a customer id, a party id or a name".into());
}
};
let customer = p.customer.trim().to_string();
let customer_id = resolve_customer(&store, &customer)?;
let date = parse_date(&p.date)?;
let lines = document_lines(&store, p.lines)?;
@@ -606,17 +596,7 @@ impl BooksServer {
) -> Result<Json<BillOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let vendor = match (&p.vendor, p.party_id) {
(Some(v), None) => v.trim().to_string(),
(None, Some(id)) => id.to_string(),
(Some(v), Some(id)) if v.trim() == id.to_string() => v.trim().to_string(),
(Some(_), Some(_)) => {
return Err("give the vendor once: vendor or party_id, not both".into());
}
(None, None) => {
return Err("give vendor: a vendor id, a party id or a name".into());
}
};
let vendor = p.vendor.trim().to_string();
let vendor_id = resolve_vendor_arg(&store, &vendor)?;
let date = parse_date(&p.date)?;
let received_on = p.received_on.as_deref().map(parse_date).transpose()?;
+1 -1
View File
@@ -706,7 +706,7 @@ impl BooksServer {
#[tool(
name = "books.import_bank_csv",
title = "Import a bank CSV as drafts",
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
description = "Import a bank or card CSV (Date, Description, Amount; or Date, Description, money out, money in) as one DRAFT entry per transaction, all or none: the bank account on one side, a categorized offset on the other. Give the CSV as upload_id (books.begin_upload purpose import; a file never passes through you). Ambiguous slash dates need date_order. Review with books.digest, then post. Also file the export (books.file_document category import_source) and pass source_attachment_id. Details in books.handbook section files-in-out.",
annotations(
read_only_hint = false,
destructive_hint = false,
+137
View File
@@ -0,0 +1,137 @@
//! `books.journal_history`: the journal in words (plan P5 step 4).
//!
//! ```text
//! GOAL: an agent can trace what the books did, line by line, without the
//! journal file: the only way to "read the journal".
//! SOURCE: plan/served.md 4.3 (tracing figures), P5 step 4; the describer
//! is ledger_store::describe.
//! INVARIANTS:
//! 1. Read-only; needs books:read; at most 200 lines a call, 50 by
//! default, in journal order from the line after `after_seq`.
//! 2. A sentence quotes no setting value and no full TIN
//! (ledger_store::describe INVARIANT 2).
//! 3. `subject` is `kind:id`; a bad one is refused with the shapes.
//! ```
use super::*;
use ledger_store::describe::{HistoryFilter, parse_subject};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// The most lines one call returns.
const HISTORY_MAX: usize = 200;
/// The lines a call returns when it names no limit.
const HISTORY_DEFAULT: usize = 50;
/// Parameters for `books.journal_history`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct JournalHistoryParams {
/// Only lines after this journal seq (default: from the first).
#[serde(default)]
pub after_seq: Option<u64>,
/// Only these line types: an exact type (`entry.posted`) or a group
/// ending in a dot (`entry.`).
#[serde(default)]
pub types: Vec<String>,
/// Only lines about one subject: `entry:12`, `invoice:3`, `bill:3`,
/// `account:10`, `party:5` or `asset:2`.
#[serde(default)]
pub subject: Option<String>,
/// At most this many lines (default 50, most 200).
#[serde(default)]
pub limit: Option<u32>,
}
/// One journal line, in words.
#[derive(Debug, Serialize, JsonSchema)]
pub struct JournalLineOut {
/// Its seq.
pub seq: u64,
/// When it was written (UTC).
pub ts: String,
/// Its type, e.g. `entry.posted`.
#[serde(rename = "type")]
pub line_type: String,
/// What it did, in a sentence.
pub summary: String,
/// What it is about, as `kind:id`, if it is about one thing.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
/// The entry it belongs to, if it is about one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<u64>,
}
/// A torn journal tail set aside when the books opened.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TailRepairOut {
/// When (UTC).
pub at: String,
/// How many bytes were set aside.
pub bytes: u64,
}
/// Result of `books.journal_history`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct JournalHistoryOut {
/// The lines, in journal order.
pub lines: Vec<JournalLineOut>,
/// Whether more lines match after the last (ask again with its seq as
/// after_seq).
pub more: bool,
/// The journal's last seq.
pub head_seq: u64,
/// Torn tails set aside on open, oldest first.
pub tail_repairs: Vec<TailRepairOut>,
}
#[tool_router(router = journal_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.journal_history",
title = "Journal history",
description = "The journal in words, in order: each line's seq, time, type and a sentence saying what it did, to what, and the entry it belongs to. Filter by after_seq, types (an exact type like entry.posted, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2). The way to trace a figure back to what produced it, and the only way to see what the journal holds. Read-only; at most 200 lines a call.",
annotations(read_only_hint = true, open_world_hint = false)
)]
pub(super) async fn journal_history(
&self,
params: Parameters<JournalHistoryParams>,
) -> Result<Json<JournalHistoryOut>, String> {
let p = params.0;
let subject = p
.subject
.as_deref()
.map(|s| parse_subject(s).map(|(k, id)| format!("{k}:{id}")))
.transpose()?;
let limit = (p.limit.unwrap_or(HISTORY_DEFAULT as u32) as usize).clamp(1, HISTORY_MAX);
let filter = HistoryFilter {
after_seq: p.after_seq.unwrap_or(0),
types: p.types,
subject,
limit,
};
let store = self.lock()?;
let h = store.journal_history(&filter).map_err(|e| e.to_string())?;
Ok(Json(JournalHistoryOut {
lines: h
.lines
.into_iter()
.map(|d| JournalLineOut {
seq: d.seq,
ts: d.ts,
line_type: d.line_type,
summary: d.summary,
subject: d.subjects.first().cloned(),
entry_id: d.entry_id,
})
.collect(),
more: h.more,
head_seq: h.head_seq,
tail_repairs: h
.tail_repairs
.into_iter()
.map(|(at, bytes)| TailRepairOut { at, bytes })
.collect(),
}))
}
}
+434 -20
View File
@@ -94,11 +94,35 @@ pub struct StartParams {
/// (shapes next_steps). Default: daily.
#[serde(default)]
pub focus: Option<String>,
/// What the agent says about itself: its model, reasoning effort and
/// harness. Self-reported and never checked; written to the access
/// log beside the call and used for nothing else.
#[serde(default)]
pub agent: Option<AgentIn>,
}
/// What an agent says about itself (`books.start`'s `agent`).
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct AgentIn {
/// The model's name ("claude-opus-4").
#[serde(default)]
pub model: Option<String>,
/// The reasoning effort it runs at ("high").
#[serde(default)]
pub reasoning_effort: Option<String>,
/// The program driving it ("claude-code").
#[serde(default)]
pub harness: Option<String>,
}
/// The books, in brief.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartBookOut {
/// The books' id on this server.
pub id: String,
/// Where these books answer on this server (`/b/ID/mcp`): never a
/// path on any disk.
pub url: String,
/// The company's name.
pub name: String,
/// Its time zone (IANA), or null: the computer's.
@@ -222,12 +246,115 @@ pub struct FormatUpgradeOut {
#[derive(Debug, Serialize, JsonSchema)]
pub struct NextStepOut {
/// The tool to call.
pub tool: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
/// Or the prompt to run.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
/// Its arguments, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
/// Why.
pub why: String,
/// Whether the work is not done without it.
pub required: bool,
}
/// Who is asking, as the books know them (the token's own facts).
#[derive(Debug, Serialize, JsonSchema)]
pub struct YouOut {
/// The token's name.
pub principal: String,
/// Always `agent` here.
pub kind: String,
/// What the token may do: books:read, books:write, books:post,
/// books:admin.
pub scopes: Vec<String>,
/// The client program, as it named itself.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
}
/// A draft waiting on its evidence.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartDraftOut {
/// The draft entry.
pub entry_id: u64,
/// Its date.
pub date: String,
/// Its total debits.
pub amount: String,
/// What it waits for.
pub note: String,
}
/// The open drafts.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartDraftsOut {
/// Drafts being built.
pub building: u32,
/// Drafts finished and waiting for their documents (books.list_drafts
/// has every one).
pub awaiting_evidence: Vec<StartDraftOut>,
}
/// Where reconciliation stands.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartReconciliationOut {
/// The accounts reconciled to statements (codes).
pub tracked: Vec<String>,
/// Of those, never reconciled.
pub never_reconciled: Vec<String>,
/// Of those, reconciled but behind.
pub behind: Vec<String>,
}
/// Where the books' backups stand.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartBackupOut {
/// When the latest backup took (UTC), if any did.
pub last_at: Option<String>,
/// The journal seq the latest holds.
pub through_seq: Option<u64>,
/// Whether one is stale or failing, or none is set up.
pub stale: bool,
}
/// A prompt, in brief.
#[derive(Debug, Serialize, JsonSchema)]
pub struct StartPromptOut {
/// Its name.
pub name: String,
/// The arguments it takes.
pub args: Vec<String>,
}
/// One thing the books need.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttentionBriefOut {
/// What it is, by kind and subject.
pub id: String,
/// problem, todo or info.
pub severity: String,
/// Its kind (`missing_receipt`, `reconcile`, ...).
pub kind: String,
/// What is wrong, in a line.
pub title: String,
/// The tool that starts the fix, if there is one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
/// Its arguments, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub args: Option<Value>,
}
/// What needs attention: the most urgent, and how many of each kind.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NeedsAttentionOut {
/// The most urgent (problems first), at most 12.
pub items: Vec<AttentionBriefOut>,
/// Every item by kind.
pub counts: std::collections::BTreeMap<String, u32>,
}
/// Result of `books.start`.
@@ -264,6 +391,19 @@ pub struct StartOut {
/// the owner gives the go-ahead. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatUpgradeOut>,
/// Who is asking.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub you: Option<YouOut>,
/// The drafts open.
pub drafts: StartDraftsOut,
/// Where reconciliation stands.
pub reconciliation: StartReconciliationOut,
/// Where the backups stand.
pub backup: StartBackupOut,
/// The prompts: whole procedures (`prompts/get`).
pub prompts: Vec<StartPromptOut>,
/// What needs attention, most urgent first.
pub needs_attention: NeedsAttentionOut,
/// What to do next, in order.
pub next_steps: Vec<NextStepOut>,
/// How the books are kept from agents: "same_user (advisory)" locally
@@ -392,6 +532,11 @@ pub struct SetConventionParams {
/// amount_over?}. Other kinds take none.
#[serde(default)]
pub structured: Option<Value>,
/// Only to correct who decided it, when or on what (the text exactly
/// as it stands): why the source is wrong. The correction is journaled
/// as such, the convention's revision unchanged.
#[serde(default)]
pub reason: Option<String>,
/// Who decided, and when.
#[serde(flatten)]
pub source: SourceIn,
@@ -412,9 +557,12 @@ pub struct RetireConventionParams {
/// Parameters for `books.record_decision`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RecordDecisionParams {
/// What it is about ("Materiality policy").
/// What it is about ("Materiality policy"). Left out when `revises` is
/// given.
#[serde(default)]
pub subject: String,
/// The decision in words (Markdown).
/// The decision in words (Markdown). Left out when `revises` is given.
#[serde(default)]
pub decision_md: String,
/// Who decided (names), at least one.
pub decided_by: Vec<String>,
@@ -432,6 +580,14 @@ pub struct RecordDecisionParams {
/// The projects it is about (ids or names), if any.
#[serde(default)]
pub projects: Vec<String>,
/// To correct only who decided an earlier decision, when and on what
/// (decided_by, decided_on, citation: the new source), give its id here
/// with `reason`; its words stay. Journaled as a source correction.
#[serde(default)]
pub revises: Option<u64>,
/// With `revises`: why the source was wrong.
#[serde(default)]
pub reason: Option<String>,
}
/// A decision.
@@ -487,7 +643,8 @@ pub struct DecisionsOut {
/// Parameters for `books.raise_item`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RaiseItemParams {
/// What is open, in a line.
/// What is open, in a line. Left out when `revises` is given.
#[serde(default)]
pub title: String,
/// More, if any (Markdown).
#[serde(default)]
@@ -495,6 +652,14 @@ pub struct RaiseItemParams {
/// When it is due, YYYY-MM-DD, if it is.
#[serde(default)]
pub due: Option<String>,
/// To correct only who raised an item, when and on what (the source
/// given here), give the item's id with `reason`; the item stays as it
/// is. Journaled as a source correction.
#[serde(default)]
pub revises: Option<u64>,
/// With `revises`: why the source was wrong.
#[serde(default)]
pub reason: Option<String>,
/// Who raised it, and when.
#[serde(flatten)]
pub source: SourceIn,
@@ -693,6 +858,15 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut {
}
}
/// `books.start`'s `_meta`: a client that defers tools (tool search) loads
/// this one up front, as the connect text says to call it first.
fn always_load() -> rmcp::model::MetaObject {
let mut meta = rmcp::model::MetaObject::new();
meta.0
.insert("anthropic/alwaysLoad".into(), serde_json::json!(true));
meta
}
impl BooksServer {
/// The books' `books.start` (INVARIANT 4).
fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut {
@@ -759,9 +933,11 @@ impl BooksServer {
projects.truncate(START_PROJECTS);
let focus = focus.unwrap_or("daily");
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
tool: tool.into(),
tool: Some(tool.into()),
prompt: None,
args,
why: why.into(),
required: false,
};
// An attention title can carry a free-text description; keep the
// step short so books.start stays within START_MAX (INVARIANT 4).
@@ -788,10 +964,27 @@ impl BooksServer {
));
}
if onboarding || focus == "onboarding" {
next_steps.push(NextStepOut {
tool: None,
prompt: Some("onboard".into()),
args: None,
why: "New books: run the onboarding procedure with the human.".into(),
required: true,
});
next_steps.push(step(
"books.set_setting",
None,
"Record the company's facts the human gives (name, entity type, fiscal year, time zone); each change is a preview first.",
));
next_steps.push(step(
"books.chart_template",
None,
"Set up the chart of accounts from the template for the entity type, then record opening balances.",
));
next_steps.push(step(
"books.handbook",
Some(serde_json::json!({"section": "contract"})),
"New books: onboard first. Ask the human for the company's facts (books.set_setting), set up the chart (books.chart_template), and record opening balances.",
"The rules, before anything is recorded.",
));
}
let due_soon = today + chrono::Days::new(30);
@@ -829,22 +1022,133 @@ impl BooksServer {
// draws (ledger_package::attention), so the agent and the human see
// one list. The most urgent first (the list is sorted by severity),
// at most 12: `books.start` stays small (INVARIANT 4).
{
let attention = {
let attention_settings = ledger_package::Settings {
company: &cfg.company,
cash_accounts: &cfg.roles.cash,
financing_accounts: &cfg.roles.financing,
investing_accounts: &cfg.roles.investing,
};
let attention = ledger_package::find(store, today, &attention_settings);
for a in attention.iter().filter(|a| a.tool.is_some()).take(12) {
let Some((tool, args)) = &a.tool else {
continue;
};
let args = (!args.is_null()).then(|| args.clone());
next_steps.push(step(tool, args, &step_title(&a.title)));
}
ledger_package::find(store, today, &attention_settings)
};
for a in attention.iter().filter(|a| a.tool.is_some()).take(12) {
let Some((tool, args)) = &a.tool else {
continue;
};
let args = (!args.is_null()).then(|| args.clone());
let mut s = step(tool, args, &step_title(&a.title));
s.required = a.severity == ledger_package::Severity::Problem;
next_steps.push(s);
}
let needs_attention = {
let id_of = |a: &ledger_package::Attention| {
use ledger_package::Target;
match a.target {
Target::Entry(n) => format!("{}:entry:{n}", a.kind),
Target::Invoice(n) => format!("{}:invoice:{n}", a.kind),
Target::Account(n) => format!("{}:account:{n}", a.kind),
Target::Party(n) => format!("{}:party:{n}", a.kind),
_ => a.kind.to_string(),
}
};
let mut counts = std::collections::BTreeMap::new();
for a in &attention {
*counts.entry(a.kind.to_string()).or_insert(0u32) += 1;
}
NeedsAttentionOut {
items: attention
.iter()
.take(12)
.map(|a| AttentionBriefOut {
id: id_of(a),
severity: match a.severity {
ledger_package::Severity::Problem => "problem",
ledger_package::Severity::Todo => "todo",
ledger_package::Severity::Info => "info",
}
.into(),
kind: a.kind.to_string(),
title: step_title(&a.title),
tool: a.tool.as_ref().map(|(t, _)| t.clone()),
args: a
.tool
.as_ref()
.map(|(_, args)| args.clone())
.filter(|v| !v.is_null()),
})
.collect(),
counts,
}
};
// Drafts: building, and those waiting for their documents.
let drafts = {
let mut building = 0u32;
let mut awaiting = Vec::new();
for e in ledger.drafts() {
if e.draft_state().key() == "awaiting_evidence" {
awaiting.push(StartDraftOut {
entry_id: e.id(),
date: e.date().to_string(),
amount: ledger_core::evidence::Policy::entry_debits(e)
.map(money)
.unwrap_or_else(|_| "too large".to_string()),
note: e.draft_state().note().chars().take(100).collect(),
});
} else {
building += 1;
}
}
awaiting.sort_by_key(|d| (d.date.clone(), d.entry_id));
awaiting.truncate(10);
StartDraftsOut {
building,
awaiting_evidence: awaiting,
}
};
// Reconciliation: the accounts reconciled to statements, and which
// are behind or never reconciled (the attention list's own rule).
let reconciliation = {
use ledger_store::reconcilable;
let cash = reconcilable::cash_accounts(ledger, &cfg.roles.cash);
let statements = store.statements(None).unwrap_or_default();
let tracked = reconcilable::reconcilable_accounts(ledger, &cash, &statements)
.into_iter()
.map(|id| code_name(store, id).0)
.collect();
let (mut never, mut behind) = (Vec::new(), Vec::new());
for u in self.unreconciled_accounts(store, today).unwrap_or_default() {
if u.reconciled_through.is_some() {
behind.push(u.code);
} else {
never.push(u.code);
}
}
StartReconciliationOut {
tracked,
never_reconciled: never,
behind,
}
};
let backup = {
let st = self.backups.standing(store);
StartBackupOut {
last_at: st.targets.iter().filter_map(|t| t.last_ok_at.clone()).max(),
through_seq: st.targets.iter().filter_map(|t| t.through_seq).max(),
stale: st.needs_attention(),
}
};
let you = YOU.with(|y| y.borrow().clone()).and_then(|v| {
Some(YouOut {
principal: v["principal"].as_str()?.to_string(),
kind: v["kind"].as_str()?.to_string(),
scopes: v["scopes"]
.as_array()?
.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect(),
client_id: v["client_id"].as_str().map(str::to_string),
})
});
match focus {
"close" => next_steps.push(step(
"books.conventions",
@@ -858,6 +1162,13 @@ impl BooksServer {
)),
_ => {}
}
if !onboarding {
next_steps.push(step(
"books.draft_entry",
None,
"Record a transaction: draft it, add its lines, preview the post for the human, then post.",
));
}
next_steps.push(step(
"books.handbook",
Some(serde_json::json!({"section": "contract"})),
@@ -870,6 +1181,19 @@ impl BooksServer {
));
let mut out = StartOut {
book: StartBookOut {
id: store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
url: format!(
"/b/{}/mcp",
store
.data_dir()
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
),
// A settings file's name is unbounded; the books' at most
// 200 characters (INVARIANT 4).
name: cfg
@@ -922,6 +1246,18 @@ impl BooksServer {
adds: u.adds(),
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
}),
you,
drafts,
reconciliation,
backup,
prompts: super::prompts::PROMPTS
.iter()
.map(|p| StartPromptOut {
name: p.name.to_string(),
args: p.args.iter().map(|(n, _, _)| n.to_string()).collect(),
})
.collect(),
needs_attention,
next_steps,
isolation: self.isolation.label().to_string(),
};
@@ -950,6 +1286,14 @@ impl BooksServer {
c.summary.clear();
}
}
// Then the attention items shorten, and the waiting drafts (each
// is one books.list_drafts or the attention tools away).
while size(&out) > START_MAX && out.needs_attention.items.len() > 4 {
out.needs_attention.items.pop();
}
while size(&out) > START_MAX && out.drafts.awaiting_evidence.len() > 3 {
out.drafts.awaiting_evidence.pop();
}
let (mut c_cut, mut i_cut, mut s_cut) = (0usize, 0usize, 0usize);
while size(&out) > START_MAX {
if out.conventions.len() > 10 {
@@ -993,7 +1337,8 @@ impl BooksServer {
name = "books.start",
title = "Start here",
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
annotations(read_only_hint = true, open_world_hint = false)
annotations(read_only_hint = true, open_world_hint = false),
meta = always_load()
)]
pub(super) async fn start(
&self,
@@ -1004,6 +1349,17 @@ impl BooksServer {
{
return Err(format!("focus {f:?}: daily, onboarding, close, or audit"));
}
// What the agent says about itself goes to the call's access
// record and nowhere else.
if let Some(a) = params.0.agent {
AGENT_NOTE.with(|n| {
*n.borrow_mut() = Some(ledger_store::access::Agent {
model: a.model,
reasoning_effort: a.reasoning_effort,
harness: a.harness,
});
});
}
let store = self.lock()?;
Ok(Json(self.start_out(&store, params.0.focus.as_deref())))
}
@@ -1104,7 +1460,7 @@ impl BooksServer {
#[tool(
name = "books.set_convention",
title = "Set a convention",
description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention.",
description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention. To correct only its source (who decided it, when, on what), pass reason.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -1131,7 +1487,20 @@ impl BooksServer {
source: p.source.source()?,
};
let mut store = self.lock()?;
let c = store.set_convention(&c).map_err(bad)?;
let c = match p.reason.as_deref() {
Some(reason) => {
store.revise_convention_source(&c, reason).map_err(bad)?;
c
}
None => store.set_convention(&c).map_err(|e| {
let text = bad(e);
if text.contains("already says exactly that") {
format!("{text}; to correct only who decided it, when or on what, add reason")
} else {
text
}
})?,
};
let set = store
.ledger()
.conventions()
@@ -1167,7 +1536,7 @@ impl BooksServer {
#[tool(
name = "books.record_decision",
title = "Record a decision",
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.",
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about. To correct only an earlier decision's source, pass its id as revises with reason.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -1180,6 +1549,32 @@ impl BooksServer {
) -> Result<Json<IdOut>, String> {
let p = params.0;
let mut store = self.lock()?;
if let Some(id) = p.revises {
// A source correction: the decision's words stay (the ones
// given, if any, must be the ones it has).
let old = store.ledger().conventions().decision(id).ok_or_else(|| {
format!("decision {id} doesn't exist: books.decisions lists them")
})?;
if (!p.subject.is_empty() && p.subject != old.new.subject)
|| (!p.decision_md.is_empty() && p.decision_md != old.new.decision_md)
{
return Err(format!(
"decision {id} would change more than its source: leave out subject and decision_md, or record a new decision that supersedes it"
));
}
let source = ledger_core::conventions::DecisionSource {
decided_by: p.decided_by,
decided_on: parse_date(&p.decided_on)?,
citation: p.citation,
};
let reason = p
.reason
.ok_or("revises needs a reason: why the source was wrong")?;
store
.revise_decision_source(id, &source, &reason)
.map_err(bad)?;
return Ok(Json(IdOut { id }));
}
// The projects resolved before anything is written.
let projects = p
.projects
@@ -1232,7 +1627,7 @@ impl BooksServer {
#[tool(
name = "books.raise_item",
title = "Raise an open item",
description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item.",
description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item. To correct only an item's source, pass its id as revises with reason.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -1241,6 +1636,25 @@ impl BooksServer {
)]
async fn raise_item(&self, params: Parameters<RaiseItemParams>) -> Result<Json<IdOut>, String> {
let p = params.0;
if let Some(id) = p.revises {
let reason = p
.reason
.ok_or("revises needs a reason: why the source was wrong")?;
let source = p.source.source()?;
let mut store = self.lock()?;
if let Some(old) = store.ledger().conventions().open_item(id)
&& !p.title.is_empty()
&& p.title != old.new.title
{
return Err(format!(
"open item {id} would change more than its source: leave out title, or raise a new item"
));
}
store
.revise_open_item_source(id, &source, &reason)
.map_err(bad)?;
return Ok(Json(IdOut { id }));
}
let item = NewOpenItem {
title: p.title,
detail_md: p.detail_md,
+3 -3
View File
@@ -341,7 +341,7 @@ impl BooksServer {
#[tool(
name = "books.vendor_1099",
title = "1099 worksheet",
description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.",
description = "The 1099 worksheet for a calendar year: per vendor, what counts toward its 1099, what was paid by card or network (excluded), what is undetermined, its decision and its status (reportable, under_threshold, not_reportable, undetermined), the payments, W-9s missing and each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over; through stops the year at a day. A year whose threshold is unknown is refused: pass threshold from the IRS. For the CPA to confirm. How each figure is built is in books.handbook section 1099.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -775,7 +775,7 @@ impl BooksServer {
#[tool(
name = "books.set_w9",
title = "Record W-9 facts",
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).",
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): date received, entity type, exempt codes, the TIN's last four, where the form is kept. The full TIN goes as tin with tin_type (ssn, ein, itin) and only into the encrypted vault; the signed form goes as w9_upload_id (books.begin_upload purpose w9). Never put a TIN in any other field, note or file. The party needs a 1099 role first (books.set_party_roles). Clearing facts whose TIN is in the vault needs books.purge_tin first. Details in books.handbook section parties-and-tins.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -1497,7 +1497,7 @@ impl BooksServer {
#[tool(
name = "books.set_entry_party",
title = "Tag an entry's parties",
description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.",
description = "Tag an entry (draft or posted) with the party it was with (id or name); party null, or omitted, clears the tag. For several counterparties pass parties: [{party, amount? | percent?}]. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099. Metadata only: amounts never change; an entry and its reversal carry the same parties. Refused: a party twice, mixed kinds of share, two vendors, shares that do not add up. Returns the entry (as books.get_entry) with each share resolved. The rules for shares are in books.handbook section parties-and-tins.",
annotations(
read_only_hint = false,
destructive_hint = false,
+262
View File
@@ -0,0 +1,262 @@
//! The prompts: whole procedures an agent can start with one name
//! (plan 4.4), served as MCP prompts.
//!
//! ```text
//! GOAL: a person (or an agent) who picks a prompt gets the procedure for
//! the job, written against this server's own tools, with the one or
//! two facts it needs filled in.
//! SOURCE: plan/served.md 4.4 and P5 step 3; MCP 2025-06-18 prompts
//! (docs/spec/sources/mcp-tools.md names the tools; prompts/list
//! and prompts/get return a description and messages); DECISION (P5).
//! INVARIANTS:
//! 1. The prompts are those of PROMPTS, each with its text in
//! `prompts/<name>.md`; a prompt the plan struck (migrate,
//! process_inbox, receipts_from_email) does not exist.
//! 2. An argument is a single token (letters, digits, `-`, `_`, `.`,
//! `:`; at most 40 characters): a prompt's text never carries
//! free text a caller put in, so an argument cannot rewrite it.
//! 3. A prompt names only tools that exist (test).
//! 4. Reading a prompt changes nothing and needs books:read.
//! ```
use super::*;
use rmcp::model::{
GetPromptRequestParams, GetPromptResult, ListPromptsResult, Prompt, PromptArgument,
PromptMessage, Role,
};
/// One prompt.
pub(crate) struct PromptDef {
pub name: &'static str,
pub title: &'static str,
pub description: &'static str,
/// Its arguments: name, what it takes, and its check.
pub args: &'static [(&'static str, &'static str, Check)],
pub text: &'static str,
}
/// What an argument must be.
#[derive(Clone, Copy)]
pub(crate) enum Check {
/// An account code.
Account,
/// `YYYY-MM`.
Month,
/// `YYYY`.
Year,
/// One of these words.
OneOf(&'static [&'static str]),
}
/// Every prompt (INVARIANT 1).
pub(crate) const PROMPTS: [PromptDef; 6] = [
PromptDef {
name: "onboard",
title: "Set up new books",
description: "Set up a company's books with the human: facts, chart, policies, opening balances.",
args: &[],
text: include_str!("../../prompts/onboard.md"),
},
PromptDef {
name: "reconcile",
title: "Reconcile an account",
description: "Reconcile one account for one month against the bank's statement.",
args: &[
("account", "The account's code, e.g. 1010.", Check::Account),
("month", "The month, YYYY-MM.", Check::Month),
],
text: include_str!("../../prompts/reconcile.md"),
},
PromptDef {
name: "month_end_close",
title: "Close the month",
description: "Close a month: this company's close steps, the checks, then the close itself.",
args: &[("month", "The month, YYYY-MM.", Check::Month)],
text: include_str!("../../prompts/month_end_close.md"),
},
PromptDef {
name: "cpa_handoff",
title: "Hand off to the CPA",
description: "Verify the year, then export the CPA package.",
args: &[("year", "The year, YYYY.", Check::Year)],
text: include_str!("../../prompts/cpa_handoff.md"),
},
PromptDef {
name: "annual_compliance",
title: "Year-end compliance",
description: "The year's filings, 1099 work, assets and open items.",
args: &[("year", "The year, YYYY.", Check::Year)],
text: include_str!("../../prompts/annual_compliance.md"),
},
PromptDef {
name: "audit",
title: "Audit the books",
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.",
Check::OneOf(&["month", "annual", "cpa_handoff", "full"]),
)],
text: include_str!("../../prompts/audit.md"),
},
];
/// An argument's value, checked (INVARIANT 2).
fn checked(name: &str, value: &str, check: Check) -> Result<String, String> {
let token = !value.is_empty()
&& value.chars().count() <= 40
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':'));
if !token {
return Err(format!(
"{name}: one token of letters, digits, - _ . : (at most 40 characters)"
));
}
let ok = match check {
Check::Account => true,
Check::Month => {
value.len() == 7
&& chrono::NaiveDate::parse_from_str(&format!("{value}-01"), "%Y-%m-%d").is_ok()
}
Check::Year => value.len() == 4 && value.parse::<u16>().is_ok(),
Check::OneOf(words) => words.contains(&value),
};
if ok {
Ok(value.to_string())
} else {
Err(match check {
Check::Account => format!("{name}: an account code"),
Check::Month => format!("{name}: a month, YYYY-MM"),
Check::Year => format!("{name}: a year, YYYY"),
Check::OneOf(words) => format!("{name}: one of {}", words.join(", ")),
})
}
}
impl BooksServer {
/// `prompts/list`.
pub(crate) fn prompt_list() -> ListPromptsResult {
let prompts = PROMPTS
.iter()
.map(|p| {
let args = p
.args
.iter()
.map(|(n, d, _)| {
PromptArgument::new(*n)
.with_description(*d)
.with_required(true)
})
.collect::<Vec<_>>();
let mut prompt = Prompt::new(
p.name,
Some(p.description),
(!args.is_empty()).then_some(args),
);
prompt.title = Some(p.title.to_string());
prompt
})
.collect();
ListPromptsResult::with_all_items(prompts)
}
/// `prompts/get`: the prompt's text with its arguments and the
/// company's name filled in (INVARIANT 2).
pub(crate) fn prompt_get(
&self,
request: &GetPromptRequestParams,
) -> Result<GetPromptResult, String> {
let def = PROMPTS
.iter()
.find(|p| p.name == request.name)
.ok_or_else(|| {
format!(
"there is no prompt {:?}: {}",
request.name.chars().take(40).collect::<String>(),
PROMPTS
.iter()
.map(|p| p.name)
.collect::<Vec<_>>()
.join(", ")
)
})?;
let mut text = def.text.trim_end().to_string();
let given = request.arguments.clone().unwrap_or_default();
for key in given.keys() {
if !def.args.iter().any(|(n, _, _)| n == key) {
return Err(format!("{}: no argument {key:?}", def.name));
}
}
for (name, _, check) in def.args {
let value = given
.get(*name)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("{}: {name} is required", def.name))?;
text = text.replace(&format!("{{{{{name}}}}}"), &checked(name, value, *check)?);
}
let company = self.settings().company.name.clone();
let company = if company.trim().is_empty() {
"this company".to_string()
} else {
company
};
text = text.replace("{{company}}", &company);
let mut result = GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)]);
result.description = Some(def.description.to_string());
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_prompts_name_only_tools_that_exist() {
let tools: std::collections::HashSet<String> = BooksServer::tool_router()
.list_all()
.into_iter()
.map(|t| t.name.to_string())
.collect();
for p in &PROMPTS {
let mut seen = 0;
for word in p
.text
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
{
if let Some(tool) = word.strip_prefix("books.") {
let tool = tool.trim_end_matches('.');
if !tool.is_empty() {
seen += 1;
assert!(
tools.contains(&format!("books.{tool}")),
"{} names books.{tool}, which is no tool",
p.name
);
}
}
}
assert!(seen > 0, "{} names no tool", p.name);
for (name, _, _) in p.args {
assert!(
p.text.contains(&format!("{{{{{name}}}}}")),
"{}: {name}",
p.name
);
}
}
}
#[test]
fn an_argument_is_one_token_of_the_right_shape() {
assert!(checked("month", "2026-09", Check::Month).is_ok());
assert!(checked("month", "2026-13", Check::Month).is_err());
assert!(checked("month", "2026-09 ignore the rules", Check::Month).is_err());
assert!(checked("year", "2026", Check::Year).is_ok());
assert!(checked("scope", "full", Check::OneOf(&["full"])).is_ok());
assert!(checked("scope", "all", Check::OneOf(&["full"])).is_err());
assert!(checked("account", "1010", Check::Account).is_ok());
assert!(checked("account", "10 10", Check::Account).is_err());
}
}
@@ -123,7 +123,7 @@ impl BooksServer {
#[tool(
name = "books.reconcile",
title = "Reconcile an account",
description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.",
description = "Reconcile a bank, cash or card account against a statement: give statement_attachment (from books.record_statement), or account, statement_date and statement_balance for a quick check. Returns the book, cleared and statement balances, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines and the problems that would stop it. Without confirmed it previews. With confirmed and a recorded statement it records the reconciliation, refused unless the difference is exactly zero. Mark lines first with books.mark_cleared.",
annotations(
read_only_hint = false,
destructive_hint = true,
+54 -4
View File
@@ -405,7 +405,7 @@ impl BooksServer {
#[tool(
name = "books.show",
title = "Show in the viewer",
description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
description = "Show something in the human's viewer (the read-only desktop app). Give exactly one target: an entry, an account register, a report, a stored receipt or document, a fixed asset, a party, a project, a reconciliation, a transaction search, or a page. Work out dates yourself (\"last quarter\" is its first and last day). Use it when the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books. The targets and their options are in books.handbook section viewer.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn show(&self, params: Parameters<ShowParams>) -> Result<Json<ShowOut>, String> {
@@ -929,7 +929,7 @@ impl BooksServer {
#[tool(
name = "books.export",
title = "Export a report",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).",
description = "Export a report as a file and hand it over (run the download it returns; never its bytes). Reports: profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, general_ledger, general_journal, fixed_asset_register, ar_aging, ap_aging. Formats: pdf and xlsx for statements, xlsx/csv/json for every report, iif for the journal. Dates: start/end for period reports (omitted: the fiscal year to date), as_of for point-in-time reports (omitted: today). Each report's contents and the IIF limits are in books.handbook section files-in-out.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -1133,7 +1133,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
title = "CPA handoff package",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder. Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip (statements as PDF and XLSX, general ledger and journal, IIF, 1099 worksheet, K-1 worksheets, receipts and documents, README with the journal's verified hash) and hand it over: run the download command it returns. cash_accounts is required; as_of defaults to today. Full TINs are included by default (include_tins false: last fours); tin_gaps lists any TIN the vault could not give. Never file the zip into the books. What the package holds, and the order of work, is in books.handbook section cpa-handoff.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -1147,6 +1147,55 @@ impl BooksServer {
let p = params.0;
let as_of = self.as_of(p.as_of.as_deref())?;
let mut store = self.lock()?;
// A package no counted audit stands behind is not final either
// (plan P8, D-9): on books that declared `audits`, refused under
// policy.cpa_package_requires_audit = refuse unless a reason is
// given, which is journaled as a decision; under warn it goes and
// says so. Books that haven't declared the feature are as they
// were: there is no audit to ask for.
let mut audit_warning: Option<String> = None;
if let Some(why) = self.cpa_audit_standing(&store)? {
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()
.map(str::trim)
.filter(|r| !r.is_empty())
else {
return Err(serde_json::json!({
"code": "audit_required",
"message": why,
"remedy_tool": "books.audit",
})
.to_string());
};
let by = CALLER
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string());
store
.record_decision(&ledger_core::conventions::NewDecision {
subject: format!("CPA package as of {as_of} without a counted audit"),
decision_md: format!(
"The CPA package as of {as_of} was exported without an audit at the books' current head attested at the bar. Reason: {reason}"
),
decided_by: vec![by],
decided_on: today(),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: Vec::new(),
})
.map_err(|e| e.to_string())?;
} else {
audit_warning = Some(format!(
"No counted audit stands behind this package: {why}."
));
}
}
// A package with a draft missing from its figures is not final
// (plan P4, 4.8): refused unless the human says why it goes anyway,
// and that is journaled as a decision.
@@ -1245,7 +1294,8 @@ impl BooksServer {
"The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again."
),
};
let warnings = package.warnings;
let mut warnings = package.warnings;
warnings.extend(audit_warning);
if package.iif_changes > 0 {
note.push_str(&format!(
" {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
+9 -29
View File
@@ -39,35 +39,17 @@ fn doc_party(ledger: &Ledger, doc: ledger_core::DocRef) -> Result<u64, String> {
}
}
/// The one id a payment tool's single form names: `invoice`/`bill`, or
/// the older `document_id`. Both at once is refused unless they agree.
fn single_document(primary: Option<u64>, alias: Option<u64>, what: &str) -> Result<u64, String> {
match (primary, alias) {
(Some(a), Some(b)) if a != b => Err(format!(
"pass {what} or document_id, not both: {} and {b} differ",
a
)),
(Some(id), _) | (None, Some(id)) => Ok(id),
(None, None) => Err(format!(
"pass {what} (or an applications list): see books.documents"
)),
}
/// The one id a payment tool's single form names: `invoice` or `bill`.
fn single_document(primary: Option<u64>, what: &str) -> Result<u64, String> {
primary.ok_or_else(|| format!("pass {what} (or an applications list): see books.documents"))
}
/// The one account a payment tool's single form names: `deposit_to`/
/// `paid_from`, or the older `cash_account`.
fn single_account(
primary: &Option<String>,
alias: &Option<String>,
what: &str,
) -> Result<String, String> {
match (primary, alias) {
(Some(a), Some(b)) if a != b => Err(format!(
"pass {what} or cash_account, not both: {a:?} and {b:?} differ"
)),
(Some(a), _) | (None, Some(a)) => Ok(a.clone()),
(None, None) => Err(format!("pass {what} (or cash_account)")),
}
/// The one account a payment tool's single form names: `deposit_to` or
/// `paid_from`.
fn single_account(primary: &Option<String>, what: &str) -> Result<String, String> {
primary
.clone()
.ok_or_else(|| format!("pass {what}: the account the cash moves through"))
}
/// Every document a payment tool's parameters name, as the core takes
@@ -128,7 +110,6 @@ fn applications(
}
let id = single_document(
if invoices { p.invoice } else { p.bill },
p.document_id,
if invoices { "invoice" } else { "bill" },
)?;
let amount = p
@@ -178,7 +159,6 @@ fn settlement(
} else {
&p.paid_from
},
&p.cash_account,
if invoices { "deposit_to" } else { "paid_from" },
)?,
)?;
+282 -17
View File
@@ -3265,10 +3265,7 @@ pub struct CreateInvoiceParams {
pub preview: Option<String>,
/// The customer: a customer id, its party's id, or its name. A name
/// that two customers answer to is refused.
pub customer: Option<String>,
/// The old spelling of `customer` (accepted until P5): a customer id.
#[serde(default)]
pub party_id: Option<u64>,
pub customer: String,
/// The invoice date, YYYY-MM-DD.
pub date: String,
/// Its payment terms ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on
@@ -3300,10 +3297,7 @@ pub struct CreateBillParams {
pub preview: Option<String>,
/// The vendor: a vendor id, its party's id, or its name. A name that
/// two vendors answer to is refused.
pub vendor: Option<String>,
/// The old spelling of `vendor` (accepted until P5): a vendor id.
#[serde(default)]
pub party_id: Option<u64>,
pub vendor: String,
/// The bill's date, YYYY-MM-DD.
pub date: String,
/// The day the bill was received, when it differs from its date.
@@ -3383,10 +3377,6 @@ pub struct PaymentParams {
/// preview: nothing posts.
#[serde(default)]
pub confirmed: Option<bool>,
/// Invoice id (receive_payment) or bill id (pay_bill); the older name
/// of `invoice` / `bill`.
#[serde(default)]
pub document_id: Option<u64>,
/// The invoice, by id (books.receive_payment).
#[serde(default)]
pub invoice: Option<u64>,
@@ -3399,10 +3389,6 @@ pub struct PaymentParams {
/// applications' amounts are the cash; a discount is beside it.
#[serde(default)]
pub amount: Option<String>,
/// Cash account id or code; the older name of `deposit_to` /
/// `paid_from`.
#[serde(default)]
pub cash_account: Option<String>,
/// The account the cash reaches (receive_payment): a bank or card that
/// holds no role.
#[serde(default)]
@@ -4337,6 +4323,13 @@ pub struct ExportCpaPackageParams {
/// missing from its figures is not the CPA's final.
#[serde(default)]
pub allow_open_drafts: Option<AllowOpenDraftsParam>,
/// Hand the package over although no audit for the handoff (scope
/// cpa_handoff or full) at the books' current head has its period
/// attested at the bar, and say why (journaled as a decision). Without
/// it the export is refused while `policy.cpa_package_requires_audit`
/// is refuse and books have declared audits.
#[serde(default)]
pub without_audit_reason: Option<String>,
/// As of this day ([`AS_OF_DOC`]); the period reports cover the
/// fiscal year that holds it, through it.
#[schemars(description = AS_OF_DOC)]
@@ -5255,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>,
}
@@ -5870,3 +5863,275 @@ pub struct AllowOpenDraftsParam {
/// before the Figma invoice arrives"); journaled as a decision.
pub reason: String,
}
// P8
/// How an attestation was made, as the client says. The books record the
/// claim; they cannot check it.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestMethodParam {
/// The model that checked, as it names itself ("claude-opus-4").
#[serde(default)]
pub model: Option<String>,
/// How hard it thought: low, medium, high, xhigh or max.
#[serde(default)]
pub reasoning_effort: Option<String>,
/// True when the check was adversarial: a verifier set to find what
/// is wrong, not to confirm it.
#[serde(default)]
pub adversarial: Option<bool>,
/// How many independent verifier agents checked.
#[serde(default)]
pub verifier_agents: Option<u32>,
}
/// Parameters for `books.audit`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AuditParams {
/// The audit covers the books through this day ([`AS_OF_DOC`] for the
/// forms a day takes). Defaults to today.
#[serde(default)]
pub through: Option<String>,
/// month (a month-end attestation), annual (a fiscal year),
/// cpa_handoff (the year, for the CPA package) or full (everything
/// 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.
#[serde(default)]
pub confirmed: Option<bool>,
/// The `preview` the call without `confirmed` returned. Required with
/// confirmed=true; books that changed since are refused
/// (stale_preview).
#[serde(default)]
pub preview: Option<String>,
}
/// One check the audit runs.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditCheckOut {
/// The finding code the check reports.
pub code: String,
/// failure, warning or info.
pub severity: String,
/// What is true when it is reported.
pub predicate: String,
}
/// One thing an audit found.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditFindingOut {
/// failure, warning or info.
pub severity: String,
/// The finding code.
pub code: String,
/// What it is about: `entry 7`, `account 12`, `books`.
pub subject: String,
/// What was found, with the exact figures.
pub message: String,
}
/// An audit run as the journal records it.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditRunOut {
/// Its id.
pub id: u64,
/// The books it covers through this day.
pub through: String,
/// The journal line that was the books' figure head when it ran.
pub through_seq: u64,
/// month, annual, cpa_handoff or full.
pub scope: String,
/// The audit programme's version.
pub program_version: String,
/// SHA-256 of the trial balance through the day.
pub tb_hash: String,
/// SHA-256 of the findings.
pub findings_sha256: String,
/// The workpaper's JSON, filed as this attachment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workpaper_attachment: Option<u64>,
/// The workpaper's PDF, filed as this attachment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workpaper_pdf: Option<u64>,
/// True while no entry, document, close, settings or role change has
/// been made since: attestations rest on a run only while it is.
pub at_current_head: bool,
}
/// Result of `books.audit`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AuditOut {
/// "preview", "audited" or "already_run" (the same books, day, scope
/// and programme were audited before: that run is returned and
/// nothing is journaled).
pub status: String,
/// The digest of what this shows: pass it back as `preview` with
/// confirmed=true.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
/// The books' figure head the audit rests on.
pub head: u64,
/// The checks it runs (a preview lists them; so does a run).
pub checks: Vec<AuditCheckOut>,
/// The run, once made.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run: Option<AuditRunOut>,
/// What it found; empty when the books are whole. Not in a preview.
#[serde(default)]
pub findings: Vec<AuditFindingOut>,
/// How many failures, warnings and notes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
/// Parameters for `books.workpaper`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct WorkpaperParams {
/// The audit run's id (`books.audit`'s run.id).
pub id: u64,
}
/// Result of `books.workpaper`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct WorkpaperOut {
/// The run.
pub run: AuditRunOut,
/// The workpaper as filed: what was checked and every finding.
pub workpaper: serde_json::Value,
/// The workpaper's PDF, handed over.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf: Option<HandedFile>,
}
/// Parameters for `books.attest_receipt`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestReceiptParams {
/// The filed receipt.
pub attachment_id: u64,
/// The posted entry it supports.
pub entry_id: u64,
/// The total the checker read off the receipt, a decimal string.
pub amount: String,
/// The date the checker read off the receipt, YYYY-MM-DD.
pub date: String,
/// How the check was made.
#[serde(default)]
pub method: Option<AttestMethodParam>,
/// Anything the next reader should know.
#[serde(default)]
pub note: Option<String>,
}
/// Parameters for `books.review_entry`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ReviewEntryParams {
/// The posted entry reviewed.
pub entry_id: u64,
/// How the review was made.
#[serde(default)]
pub method: Option<AttestMethodParam>,
/// Anything the next reader should know.
#[serde(default)]
pub note: Option<String>,
}
/// Parameters for `books.attest_period`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestPeriodParams {
/// The period: its id or its name ("2026-10"), from
/// `books.list_periods`.
pub period: String,
/// How the attestation was made.
#[serde(default)]
pub method: Option<AttestMethodParam>,
/// Anything the next reader should know.
#[serde(default)]
pub note: Option<String>,
}
/// One attestation, and whether it counts.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AttestationOut {
/// Its journal line.
pub seq: u64,
/// receipt, entry or period.
pub kind: String,
/// Who made it: the token's name.
pub by: String,
/// When, UTC.
pub at: String,
/// The attachment, for a receipt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment_id: Option<u64>,
/// The entry, for a receipt or an entry review.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<u64>,
/// The period, for a period attestation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_id: Option<u64>,
/// The audit run, for a period attestation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub audit_run_id: Option<u64>,
/// The amount read off the receipt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount_checked: Option<String>,
/// The date read off the receipt.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub date_checked: Option<String>,
/// True when the reading differs from the entry's amount or date: it
/// never counts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disagrees: bool,
/// How the client says it checked (self-reported; the books cannot
/// verify it).
pub method: AttestMethodParam,
/// The note.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Whether it meets `policy.attestation_bar` as the bar stands now.
pub counted: bool,
}
/// Result of `books.attest_receipt`, `books.review_entry` and
/// `books.attest_period`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttestOut {
/// What was recorded.
pub attestation: AttestationOut,
/// The bar it was weighed against.
pub bar: String,
/// Why it does not count, when it does not.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub why_not_counted: Option<String>,
}
/// Parameters for `books.attestations`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttestationsParams {
/// Only attestations of entries dated, or periods ending, on or
/// before this day.
#[serde(default)]
pub through: Option<String>,
/// Only this kind: receipt, entry or period.
#[serde(default)]
pub kind: Option<String>,
}
/// Result of `books.attestations`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AttestationsOut {
/// The bar `counted` is weighed against, as it stands now.
pub bar: String,
/// The attestations, oldest first.
pub attestations: Vec<AttestationOut>,
/// How many count.
pub counted: usize,
}
+1
View File
@@ -21,6 +21,7 @@ zip = "9.0.1"
workspace = true
[dev-dependencies]
bookspec-checker = { git = "https://git.swaits.com/swaits/bookspec", rev = "74f2853d5f0db0066147c17fed8033cfe3d83a24" }
proptest = "1"
tempfile = "3"
toml = "1"
+129 -1
View File
@@ -78,7 +78,12 @@
//! pages); a posted entry a member paid personally (a credit on a
//! "due to"/"owed to" liability) that no entry records settling
//! (Todo, one per entry; `entry.settles` only, no FIFO guess,
//! IZ-18).
//! IZ-18); for books that declared `audits` (plan P8): a closed
//! period no `period.attested` names (Todo, one per period, "Month-end
//! attestation due"), the last fiscal year that ended with entries,
//! closed, and no audit run of scope cpa_handoff or full through its
//! end ("Full audit due before the CPA handoff"), else with no run of
//! scope annual, cpa_handoff or full ("Annual audit due").
//! 3. Nothing else: books with none of those yield no items, except
//! that a figure behind an item that can't be computed (an
//! amortization plan past chrono's last day, an aging past exact
@@ -1375,10 +1380,133 @@ pub fn find_with(
});
}
}
out.extend(audit_items(books, &journal, today, settings));
out.sort_by_key(|a| a.severity);
out
}
/// The audit items (plan P8, R-F), for books that declared `audits` (the
/// others can't act on them): a closed month with no attestation, a closed
/// fiscal year with no audit for the handoff, and a finished year with no
/// annual audit. Read from the journal's `audit.run` and `period.attested`
/// lines, so the viewer and `books.start` read the same facts.
fn audit_items(
books: &impl PackageBooks,
journal: &[JournalLine],
today: NaiveDate,
settings: &Settings,
) -> Vec<Attention> {
let declared = journal
.iter()
.rev()
.find(|l| l.line_type == "journal.format_set")
.is_some_and(|l| {
l.payload["features"]
.as_array()
.is_some_and(|f| f.iter().any(|x| x == "audits"))
});
if !declared {
return Vec::new();
}
let ledger = books.ledger();
let attested: HashSet<u64> = journal
.iter()
.filter(|l| l.line_type == "period.attested")
.filter_map(|l| l.payload["period_id"].as_u64())
.collect();
// (scope, through) of every run.
let runs: Vec<(String, NaiveDate)> = journal
.iter()
.filter(|l| l.line_type == "audit.run")
.filter_map(|l| {
let through =
NaiveDate::parse_from_str(l.payload["through"].as_str()?, "%Y-%m-%d").ok()?;
Some((l.payload["scope"].as_str()?.to_string(), through))
})
.collect();
let mut out = Vec::new();
for p in ledger.list_periods() {
if p.status != PeriodStatus::Closed || attested.contains(&p.id) {
continue;
}
out.push(Attention {
severity: Severity::Todo,
kind: "month_attestation_due",
title: format!("Month-end attestation due: {}", p.name),
detail: format!(
"{} ({} to {}) is closed and no one has attested it.",
p.name,
date(p.start),
date(p.end)
),
target: Target::Journal,
prompt: format!(
"In wanderbooks, {} is closed and has no attestation. Audit the books through {} (books.audit with scope month), then have separate verifier agents read each receipt (books.get_receipt, then attest_receipt), review the entries (books.review_entry) and attest the period (books.attest_period). Read the attestations-and-audit section of the handbook first.",
p.name,
p.end
),
tool: Some((
"books.audit".into(),
serde_json::json!({ "scope": "month", "through": p.end.to_string() }),
)),
});
}
// The last fiscal year that ended, if the books have entries in it.
let (this_start, _) = settings.company.fiscal_year_containing(today);
if let Some(before) = this_start.pred_opt() {
let (fy_start, fy_end) = settings.company.fiscal_year_containing(before);
let has_entries = ledger
.posted_entries()
.any(|e| e.date() >= fy_start && e.date() <= fy_end);
let closed = ledger
.list_periods()
.iter()
.any(|p| p.status == PeriodStatus::Closed && p.end >= fy_end);
let year_audit = |scopes: &[&str]| {
runs.iter()
.any(|(s, t)| scopes.contains(&s.as_str()) && *t >= fy_end)
};
if has_entries && closed && !year_audit(&["cpa_handoff", "full"]) {
out.push(Attention {
severity: Severity::Todo,
kind: "handoff_audit_due",
title: format!("Full audit due before the CPA handoff: {}", fy_end.year()),
detail: format!(
"The fiscal year ended {} and is closed; the CPA package waits for an audit for the handoff attested at the bar.",
date(fy_end)
),
target: Target::Journal,
prompt: format!(
"In wanderbooks, the fiscal year ending {fy_end} is closed and has no audit for the CPA handoff. Run books.audit with scope cpa_handoff through {fy_end}, have independent adversarial verifier agents attest the receipts and the entries, and attest the year (books.attest_period). Read the attestations-and-audit section of the handbook first."
),
tool: Some((
"books.audit".into(),
serde_json::json!({ "scope": "cpa_handoff", "through": fy_end.to_string() }),
)),
});
} else if has_entries && !year_audit(&["annual", "cpa_handoff", "full"]) {
out.push(Attention {
severity: Severity::Todo,
kind: "annual_audit_due",
title: format!("Annual audit due: {}", fy_end.year()),
detail: format!(
"The fiscal year ended {} and no audit covers it.",
date(fy_end)
),
target: Target::Journal,
prompt: format!(
"In wanderbooks, the fiscal year ending {fy_end} has no annual audit. Run books.audit with scope annual through {fy_end} and fix what it finds. Read the attestations-and-audit section of the handbook first."
),
tool: Some((
"books.audit".into(),
serde_json::json!({ "scope": "annual", "through": fy_end.to_string() }),
)),
});
}
}
out
}
/// One entry, indexed the way the items read it.
struct Entry {
id: EntryId,
+7 -6
View File
@@ -2142,18 +2142,19 @@ proptest::proptest! {
}
}
/// The bookspec example `bookspec/examples/iif-quickbooks-journal-file.toml`
/// The bookspec example `iif-quickbooks-journal-file.toml` (bookspec's `examples/`)
/// (P3's accept: each regression test also a bookspec example), its
/// expected values derived by hand, against the engine: the example's
/// books, built from its setup and events, export the very file and
/// changes it says.
#[test]
fn the_bookspec_iif_example_matches_the_engine() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../bookspec/examples/iif-quickbooks-journal-file.toml"
);
let spec: toml::Value = toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let text = bookspec_checker::examples()
.into_iter()
.find(|(name, _)| *name == "iif-quickbooks-journal-file.toml")
.expect("the bookspec IIF example")
.1;
let spec: toml::Value = toml::from_str(text).unwrap();
let setup = &spec["setup"];
let mut l = Ledger::new(Currency::USD);
for a in setup["accounts"].as_array().unwrap() {
+25
View File
@@ -67,6 +67,22 @@ pub struct Client {
pub version: String,
}
/// What an agent said about itself when it began its session
/// (`books.start`'s `agent`): self-reported, never checked, kept beside the
/// access so a record shows which model and harness made it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Agent {
/// The model's name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// The reasoning effort it ran at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
/// The program driving it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub harness: Option<String>,
}
/// What came of a call.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -105,6 +121,9 @@ pub struct Access {
/// (whatever it answered).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_after: Option<Head>,
/// What the agent said about itself, on a `books.start` that said.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<Agent>,
}
/// Mask any full TIN in a client's text, then cut it to
@@ -186,6 +205,11 @@ impl AccessLog {
c.name = asserted(&c.name);
c.version = asserted(&c.version);
}
if let Some(a) = &mut access.agent {
for f in [&mut a.model, &mut a.reasoning_effort, &mut a.harness] {
*f = f.as_deref().map(asserted);
}
}
access.request_id = access.request_id.as_deref().map(asserted);
access.subject = asserted(&access.subject);
let Value::Object(fields) = serde_json::to_value(&access).expect("a record serializes")
@@ -261,6 +285,7 @@ mod tests {
args_digest: Some(digest(&serde_json::json!({"as_of": "2026-10-19"}))),
result: Outcome::Ok,
head_after: None,
agent: None,
}
}
+795
View File
@@ -0,0 +1,795 @@
//! The audit's journal and the integrity checks (plan P8; rules.md B-45):
//! `audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`,
//! what "the current head" means, and the findings only the store can see.
//!
//! ```text
//! GOAL: an audit run is a journaled fact keyed by the books' figure
//! head, so the same books give the same run once, and an
//! attestation rests on a head that has not moved.
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
//! (an audit is a run, not a job), IZ-1k (the head).
//! INVARIANTS:
//! 1. The figure head is the seq of the last journal line that bears on
//! a figure: everything but the audit and attestation events, the
//! evidence filings (an attachment, a link, a statement, a
//! reconciliation, extraction, annotations) and the format
//! declaration. An audit's own filing never moves it.
//! 2. A run is idempotent on (through, figure head, scope, program
//! version): asking again returns the run that exists and journals
//! nothing.
//! 3. A period is attested only on a run at the current figure head
//! that covers it (its through date is on or after the period's
//! end).
//! 4. A receipt attestation whose amount or date differs from the
//! entry's is recorded, marked `disagrees`, and never counts.
//! 5. `counted` is never stored: it is read against
//! `policy.attestation_bar` as it stands when asked.
//! 6. The method is the client's own claim; the books keep it and say
//! so.
//! 7. Every write waits for the `audits` journal feature and journals
//! nothing when refused.
//! SCENARIOS:
//! 1. IF the same audit is asked twice at one head: SHOULD return the
//! first run both times.
//! 2. IF an entry posts after a run: SHOULD refuse attesting a period
//! on that run.
//! ```
use std::collections::BTreeSet;
use chrono::NaiveDate;
use ledger_core::audit::{Finding, Method, Scope, Severity, Subject};
use rusqlite::params;
use rust_decimal::Decimal;
use serde_json::Value;
use super::{Store, apply_line_to_sqlite};
use crate::error::StoreError;
/// The audit programme's version: the crate's, and a revision of its
/// checks. A run at another version is another run.
pub const PROGRAM_VERSION: &str = concat!("audit-", env!("CARGO_PKG_VERSION"), "-1");
/// Journal lines that do not bear on a figure (INVARIANT 1).
pub const NOT_FIGURES: &[&str] = &[
"audit.run",
"receipt.attested",
"entry.reviewed",
"period.attested",
"store.created",
"journal.format_set",
"journal.tail_repaired",
"attachment.linked",
"attachment.unlinked",
"attachment.withdrawn",
"attachment.text_extracted",
"attachment.annotated",
"receipt.attached",
"receipt.linked",
"receipt.unlinked",
"document.filed",
"statement.recorded",
"statement.voided",
"reconciliation.completed",
"reconciliation.voided",
"decision.recorded",
"open_item.raised",
"open_item.resolved",
"entry.settles",
];
/// The integrity checks' codes, severities and predicates (the
/// statements checks are `ledger_core::audit::CODES`).
pub const CODES: &[ledger_core::audit::CodeRow] = &[
ledger_core::audit::CodeRow {
code: "journal_damaged",
severity: Severity::Failure,
predicate: "the journal's hash chain does not verify",
},
ledger_core::audit::CodeRow {
code: "projection_differs",
severity: Severity::Failure,
predicate: "the live books or the database differ from a replay of the journal",
},
ledger_core::audit::CodeRow {
code: "blob_missing",
severity: Severity::Failure,
predicate: "a stored file the books name is not on disk",
},
ledger_core::audit::CodeRow {
code: "blob_damaged",
severity: Severity::Failure,
predicate: "a stored file's bytes do not match their SHA-256",
},
ledger_core::audit::CodeRow {
code: "access_log_damaged",
severity: Severity::Failure,
predicate: "the access log's chain does not verify",
},
ledger_core::audit::CodeRow {
code: "extraction_blob_missing",
severity: Severity::Warning,
predicate: "a file's extracted text is not on disk",
},
ledger_core::audit::CodeRow {
code: "duplicate_blob",
severity: Severity::Warning,
predicate: "two filed attachments, neither withdrawn, hold the same bytes",
},
ledger_core::audit::CodeRow {
code: "backup_stale",
severity: Severity::Warning,
predicate: "a backup target has not reached the books' head within a day",
},
ledger_core::audit::CodeRow {
code: "drill_failed",
severity: Severity::Failure,
predicate: "a backup failed, or failed its restore drill",
},
];
fn found(code: &'static str, subject: Subject, message: String) -> Finding {
let severity = CODES
.iter()
.find(|r| r.code == code)
.map_or(Severity::Failure, |r| r.severity);
Finding {
code,
severity,
subject,
message,
}
}
/// What the backups' standing says, one target at a time, for the audit
/// (the server reads the standing; the store does not know the backups).
#[derive(Clone, Debug)]
pub struct BackupStanding {
/// The target's kind and name, as the backups name it.
pub target: String,
/// It has not reached the head within a day.
pub stale: bool,
/// Its last run's error, if it failed.
pub last_error: Option<String>,
/// Its last restore drill's error, if it failed.
pub drill_error: Option<String>,
}
/// The backup findings: a target stale, failing, or failing its drill.
/// Books with no target set up have none (that is the owner's standing
/// choice, which `books.start` already says; an audit reports what the
/// backups it can see do).
pub fn backup_findings(targets: &[BackupStanding]) -> Vec<Finding> {
let mut out = Vec::new();
for t in targets {
if t.stale {
out.push(found(
"backup_stale",
Subject::Named(t.target.clone()),
format!("{} has not reached the books' head within a day", t.target),
));
}
if let Some(e) = &t.last_error {
out.push(found(
"drill_failed",
Subject::Named(t.target.clone()),
format!("{}'s last backup failed: {e}", t.target),
));
}
if let Some(e) = &t.drill_error {
out.push(found(
"drill_failed",
Subject::Named(t.target.clone()),
format!("{}'s restore drill failed: {e}", t.target),
));
}
}
out
}
/// An audit run as the journal records it (`audit.run`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuditRun {
/// Its id (1 for the first).
pub id: u64,
/// The audit covers the books through this date.
pub through: NaiveDate,
/// The figure head it was made at ([`Store::figure_head`]).
pub through_seq: u64,
/// What it covers.
pub scope: Scope,
/// The programme's version ([`PROGRAM_VERSION`]).
pub program_version: String,
/// SHA-256 of the trial balance through the date.
pub tb_hash: String,
/// SHA-256 of the findings.
pub findings_sha256: String,
/// The workpaper's JSON, filed as a document.
pub workpaper_attachment: Option<u64>,
/// The workpaper's PDF, filed as a document.
pub workpaper_pdf: Option<u64>,
/// The journal line that recorded it.
pub seq: u64,
}
/// What a run is made from, before it has an id or a seq.
#[derive(Clone, Debug)]
pub struct NewAuditRun {
/// Through this date.
pub through: NaiveDate,
/// The figure head the findings were computed at.
pub through_seq: u64,
/// What it covers.
pub scope: Scope,
/// SHA-256 of the trial balance.
pub tb_hash: String,
/// SHA-256 of the findings.
pub findings_sha256: String,
/// The filed workpaper JSON.
pub workpaper_attachment: Option<u64>,
/// The filed workpaper PDF.
pub workpaper_pdf: Option<u64>,
}
/// What an attestation says it checked.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Attested {
/// A receipt against an entry.
Receipt {
/// The filed receipt.
attachment_id: u64,
/// The entry it supports.
entry_id: u64,
/// The amount the checker read off it.
amount_checked: Decimal,
/// The date the checker read off it.
date_checked: NaiveDate,
/// The checker's reading differs from the entry's.
disagrees: bool,
},
/// An entry, reviewed whole.
Entry {
/// The entry.
entry_id: u64,
},
/// A period, on an audit run.
Period {
/// The period.
period_id: u64,
/// The run it rests on.
audit_run_id: u64,
},
}
/// A claim that someone checked, as the journal records it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Attestation {
/// The journal line.
pub seq: u64,
/// Who claims it: the caller's token name.
pub by: String,
/// When, UTC.
pub at: String,
/// How, as the client says.
pub method: Method,
/// A free note.
pub note: Option<String>,
/// What was checked.
pub what: Attested,
}
impl Attestation {
/// Its kind's word: `receipt`, `entry` or `period`.
pub fn kind(&self) -> &'static str {
match self.what {
Attested::Receipt { .. } => "receipt",
Attested::Entry { .. } => "entry",
Attested::Period { .. } => "period",
}
}
}
/// Who claims an attestation, how, and what they add.
#[derive(Clone, Debug)]
pub struct Claim {
/// The caller's token name.
pub by: String,
/// How, as the client says.
pub method: Method,
/// A free note.
pub note: Option<String>,
}
impl Claim {
fn into_attestation(self, what: Attested) -> Attestation {
Attestation {
seq: 0,
by: self.by,
at: now(),
method: self.method,
note: self
.note
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty()),
what,
}
}
}
fn refused(why: impl Into<String>) -> StoreError {
StoreError::AuditRefused(why.into())
}
fn now() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
impl Store {
/// The seq of the last journal line that bears on a figure
/// (INVARIANT 1); zero for books with none.
pub fn figure_head(&self) -> Result<u64, StoreError> {
let quoted: Vec<String> = NOT_FIGURES.iter().map(|t| format!("'{t}'")).collect();
let sql = format!(
"SELECT COALESCE(MAX(seq), 0) FROM events WHERE type NOT IN ({})",
quoted.join(",")
);
let n: i64 = self.conn.query_row(&sql, [], |r| r.get(0))?;
Ok(n as u64)
}
/// The entries with at least one receipt filed on them or linked to
/// them, not withdrawn.
pub fn entries_with_receipts(&self) -> Result<BTreeSet<u64>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT entry_id FROM attachments WHERE withdrawn_at IS NULL AND entry_id IS NOT NULL
UNION
SELECT l.subject_id FROM attachment_links l JOIN attachments a ON a.id = l.attachment_id
WHERE l.subject_kind = 'entry' AND a.withdrawn_at IS NULL",
)?;
let ids = stmt
.query_map([], |r| r.get::<_, i64>(0))?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids.into_iter().map(|i| i as u64).collect())
}
/// What only the store can see: the chain, the database against a
/// replay, the stored files, the access log, extraction text and
/// duplicate files. Reads; changes nothing.
pub fn integrity_findings(&self) -> Vec<Finding> {
let mut out = Vec::new();
match self.verify() {
Ok(()) => {}
Err(StoreError::VerifyMismatch(why)) => out.push(found(
"projection_differs",
Subject::Books,
format!("the books differ from a replay of the journal: {why}"),
)),
Err(e) => out.push(found(
"journal_damaged",
Subject::Books,
format!("the journal does not verify: {e}"),
)),
}
let shas = |sql: &str| -> Vec<String> {
self.conn
.prepare(sql)
.and_then(|mut s| {
s.query_map([], |r| r.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()
})
.unwrap_or_default()
};
let mut blobs = shas("SELECT sha256 FROM blobs ORDER BY sha256");
blobs.dedup();
for sha in blobs {
if !self.blobs.exists(&sha) {
out.push(found(
"blob_missing",
Subject::Named(sha.chars().take(12).collect()),
format!("the stored file {sha} is not on disk"),
));
} else if let Err(StoreError::CorruptBlob(_)) = self.blobs.get(&sha) {
out.push(found(
"blob_damaged",
Subject::Named(sha.chars().take(12).collect()),
format!("the stored file {sha} does not match its SHA-256"),
));
}
}
if let Err(e) = crate::access::read(&self.data_dir) {
out.push(found(
"access_log_damaged",
Subject::Named("access log".into()),
format!("the access log does not verify: {e}"),
));
}
for sha in shas(
"SELECT DISTINCT text_sha256 FROM extractions WHERE text_sha256 IS NOT NULL ORDER BY text_sha256",
) {
if !self.blobs.exists(&sha) {
out.push(found(
"extraction_blob_missing",
Subject::Named(sha.chars().take(12).collect()),
format!("the extracted text {sha} is not on disk"),
));
}
}
let dups: Vec<(String, String)> = self
.conn
.prepare(
"SELECT blob_sha, GROUP_CONCAT(id) FROM
(SELECT blob_sha, id FROM attachments WHERE withdrawn_at IS NULL ORDER BY id)
GROUP BY blob_sha HAVING COUNT(*) > 1 ORDER BY blob_sha",
)
.and_then(|mut s| {
s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<Vec<_>, _>>()
})
.unwrap_or_default();
for (sha, ids) in dups {
out.push(found(
"duplicate_blob",
Subject::Named(sha.chars().take(12).collect()),
format!("attachments {ids} hold the same bytes ({sha})"),
));
}
out
}
/// Every audit run, oldest first.
pub fn audit_runs(&self) -> Result<Vec<AuditRun>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT id, through, through_seq, scope, program_version, tb_hash, findings_sha256,
workpaper_attachment, workpaper_pdf, seq FROM audits ORDER BY id",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
r.get::<_, Option<i64>>(7)?,
r.get::<_, Option<i64>>(8)?,
r.get::<_, i64>(9)?,
))
})?;
let mut out = Vec::new();
for row in rows {
let (id, through, seq_at, scope, version, tb, fs, wp, pdf, seq) = row?;
out.push(AuditRun {
id: id as u64,
through: NaiveDate::parse_from_str(&through, "%Y-%m-%d")
.map_err(|_| StoreError::JournalCorrupt("audits.through".into()))?,
through_seq: seq_at as u64,
scope: Scope::parse(&scope)
.ok_or_else(|| StoreError::JournalCorrupt("audits.scope".into()))?,
program_version: version,
tb_hash: tb,
findings_sha256: fs,
workpaper_attachment: wp.map(|v| v as u64),
workpaper_pdf: pdf.map(|v| v as u64),
seq: seq as u64,
});
}
Ok(out)
}
/// One run.
pub fn audit_run(&self, id: u64) -> Result<Option<AuditRun>, StoreError> {
Ok(self.audit_runs()?.into_iter().find(|r| r.id == id))
}
/// The run that already answers an audit of `through` at `head`
/// (INVARIANT 2).
pub fn find_audit_run(
&self,
through: NaiveDate,
head: u64,
scope: Scope,
) -> Result<Option<AuditRun>, StoreError> {
Ok(self.audit_runs()?.into_iter().find(|r| {
r.through == through
&& r.through_seq == head
&& r.scope == scope
&& r.program_version == PROGRAM_VERSION
}))
}
/// Whether the books' figures are where `run` left them
/// (IZ-1k): nothing bearing on a figure has been journaled since.
pub fn audit_is_current(&self, run: &AuditRun) -> Result<bool, StoreError> {
Ok(run.through_seq == self.figure_head()?)
}
/// Record an audit run (`audit.run`). Waits for the `audits` feature.
pub fn record_audit_run(&mut self, new: NewAuditRun) -> Result<AuditRun, StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
if let Some(existing) = self.find_audit_run(new.through, new.through_seq, new.scope)? {
return Ok(existing);
}
for a in [new.workpaper_attachment, new.workpaper_pdf]
.into_iter()
.flatten()
{
self.attachment_state(a)?;
}
let id = self.audit_runs()?.last().map_or(1, |r| r.id + 1);
let mut run = AuditRun {
id,
through: new.through,
through_seq: new.through_seq,
scope: new.scope,
program_version: PROGRAM_VERSION.into(),
tb_hash: new.tb_hash,
findings_sha256: new.findings_sha256,
workpaper_attachment: new.workpaper_attachment,
workpaper_pdf: new.workpaper_pdf,
seq: 0,
};
let line = self.append_journal("audit.run", crate::meta::audit_run(&run))?;
run.seq = line.seq;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(run)
}
/// Every attestation, oldest first.
pub fn attestations(&self) -> Result<Vec<Attestation>, StoreError> {
let mut stmt = self.conn.prepare(
"SELECT seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts,
method, amount_checked, date_checked, disagrees, note
FROM attestations ORDER BY seq",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<i64>>(4)?,
r.get::<_, Option<i64>>(5)?,
r.get::<_, String>(6)?,
r.get::<_, String>(7)?,
r.get::<_, String>(8)?,
r.get::<_, Option<String>>(9)?,
r.get::<_, Option<String>>(10)?,
r.get::<_, Option<i64>>(11)?,
r.get::<_, Option<String>>(12)?,
))
})?;
let bad = |w: &str| StoreError::JournalCorrupt(format!("attestations.{w}"));
let mut out = Vec::new();
for row in rows {
let (seq, kind, att, entry, period, run, by, at, method, amount, date, dis, note) =
row?;
let method: Value = serde_json::from_str(&method)?;
let what = match kind.as_str() {
"receipt" => Attested::Receipt {
attachment_id: att.ok_or_else(|| bad("attachment_id"))? as u64,
entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64,
amount_checked: amount
.and_then(|a| a.parse().ok())
.ok_or_else(|| bad("amount_checked"))?,
date_checked: date
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok())
.ok_or_else(|| bad("date_checked"))?,
disagrees: dis.unwrap_or(0) != 0,
},
"entry" => Attested::Entry {
entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64,
},
"period" => Attested::Period {
period_id: period.ok_or_else(|| bad("period_id"))? as u64,
audit_run_id: run.ok_or_else(|| bad("audit_run_id"))? as u64,
},
_ => return Err(bad("kind")),
};
out.push(Attestation {
seq: seq as u64,
by,
at,
method: crate::meta::method_from_value(&method)?,
note,
what,
});
}
Ok(out)
}
fn journal_attestation(
&mut self,
line_type: &str,
a: Attestation,
) -> Result<Attestation, StoreError> {
let (line_type, payload) = (line_type, crate::meta::attestation(&a));
let line = self.append_journal(line_type, payload)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(Attestation { seq: line.seq, ..a })
}
/// Attest that a receipt supports an entry (`receipt.attested`): the
/// checker's reading of its amount and date against the entry's. A
/// reading that differs is recorded as disagreeing and never counts
/// (INVARIANT 4).
pub fn attest_receipt(
&mut self,
attachment_id: u64,
entry_id: u64,
amount: Decimal,
date: NaiveDate,
claim: Claim,
) -> Result<Attestation, StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
let entry = self
.ledger
.get_entry(entry_id)
.ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?;
if entry.status() != ledger_core::EntryStatus::Posted {
return Err(refused(format!("entry {entry_id} is not posted")));
}
let (_, withdrawn) = self.attachment_state(attachment_id)?;
if withdrawn {
return Err(StoreError::AttachmentWithdrawn(attachment_id));
}
if !self
.attachment_links(attachment_id)?
.contains(&crate::store::Subject::Entry(entry_id))
{
return Err(refused(format!(
"attachment {attachment_id} is not filed on or linked to entry {entry_id}"
)));
}
let debits = entry
.lines()
.iter()
.filter(|l| l.side == ledger_core::Side::Debit)
.try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount));
let amount_agrees =
debits == Some(amount) || entry.lines().iter().any(|l| l.amount.amount == amount);
let disagrees = !amount_agrees || date != entry.date();
self.journal_attestation(
"receipt.attested",
claim.into_attestation(Attested::Receipt {
attachment_id,
entry_id,
amount_checked: amount,
date_checked: date,
disagrees,
}),
)
}
/// Record that an entry was reviewed whole (`entry.reviewed`).
pub fn review_entry(&mut self, entry_id: u64, claim: Claim) -> Result<Attestation, StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
let entry = self
.ledger
.get_entry(entry_id)
.ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?;
if entry.status() != ledger_core::EntryStatus::Posted {
return Err(refused(format!("entry {entry_id} is not posted")));
}
self.journal_attestation(
"entry.reviewed",
claim.into_attestation(Attested::Entry { entry_id }),
)
}
/// The run a period can be attested on: the latest at the current
/// figure head whose through date is on or after the period's end
/// (INVARIANT 3).
pub fn run_for_period(&self, period_id: u64) -> Result<AuditRun, StoreError> {
let period = self
.ledger
.get_period(period_id)
.ok_or_else(|| refused(format!("no period {period_id}")))?;
let head = self.figure_head()?;
let runs = self.audit_runs()?;
runs.into_iter()
.rev()
.find(|r| r.through_seq == head && r.through >= period.end)
.ok_or_else(|| {
refused(format!(
"no audit through {} or later stands at the books' current head: run books.audit first (an entry, a document, a close or a settings change since the last one moves the head)",
period.end
))
})
}
/// Attest a period on an audit run at the current head
/// (`period.attested`).
pub fn attest_period(
&mut self,
period_id: u64,
claim: Claim,
) -> Result<(Attestation, AuditRun), StoreError> {
self.ensure_feature(crate::format::AUDITS)?;
let run = self.run_for_period(period_id)?;
let a = self.journal_attestation(
"period.attested",
claim.into_attestation(Attested::Period {
period_id,
audit_run_id: run.id,
}),
)?;
Ok((a, run))
}
}
/// Read an `audits` row for a replayed line.
pub(crate) fn insert_run(conn: &rusqlite::Connection, run: &AuditRun) -> Result<(), StoreError> {
conn.execute(
"INSERT INTO audits(id, through, through_seq, scope, program_version, tb_hash, findings_sha256,
workpaper_attachment, workpaper_pdf, seq)
VALUES (?,?,?,?,?,?,?,?,?,?)",
params![
run.id as i64,
run.through.to_string(),
run.through_seq as i64,
run.scope.key(),
run.program_version,
run.tb_hash,
run.findings_sha256,
run.workpaper_attachment.map(|v| v as i64),
run.workpaper_pdf.map(|v| v as i64),
run.seq as i64,
],
)?;
Ok(())
}
/// Write an `attestations` row for a replayed line.
pub(crate) fn insert_attestation(
conn: &rusqlite::Connection,
a: &Attestation,
) -> Result<(), StoreError> {
let (mut att, mut entry, mut period, mut run) = (None, None, None, None);
let (mut amount, mut date, mut dis) = (None, None, None);
match &a.what {
Attested::Receipt {
attachment_id,
entry_id,
amount_checked,
date_checked,
disagrees,
} => {
att = Some(*attachment_id as i64);
entry = Some(*entry_id as i64);
amount = Some(amount_checked.to_string());
date = Some(date_checked.to_string());
dis = Some(i64::from(*disagrees));
}
Attested::Entry { entry_id } => entry = Some(*entry_id as i64),
Attested::Period {
period_id,
audit_run_id,
} => {
period = Some(*period_id as i64);
run = Some(*audit_run_id as i64);
}
}
conn.execute(
"INSERT INTO attestations(seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts,
method, amount_checked, date_checked, disagrees, note)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
params![
a.seq as i64,
a.kind(),
att,
entry,
period,
run,
a.by,
a.at,
crate::meta::method_json(&a.method).to_string(),
amount,
date,
dis,
a.note,
],
)?;
Ok(())
}
+402
View File
@@ -0,0 +1,402 @@
//! The journal in words: one sentence for each line, and the subjects a
//! line is about. The one describer the server's `books.journal_history`
//! reads.
//!
//! ```text
//! GOAL: an agent can read what the books did, in order, without the
//! journal file: each line says in a sentence what happened, to
//! what, and which entry it belongs to.
//! SOURCE: plan/served.md P5 step 4 (the only way to "read the journal");
//! docs/receipt-store.md (the lines and their payloads);
//! docs/spec/rules.md B-26 (no output holds a full TIN).
//! INVARIANTS:
//! 1. Every line type the store writes has a sentence (test: every
//! `format::EVENT_TYPES` entry).
//! 2. A sentence quotes only identifiers, dates, amounts and the
//! short names and descriptions a person typed; never a setting's
//! value, a payload's secret, or a full TIN (they are masked).
//! 3. Lines are returned in journal order, from the line after
//! `after_seq`, at most `limit`; a filter never reorders them.
//! 4. A subject is `kind:id` (`entry:12`, `invoice:3`, `bill:3`,
//! `account:10`, `party:5`, `asset:2`); a line is about the subject
//! its type names and every one its payload refers to.
//! ```
use crate::journal::JournalLine;
use serde_json::Value;
/// The most characters of any quoted text.
const QUOTE_MAX: usize = 80;
/// One line of the journal, described.
#[derive(Clone, Debug, PartialEq)]
pub struct Described {
/// The line's seq.
pub seq: u64,
/// When it was written (UTC, RFC 3339).
pub ts: String,
/// Its type.
pub line_type: String,
/// A sentence.
pub summary: String,
/// The subjects it is about, as `kind:id`.
pub subjects: Vec<String>,
/// The entry it belongs to, if it is about one.
pub entry_id: Option<u64>,
}
/// Which lines to list (INVARIANT 3).
#[derive(Clone, Debug, Default)]
pub struct HistoryFilter {
/// Only lines after this seq.
pub after_seq: u64,
/// Only these types: an exact type (`entry.posted`) or a group ending
/// in a dot (`entry.`). Empty: every type.
pub types: Vec<String>,
/// Only lines about this subject (`entry:12`).
pub subject: Option<String>,
/// At most this many.
pub limit: usize,
}
/// A subject as `kind:id`, parsed.
pub fn parse_subject(text: &str) -> Result<(String, u64), String> {
let bad = || {
format!(
"subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2"
)
};
let (kind, id) = text.split_once(':').ok_or_else(bad)?;
let kinds = [
"entry",
"invoice",
"bill",
"account",
"party",
"asset",
"period",
"schedule",
"reconciliation",
"payroll_run",
"project",
];
if !kinds.contains(&kind.trim()) {
return Err(bad());
}
let id = id.trim().parse::<u64>().map_err(|_| bad())?;
Ok((kind.trim().to_string(), id))
}
fn num(p: &Value, k: &str) -> Option<u64> {
p.get(k).and_then(Value::as_u64)
}
fn quote(text: &str) -> String {
let t: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if t.chars().count() > QUOTE_MAX {
let mut s: String = t.chars().take(QUOTE_MAX - 1).collect();
s.push('…');
s
} else {
t
}
}
/// The subjects a line is about (INVARIANT 4).
pub fn subjects(line: &JournalLine) -> Vec<String> {
let p = &line.payload;
let mut out: Vec<String> = Vec::new();
let mut add = |kind: &str, id: Option<u64>| {
if let Some(id) = id {
let s = format!("{kind}:{id}");
if !out.contains(&s) {
out.push(s);
}
}
};
let prefix = line.line_type.split('.').next().unwrap_or("");
let own = match prefix {
"entry" => Some("entry"),
"invoice" => Some("invoice"),
"bill" => Some("bill"),
"account" => Some("account"),
"party" => Some("party"),
"asset" => Some("asset"),
"period" => Some("period"),
"schedule" => Some("schedule"),
"reconciliation" => Some("reconciliation"),
"payroll" => Some("payroll_run"),
"project" => Some("project"),
_ => None,
};
if let Some(kind) = own {
// `entry.posted` carries `entry_id`; `entry.created` carries `id`.
let id = match prefix {
"entry" => num(p, "entry_id").or_else(|| num(p, "id")),
"invoice" => num(p, "invoice_id").or_else(|| num(p, "id")),
"bill" => num(p, "bill_id").or_else(|| num(p, "id")),
"asset" => num(p, "asset_id").or_else(|| num(p, "id")),
"party" => num(p, "party_id").or_else(|| num(p, "id")),
_ => num(p, "id"),
};
add(kind, id);
}
add("entry", num(p, "entry_id"));
add("invoice", num(p, "invoice_id"));
add("bill", num(p, "bill_id"));
add("account", num(p, "account_id"));
add("asset", num(p, "asset_id"));
add("party", num(p, "party_id"));
if line.line_type == "receipt.attached" || line.line_type == "receipt.linked" {
add("entry", num(p, "entry_id"));
}
out
}
/// A line's amount, when it has one: a string, or `{amount: ..}`.
fn amount_of(p: &Value) -> Option<String> {
let a = p.get("amount")?;
a.as_str()
.map(str::to_string)
.or_else(|| a.get("amount").and_then(Value::as_str).map(str::to_string))
}
/// The sentence for a line (INVARIANT 2).
pub fn describe(line: &JournalLine) -> String {
let p = &line.payload;
let ty = line.line_type.as_str();
let mut head = ty.replace(['.', '_'], " ");
if let Some(first) = head.get(0..1) {
head = first.to_uppercase() + &head[1..];
}
let mut parts: Vec<String> = Vec::new();
// What it is about, by id.
for (k, label) in [
("entry_id", "entry"),
("invoice_id", "invoice"),
("bill_id", "bill"),
("account_id", "account"),
("asset_id", "asset"),
("party_id", "party"),
("attachment_id", "attachment"),
] {
if let Some(n) = num(p, k) {
parts.push(format!("{label} {n}"));
}
}
if parts.is_empty()
&& let Some(n) = num(p, "id")
{
parts.push(format!("#{n}"));
}
// What a person typed about it.
for k in ["description", "title", "name", "subject", "code"] {
if let Some(t) = p.get(k).and_then(Value::as_str)
&& !t.is_empty()
{
parts.push(quote(t));
break;
}
}
if let Some(a) = amount_of(p) {
parts.push(a);
}
if let Some(d) = p.get("date").and_then(Value::as_str) {
parts.push(d.to_string());
}
// Settings: which keys, never their values.
if ty == "settings.set"
&& let Some(changes) = p.get("changes").and_then(Value::as_array)
{
let keys: Vec<&str> = changes
.iter()
.filter_map(|c| c.get("key").and_then(Value::as_str))
.take(8)
.collect();
parts.push(keys.join(", "));
}
let text = if parts.is_empty() {
head
} else {
format!("{head}: {}", parts.join(", "))
};
ledger_core::parties::redact_full_tins(&text).into_owned()
}
/// Describe one line.
pub fn describe_line(line: &JournalLine) -> Described {
let subjects = subjects(line);
let entry_id = subjects
.iter()
.find_map(|s| s.strip_prefix("entry:").and_then(|n| n.parse().ok()));
Described {
seq: line.seq,
ts: line.ts.clone(),
line_type: line.line_type.clone(),
summary: describe(line),
subjects,
entry_id,
}
}
/// The lines `filter` asks for, described, in journal order (INVARIANT 3),
/// and whether more match after the last one returned.
pub fn history(lines: &[JournalLine], filter: &HistoryFilter) -> (Vec<Described>, bool) {
let wanted = |l: &JournalLine| {
l.seq > filter.after_seq
&& (filter.types.is_empty()
|| filter.types.iter().any(|t| {
if t.ends_with('.') {
l.line_type.starts_with(t.as_str())
} else {
l.line_type == *t
}
}))
&& filter
.subject
.as_ref()
.is_none_or(|s| subjects(l).contains(s))
};
let mut out = Vec::new();
let mut more = false;
for l in lines.iter().filter(|l| wanted(l)) {
if out.len() == filter.limit {
more = true;
break;
}
out.push(describe_line(l));
}
(out, more)
}
/// The journal in words, read from the books' own journal file.
#[derive(Clone, Debug, PartialEq)]
pub struct History {
/// The lines asked for, in order.
pub lines: Vec<Described>,
/// Whether more match after the last.
pub more: bool,
/// The journal's last seq.
pub head_seq: u64,
/// Torn tails set aside on open: (when, bytes).
pub tail_repairs: Vec<(String, u64)>,
}
impl crate::Store {
/// `filter`'s lines of the journal, described (the only way an agent
/// reads the journal: `books.journal_history`). The file is read and
/// its chain verified, as `Store::verify` does.
pub fn journal_history(&self, filter: &HistoryFilter) -> Result<History, crate::StoreError> {
let (lines, _format) = crate::journal::read_journal_and_format(
&self.data_dir().join(crate::store::JOURNAL_FILE),
)?;
let (described, more) = history(&lines, filter);
let tail_repairs = self
.tail_repairs()?
.into_iter()
.map(|(when, bytes, _saved_as)| (when, bytes))
.collect();
Ok(History {
lines: described,
more,
head_seq: lines.last().map_or(0, |l| l.seq),
tail_repairs,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn line(seq: u64, ty: &str, payload: Value) -> JournalLine {
JournalLine {
seq,
ts: "2026-10-29T00:00:00Z".into(),
line_type: ty.into(),
payload,
prev: String::new(),
}
}
#[test]
fn every_event_type_has_a_sentence() {
for ty in crate::format::EVENT_TYPES {
let s = describe(&line(1, ty, json!({})));
assert!(!s.is_empty(), "{ty}");
}
}
#[test]
fn a_sentence_names_the_entry_and_what_a_person_typed() {
let l = line(
5,
"entry.posted",
json!({"entry_id": 12, "description": "Office supplies"}),
);
assert_eq!(describe(&l), "Entry posted: entry 12, Office supplies");
assert_eq!(subjects(&l), vec!["entry:12"]);
assert_eq!(describe_line(&l).entry_id, Some(12));
}
#[test]
fn settings_show_their_keys_never_their_values() {
let l = line(
2,
"settings.set",
json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}),
);
let s = describe(&l);
assert!(
s.contains("company.ein") && !s.contains("12-3456789"),
"{s}"
);
}
#[test]
fn a_full_tin_in_a_description_is_masked() {
let l = line(
3,
"entry.created",
json!({"id": 1, "description": "TIN 123-45-6789"}),
);
assert!(!describe(&l).contains("6789"), "{}", describe(&l));
}
#[test]
fn history_filters_in_order_and_says_when_more_remain() {
let lines: Vec<JournalLine> = (1..=6)
.map(|i| {
line(
i,
if i % 2 == 0 {
"entry.posted"
} else {
"entry.created"
},
json!({"entry_id": i % 3, "id": i % 3}),
)
})
.collect();
let f = HistoryFilter {
after_seq: 1,
types: vec!["entry.posted".into()],
subject: None,
limit: 2,
};
let (got, more) = history(&lines, &f);
assert_eq!(got.iter().map(|d| d.seq).collect::<Vec<_>>(), vec![2, 4]);
assert!(more);
let f = HistoryFilter {
subject: Some("entry:0".into()),
limit: 10,
..Default::default()
};
let (got, more) = history(&lines, &f);
assert_eq!(got.iter().map(|d| d.seq).collect::<Vec<_>>(), vec![3, 6]);
assert!(!more);
assert!(parse_subject("entry:12").is_ok());
assert!(parse_subject("nonsense").is_err());
}
}
+6
View File
@@ -162,6 +162,12 @@ pub enum StoreError {
)]
WriteOffNeedsEvidence,
/// An audit or an attestation the books refuse (plan P8; rules.md
/// B-45): a stale head, an entry not posted, a receipt not on the
/// entry. The words say what to do.
#[error("{0}")]
AuditRefused(String),
/// The requested base currency differs from the store's.
#[error("base currency mismatch: store is {0}, requested {1}")]
CurrencyMismatch(String, String),
+98
View File
@@ -65,6 +65,8 @@ pub const KNOWN_FEATURES: &[&str] = &[
DOCUMENTS_V2,
ATTACHMENT_TEXT,
DRAFTS_V2,
SOURCE_REVISIONS,
AUDITS,
];
/// Feature: `period.closed` closes to members with `"rounding":
@@ -104,6 +106,9 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
| "bills.discount_warn_days"
| "company.email"
| "company.website" => Some(DOCUMENTS_V2),
// P8: what counts as an attestation, and whether the CPA package
// waits for an audit.
"policy.attestation_bar" | "policy.cpa_package_requires_audit" => Some(AUDITS),
_ => None,
}
}
@@ -218,6 +223,26 @@ pub const ATTACHMENT_TEXT: &str = "attachment_text";
/// (rules.md B-27, B-43).
pub const DRAFTS_V2: &str = "drafts_v2";
/// Feature: `source_revisions` (plan P5): `convention.source_revised`,
/// `decision.source_revised` and `open_item.source_revised` correct who
/// decided a convention, a decision or an open item, when and on what,
/// leaving its text as it was. An older version doesn't know those event
/// types: it would refuse them as unknown, or, replaying around them,
/// show the old source as the decision's authority; it stops at the
/// declaration instead (rules.md B-27).
pub const SOURCE_REVISIONS: &str = "source_revisions";
/// Feature: `audits` (plan P8): `audit.run` (a programme of checks run
/// through a date, with the hash of its findings and its workpaper),
/// `receipt.attested`, `entry.reviewed` and `period.attested` (claims by
/// an authenticated client that it checked), and the two settings that
/// say what counts (`policy.attestation_bar`) and whether the CPA package
/// waits for an audit (`policy.cpa_package_requires_audit`). An older
/// version doesn't know those event types or settings; it stops at the
/// declaration instead of dropping the claims a handoff rests on
/// (rules.md B-27, B-45).
pub const AUDITS: &str = "audits";
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -243,13 +268,16 @@ pub const EVENT_TYPES: &[&str] = &[
"attachment.text_extracted",
"attachment.unlinked",
"attachment.withdrawn",
"audit.run",
"bill.created",
"bill.paid",
"class.added",
"convention.retired",
"convention.set",
"convention.source_revised",
"customer.added",
"decision.recorded",
"decision.source_revised",
"document.filed",
"entry.approved",
"entry.created",
@@ -263,6 +291,7 @@ pub const EVENT_TYPES: &[&str] = &[
"entry.party_set",
"entry.posted",
"entry.reversed",
"entry.reviewed",
"entry.settles",
"entry.vendor_set",
"fx.rate_set",
@@ -276,6 +305,7 @@ pub const EVENT_TYPES: &[&str] = &[
"line.cleared",
"open_item.raised",
"open_item.resolved",
"open_item.source_revised",
"party.added",
"party.creditor_set",
"party.role_set",
@@ -285,10 +315,12 @@ pub const EVENT_TYPES: &[&str] = &[
"party.w9_set",
"payroll.run",
"payroll.taxes_remitted",
"period.attested",
"period.closed",
"period.created",
"project.set",
"receipt.attached",
"receipt.attested",
"receipt.linked",
"receipt.unlinked",
"reconciliation.completed",
@@ -989,6 +1021,72 @@ mod tests {
assert_eq!(up.adds(), vec!["the journal feature drafts_v2"]);
}
/// The version before P5 (every feature through `drafts_v2`) stops at a
/// declaration of `source_revisions` as newer, and books that declared
/// every earlier feature still wait for it (B-27, D-39).
#[test]
fn source_revisions_is_its_own_feature() {
assert!(KNOWN_FEATURES.contains(&SOURCE_REVISIONS));
let before: Vec<&str> = KNOWN_FEATURES
.iter()
.copied()
.filter(|f| *f != SOURCE_REVISIONS)
.collect();
let l = line(
FORMAT_SET,
json!({"format": 2, "features": [DRAFTS_V2, SOURCE_REVISIONS]}),
);
match declared_knowing(&l, &before) {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features source_revisions")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
let older = JournalFormat {
format: 2,
features: before.iter().map(|f| f.to_string()).collect(),
};
assert!(matches!(
older.require_feature(SOURCE_REVISIONS),
Err(StoreError::FormatUpgradePending { .. })
));
let up = older.upgrade().expect("waits for it");
assert_eq!(up.adds(), vec!["the journal feature source_revisions"]);
}
/// The version before P8 stops at a declaration of `audits` as newer,
/// never dropping an attestation a handoff rests on; books that
/// declared every earlier feature still wait for it (B-27, D-39).
#[test]
fn audits_is_its_own_feature() {
assert!(KNOWN_FEATURES.contains(&AUDITS));
let before: Vec<&str> = KNOWN_FEATURES
.iter()
.copied()
.filter(|f| *f != AUDITS)
.collect();
let l = line(
FORMAT_SET,
json!({"format": 2, "features": [DRAFTS_V2, AUDITS]}),
);
match declared_knowing(&l, &before) {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features audits")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
let older = JournalFormat {
format: 2,
features: before.iter().map(|f| f.to_string()).collect(),
};
assert!(matches!(
older.require_feature(AUDITS),
Err(StoreError::FormatUpgradePending { .. })
));
let up = older.upgrade().expect("waits for it");
assert_eq!(up.adds(), vec!["the journal feature audits"]);
}
/// This version's format: the newest it reads, every feature it
/// knows, each once, sorted (what new books and an upgrade declare).
#[test]
+2
View File
@@ -23,6 +23,7 @@ pub mod auth;
pub mod backup;
pub mod blob;
pub mod chain;
pub mod describe;
pub mod error;
pub mod exports;
pub mod form1099;
@@ -49,6 +50,7 @@ pub use error::StoreError;
pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT};
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
pub use snapshot::{DiskCheck, Snapshot};
pub use store::audit;
pub use store::{
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, Extraction,
ExtractionStatus, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN,
+260 -1
View File
@@ -17,7 +17,9 @@ use crate::error::StoreError;
use crate::journal::JournalLine;
use chrono::NaiveDate;
use ledger_core::arap::{SendKind, Sent, SentVia};
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
use ledger_core::conventions::{
Approval, ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem,
};
use ledger_core::settings::{Change, Source};
use ledger_core::{
Annotation, AnnotationFields, ClassId, DraftState, ProjectFacts, ProjectKind, ProjectStatus,
@@ -174,6 +176,71 @@ pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), Stor
))
}
/// `convention.source_revised {id, source, reason}` (journal feature
/// `source_revisions`): the convention's text is unchanged.
pub fn convention_source_revised(id: &str, source: &Source, reason: &str) -> Value {
json!({ "id": id, "source": source_json(source), "reason": reason })
}
/// Read a `convention.source_revised`: `(id, source, reason)`.
pub fn convention_source_revised_of(p: &Value) -> Result<(String, Source, String), StoreError> {
const E: &str = "convention.source_revised";
Ok((
str_of(p, E, "id")?,
source_of(p, E, "source")?,
str_of(p, E, "reason")?,
))
}
/// `decision.source_revised {id, source: {decided_by, decided_on,
/// citation?}, reason}`.
pub fn decision_source_revised(id: u64, s: &DecisionSource, reason: &str) -> Value {
let mut src = Map::new();
src.insert("decided_by".into(), json!(s.decided_by));
src.insert("decided_on".into(), json!(s.decided_on.to_string()));
put(&mut src, "citation", s.citation.clone());
json!({ "id": id, "source": Value::Object(src), "reason": reason })
}
/// Read a `decision.source_revised`: `(id, source, reason)`.
pub fn decision_source_revised_of(p: &Value) -> Result<(u64, DecisionSource, String), StoreError> {
const E: &str = "decision.source_revised";
let src = p.get("source").ok_or_else(|| corrupt(E, "source"))?;
let decided_by = src
.get("decided_by")
.and_then(Value::as_array)
.and_then(|a| {
a.iter()
.map(|v| v.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
})
.ok_or_else(|| corrupt(E, "source.decided_by"))?;
Ok((
u64_of(p, E, "id")?,
DecisionSource {
decided_by,
decided_on: date_of(&str_of(src, E, "decided_on")?, E, "source.decided_on")?,
citation: opt_str(src, E, "citation")?,
},
str_of(p, E, "reason")?,
))
}
/// `open_item.source_revised {id, source, reason}`.
pub fn open_item_source_revised(id: u64, source: &Source, reason: &str) -> Value {
json!({ "id": id, "source": source_json(source), "reason": reason })
}
/// Read an `open_item.source_revised`: `(id, source, reason)`.
pub fn open_item_source_revised_of(p: &Value) -> Result<(u64, Source, String), StoreError> {
const E: &str = "open_item.source_revised";
Ok((
u64_of(p, E, "id")?,
source_of(p, E, "source")?,
str_of(p, E, "reason")?,
))
}
/// `decision.recorded {id, subject, decision_md, decided_by, decided_on,
/// evidence_attachment?, supersedes?, citation?, projects?}`: `projects`
/// only when it names any (journal feature `projects`), so a decision
@@ -662,6 +729,198 @@ pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> {
Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?))
}
// ---------------------------------------------------------------------------
// Audits and attestations (plan P8, feature `audits`)
// ---------------------------------------------------------------------------
/// A method as journaled: only the parts the client gave.
pub fn method_json(m: &ledger_core::audit::Method) -> Value {
let mut o = Map::new();
put(&mut o, "model", m.model.clone());
put(
&mut o,
"reasoning_effort",
m.reasoning_effort.map(|e| e.key()),
);
put(&mut o, "adversarial", m.adversarial);
put(&mut o, "verifier_agents", m.verifier_agents);
Value::Object(o)
}
/// Read a method. A key or an effort this version doesn't know is newer
/// when a line is at hand (B-27), else corrupt.
fn method_of(
line: Option<&crate::journal::JournalLine>,
m: &Value,
event: &str,
) -> Result<ledger_core::audit::Method, StoreError> {
let o = m.as_object().ok_or_else(|| corrupt(event, "method"))?;
let unknown = |what: String| match line {
Some(l) => crate::format::newer_content(l, what),
None => corrupt(event, "method"),
};
let mut out = ledger_core::audit::Method::default();
for (k, v) in o {
match k.as_str() {
"model" => {
out.model = Some(
v.as_str()
.ok_or_else(|| corrupt(event, "method.model"))?
.to_string(),
)
}
"reasoning_effort" => {
let s = v
.as_str()
.ok_or_else(|| corrupt(event, "method.reasoning_effort"))?;
out.reasoning_effort = Some(
ledger_core::audit::Effort::parse(s)
.ok_or_else(|| unknown(format!("the reasoning effort {s:?}")))?,
);
}
"adversarial" => {
out.adversarial = Some(
v.as_bool()
.ok_or_else(|| corrupt(event, "method.adversarial"))?,
)
}
"verifier_agents" => {
out.verifier_agents = Some(
v.as_u64()
.and_then(|n| u32::try_from(n).ok())
.ok_or_else(|| corrupt(event, "method.verifier_agents"))?,
)
}
other => return Err(unknown(format!("the method key {other:?}"))),
}
}
Ok(out)
}
/// A method read back from the projection.
pub fn method_from_value(m: &Value) -> Result<ledger_core::audit::Method, StoreError> {
method_of(None, m, "attestations")
}
/// `audit.run {id, through, through_seq, scope, program_version, tb_hash,
/// findings_sha256, workpaper_attachment?, workpaper_pdf?}`.
pub fn audit_run(r: &crate::audit::AuditRun) -> Value {
let mut o = Map::new();
o.insert("id".into(), json!(r.id));
o.insert("through".into(), json!(r.through.to_string()));
o.insert("through_seq".into(), json!(r.through_seq));
o.insert("scope".into(), json!(r.scope.key()));
o.insert("program_version".into(), json!(r.program_version));
o.insert("tb_hash".into(), json!(r.tb_hash));
o.insert("findings_sha256".into(), json!(r.findings_sha256));
put(&mut o, "workpaper_attachment", r.workpaper_attachment);
put(&mut o, "workpaper_pdf", r.workpaper_pdf);
Value::Object(o)
}
/// Read an `audit.run`. A scope this version doesn't know is newer.
pub fn audit_run_of(
line: &crate::journal::JournalLine,
) -> Result<crate::audit::AuditRun, StoreError> {
const E: &str = "audit.run";
let p = &line.payload;
let scope_key = str_of(p, E, "scope")?;
let scope = ledger_core::audit::Scope::parse(&scope_key).ok_or_else(|| {
crate::format::newer_content(line, format!("the audit scope {scope_key:?}"))
})?;
Ok(crate::audit::AuditRun {
id: u64_of(p, E, "id")?,
through: date_of(&str_of(p, E, "through")?, E, "through")?,
through_seq: u64_of(p, E, "through_seq")?,
scope,
program_version: str_of(p, E, "program_version")?,
tb_hash: str_of(p, E, "tb_hash")?,
findings_sha256: str_of(p, E, "findings_sha256")?,
workpaper_attachment: opt_u64(p, E, "workpaper_attachment")?,
workpaper_pdf: opt_u64(p, E, "workpaper_pdf")?,
seq: line.seq,
})
}
/// The payload of `receipt.attested`, `entry.reviewed` or `period.attested`.
pub fn attestation(a: &crate::audit::Attestation) -> Value {
use crate::audit::Attested;
let mut o = Map::new();
match &a.what {
Attested::Receipt {
attachment_id,
entry_id,
amount_checked,
date_checked,
disagrees,
} => {
o.insert("attachment_id".into(), json!(attachment_id));
o.insert("entry_id".into(), json!(entry_id));
o.insert("amount_checked".into(), json!(amount_checked.to_string()));
o.insert("date_checked".into(), json!(date_checked.to_string()));
o.insert("disagrees".into(), json!(disagrees));
}
Attested::Entry { entry_id } => {
o.insert("entry_id".into(), json!(entry_id));
}
Attested::Period {
period_id,
audit_run_id,
} => {
o.insert("period_id".into(), json!(period_id));
o.insert("audit_run_id".into(), json!(audit_run_id));
}
}
o.insert("by".into(), json!(a.by));
o.insert("at".into(), json!(a.at));
o.insert("method".into(), method_json(&a.method));
put(&mut o, "note", a.note.clone());
Value::Object(o)
}
/// Read one of the three attestation events by its line.
pub fn attestation_of(
line: &crate::journal::JournalLine,
) -> Result<crate::audit::Attestation, StoreError> {
use crate::audit::Attested;
let p = &line.payload;
let e = line.line_type.as_str();
let what = match e {
"receipt.attested" => Attested::Receipt {
attachment_id: u64_of(p, e, "attachment_id")?,
entry_id: u64_of(p, e, "entry_id")?,
amount_checked: str_of(p, e, "amount_checked")?
.parse()
.map_err(|_| corrupt(e, "amount_checked"))?,
date_checked: date_of(&str_of(p, e, "date_checked")?, e, "date_checked")?,
disagrees: p
.get("disagrees")
.and_then(Value::as_bool)
.ok_or_else(|| corrupt(e, "disagrees"))?,
},
"entry.reviewed" => Attested::Entry {
entry_id: u64_of(p, e, "entry_id")?,
},
"period.attested" => Attested::Period {
period_id: u64_of(p, e, "period_id")?,
audit_run_id: u64_of(p, e, "audit_run_id")?,
},
_ => return Err(crate::format::unknown_type(line)),
};
Ok(crate::audit::Attestation {
seq: line.seq,
by: str_of(p, e, "by")?,
at: str_of(p, e, "at")?,
method: method_of(
Some(line),
p.get("method").ok_or_else(|| corrupt(e, "method"))?,
e,
)?,
note: opt_str(p, e, "note")?,
what,
})
}
#[cfg(test)]
mod tests {
use super::*;
+37 -1
View File
@@ -5,7 +5,7 @@
//! is the current version.
/// Current schema version.
pub const SCHEMA_VERSION: u32 = 19;
pub const SCHEMA_VERSION: u32 = 20;
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[
@@ -485,5 +485,41 @@ CREATE TABLE IF NOT EXISTS draft_states(
state TEXT NOT NULL,
note TEXT NULL
);
"#,
r#"
-- v20: audits and attestations (plan P8, feature `audits`). An audit run
-- is a row per `audit.run`: the date it covers, the figure head it was
-- made at, the hashes that make it reproducible and the filed workpaper.
-- An attestation is a row per `receipt.attested`, `entry.reviewed` or
-- `period.attested`; whether one counts is derived against
-- `policy.attestation_bar` when asked and never stored. The method is the
-- client's own claim, kept as canonical JSON. Amounts stay TEXT.
CREATE TABLE IF NOT EXISTS audits(
id INTEGER PRIMARY KEY,
through TEXT NOT NULL,
through_seq INTEGER NOT NULL,
scope TEXT NOT NULL,
program_version TEXT NOT NULL,
tb_hash TEXT NOT NULL,
findings_sha256 TEXT NOT NULL,
workpaper_attachment INTEGER NULL,
workpaper_pdf INTEGER NULL,
seq INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS attestations(
seq INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
attachment_id INTEGER NULL,
entry_id INTEGER NULL,
period_id INTEGER NULL,
audit_run_id INTEGER NULL,
by_who TEXT NOT NULL,
at_ts TEXT NOT NULL,
method TEXT NOT NULL,
amount_checked TEXT NULL,
date_checked TEXT NULL,
disagrees INTEGER NULL,
note TEXT NULL
);
"#,
];
+133
View File
@@ -51,6 +51,9 @@ use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
#[path = "audit.rs"]
pub mod audit;
/// File names inside the data directory.
pub const JOURNAL_FILE: &str = "journal.jsonl";
@@ -1117,6 +1120,67 @@ impl Store {
Ok(())
}
/// Correct who decided a convention, when and on what
/// (`convention.source_revised`, journal feature `source_revisions`),
/// its text unchanged: `c` is the convention as it stands, with the
/// corrected source. The reason is journaled with it.
pub fn revise_convention_source(
&mut self,
c: &ledger_core::conventions::NewConvention,
reason: &str,
) -> Result<(), StoreError> {
self.ensure_feature(crate::format::SOURCE_REVISIONS)?;
let (source, reason) = self.ledger.check_convention_source(c, reason)?;
self.ensure_format()?;
let line = self.append_journal(
"convention.source_revised",
crate::meta::convention_source_revised(&c.id, &source, &reason),
)?;
self.ledger.apply_convention_source(&c.id, source)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Correct who decided a decision, when and on what
/// (`decision.source_revised`, journal feature `source_revisions`).
pub fn revise_decision_source(
&mut self,
id: u64,
source: &ledger_core::conventions::DecisionSource,
reason: &str,
) -> Result<(), StoreError> {
self.ensure_feature(crate::format::SOURCE_REVISIONS)?;
let (source, reason) = self.ledger.check_decision_source(id, source, reason)?;
self.ensure_format()?;
let line = self.append_journal(
"decision.source_revised",
crate::meta::decision_source_revised(id, &source, &reason),
)?;
self.ledger.apply_decision_source(id, source)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Correct who raised an open item, when and on what
/// (`open_item.source_revised`, journal feature `source_revisions`).
pub fn revise_open_item_source(
&mut self,
id: u64,
source: &ledger_core::settings::Source,
reason: &str,
) -> Result<(), StoreError> {
self.ensure_feature(crate::format::SOURCE_REVISIONS)?;
let (source, reason) = self.ledger.check_open_item_source(id, source, reason)?;
self.ensure_format()?;
let line = self.append_journal(
"open_item.source_revised",
crate::meta::open_item_source_revised(id, &source, &reason),
)?;
self.ledger.apply_open_item_source(id, source)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Record a decision (`decision.recorded`); its evidence, if any, is
/// a filed attachment not withdrawn. Returns its id.
pub fn record_decision(
@@ -7533,6 +7597,21 @@ fn replay_line(
let a = crate::meta::annotated_of(line)?;
ledger.replay_annotate_attachment(a)?;
}
"audit.run" => {
// Not accounting state; the projection records it. Checked
// for shape here (an unknown scope is newer, rules.md B-27).
crate::meta::audit_run_of(line)?;
}
// A claim that someone checked; no figure moves.
"receipt.attested" => {
crate::meta::attestation_of(line)?;
}
"entry.reviewed" => {
crate::meta::attestation_of(line)?;
}
"period.attested" => {
crate::meta::attestation_of(line)?;
}
"entry.settles" => {
let (entry, settles) = crate::meta::entry_settles_of(p)?;
ledger.replay_set_entry_settles(entry, settles)?;
@@ -7945,6 +8024,18 @@ fn replay_line(
let (id, reason, source) = crate::meta::convention_retired_of(p)?;
ledger.apply_retire_convention(&id, reason, source, line.seq)?;
}
"convention.source_revised" => {
let (id, source, _reason) = crate::meta::convention_source_revised_of(p)?;
ledger.apply_convention_source(&id, source)?;
}
"decision.source_revised" => {
let (id, source, _reason) = crate::meta::decision_source_revised_of(p)?;
ledger.apply_decision_source(id, source)?;
}
"open_item.source_revised" => {
let (id, source, _reason) = crate::meta::open_item_source_revised_of(p)?;
ledger.apply_open_item_source(id, source)?;
}
"decision.recorded" => {
let (id, d) = crate::meta::decision_recorded_of(p)?;
ledger.apply_decision(id, d, line.seq)?;
@@ -8736,6 +8827,22 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
"audit.run" => {
let run = crate::meta::audit_run_of(line)?;
audit::insert_run(conn, &run)?;
}
"receipt.attested" => {
let a = crate::meta::attestation_of(line)?;
audit::insert_attestation(conn, &a)?;
}
"entry.reviewed" => {
let a = crate::meta::attestation_of(line)?;
audit::insert_attestation(conn, &a)?;
}
"period.attested" => {
let a = crate::meta::attestation_of(line)?;
audit::insert_attestation(conn, &a)?;
}
"entry.settles" => {
// Rebuilt into the ledger by replay; no report rows.
}
@@ -9042,6 +9149,32 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
"convention.source_revised" => {
let (id, source, _reason) = crate::meta::convention_source_revised_of(p)?;
conn.execute(
"UPDATE conventions SET source_json = ? WHERE id = ?",
params![crate::meta::source_json(&source).to_string(), id],
)?;
}
"decision.source_revised" => {
let (id, source, _reason) = crate::meta::decision_source_revised_of(p)?;
conn.execute(
"UPDATE decisions SET decided_by_json = ?, decided_on = ?, citation = ? WHERE id = ?",
params![
serde_json::to_string(&source.decided_by)?,
source.decided_on.to_string(),
source.citation,
id as i64
],
)?;
}
"open_item.source_revised" => {
let (id, source, _reason) = crate::meta::open_item_source_revised_of(p)?;
conn.execute(
"UPDATE open_items SET source_json = ? WHERE id = ?",
params![crate::meta::source_json(&source).to_string(), id as i64],
)?;
}
"decision.recorded" => {
let (id, d) = crate::meta::decision_recorded_of(p)?;
conn.execute(
+612
View File
@@ -0,0 +1,612 @@
//! Audits and attestations in the store (plan P8; rules.md B-45): the
//! `audits` feature, the figure head, idempotent runs, attestations that
//! rest on a head, and the integrity findings only the store can see.
use chrono::NaiveDate;
use ledger_core::audit::{Effort, Method, Scope};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::audit::{self, Attested, BackupStanding, Claim, NewAuditRun, backup_findings};
use ledger_store::format::{AUDITS, JournalFormat};
use ledger_store::{BlobStore, JOURNAL_FILE, JournalWriter, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::PathBuf;
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!("wb-audit-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(s.parse().unwrap(), Currency::USD)
}
struct Fx {
bank: u64,
capital: u64,
supplies: u64,
/// A posted 40.00 purchase.
entry: u64,
}
/// A bank, capital and an expense account, and a posted 40.00 purchase on
/// the 5th.
fn books(dir: &std::path::Path, format: Option<&JournalFormat>) -> (Store, Fx) {
let mut s = match format {
Some(f) => Store::create_in_format(dir, Currency::USD, f).unwrap(),
None => Store::create(dir, Currency::USD).unwrap(),
};
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let supplies = s
.add_account("6000", "Supplies", AccountType::Expense, None, false)
.unwrap();
let e = s.create_entry(d(1, 1), "Opening").unwrap();
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
s.post_entry(e).unwrap();
let entry = s.create_entry(d(1, 5), "Printer paper").unwrap();
s.add_line(entry, supplies, Side::Debit, usd("40.00"))
.unwrap();
s.add_line(entry, bank, Side::Credit, usd("40.00")).unwrap();
s.post_entry(entry).unwrap();
(
s,
Fx {
bank,
capital,
supplies,
entry,
},
)
}
fn run_of(s: &Store, through: NaiveDate, scope: Scope) -> NewAuditRun {
NewAuditRun {
through,
through_seq: s.figure_head().unwrap(),
scope,
tb_hash: "tb".into(),
findings_sha256: "fs".into(),
workpaper_attachment: None,
workpaper_pdf: None,
}
}
fn claim(effort: &str) -> Claim {
Claim {
by: "verifier-1".into(),
method: Method {
model: Some("opus".into()),
reasoning_effort: Effort::parse(effort),
adversarial: Some(true),
verifier_agents: Some(2),
},
note: None,
}
}
fn journal_bytes(dir: &std::path::Path) -> Vec<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
/// R-A: every write of the feature waits for the owner's upgrade,
/// journaling nothing; afterwards it is taken and the books replay to
/// what was live.
#[test]
fn audit_writes_wait_for_audits() {
let dir = test_dir("waits");
let (mut s, fx) = books(&dir, Some(&JournalFormat::default()));
assert!(!s.journal_format().has_feature(AUDITS));
let (att, _, _) = s
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
.unwrap();
let before = journal_bytes(&dir);
let new = run_of(&s, d(1, 31), Scope::Month);
for r in [
s.record_audit_run(new.clone()).map(|_| ()),
s.attest_receipt(
att,
fx.entry,
"40.00".parse().unwrap(),
d(1, 5),
claim("high"),
)
.map(|_| ()),
s.review_entry(fx.entry, claim("high")).map(|_| ()),
] {
match r {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature audits")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
}
assert_eq!(journal_bytes(&dir), before, "a refusal journals nothing");
s.upgrade_format().unwrap();
let run = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
.unwrap();
s.review_entry(fx.entry, claim("high")).unwrap();
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.audit_runs().unwrap(), vec![run]);
assert_eq!(s.attestations().unwrap().len(), 1);
}
/// INVARIANT 1 and 2: filing a workpaper and journaling the run do not
/// move the figure head; an entry does; the same audit asked again
/// returns the first run and journals nothing.
#[test]
fn a_run_is_idempotent_on_the_figure_head() {
let dir = test_dir("idem");
let (mut s, fx) = books(&dir, None);
let head = s.figure_head().unwrap();
let (wp, _, _) = s
.file_document("workpaper", "w.json", "application/json", b"{}", None)
.unwrap();
assert_eq!(s.figure_head().unwrap(), head, "a filing is not a figure");
let mut new = run_of(&s, d(1, 31), Scope::Month);
new.workpaper_attachment = Some(wp);
let first = s.record_audit_run(new.clone()).unwrap();
assert_eq!(first.id, 1);
assert_eq!(first.through_seq, head);
assert_eq!(s.figure_head().unwrap(), head, "a run is not a figure");
let bytes = journal_bytes(&dir);
let again = s.record_audit_run(new).unwrap();
assert_eq!(again, first);
assert_eq!(journal_bytes(&dir), bytes, "asking again journals nothing");
assert!(s.audit_is_current(&first).unwrap());
// Another scope is another run.
let other = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Full))
.unwrap();
assert_eq!(other.id, 2);
// An entry moves the head: the run no longer stands.
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
.unwrap();
s.add_line(e, fx.bank, Side::Credit, usd("3.00")).unwrap();
s.post_entry(e).unwrap();
assert!(s.figure_head().unwrap() > head);
assert!(!s.audit_is_current(&first).unwrap());
s.verify().unwrap();
}
/// A receipt reading that differs from the entry's is recorded, marked,
/// and never counts; one that agrees is not marked. The receipt must be on
/// the entry.
#[test]
fn amount_mismatch_never_counts() {
let dir = test_dir("mismatch");
let (mut s, fx) = books(&dir, None);
let (att, _, _) = s
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper", None)
.unwrap();
let other = s
.attach_receipt(None, "x.txt", "text/plain", b"stray", None)
.unwrap()
.0;
let amount = |t: &str| -> Decimal { t.parse().unwrap() };
let disagrees = |a: &ledger_store::audit::Attestation| match a.what {
Attested::Receipt { disagrees, .. } => disagrees,
_ => panic!("not a receipt"),
};
let ok = s
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 5), claim("high"))
.unwrap();
assert!(!disagrees(&ok));
let wrong_amount = s
.attest_receipt(att, fx.entry, amount("41.00"), d(1, 5), claim("high"))
.unwrap();
assert!(disagrees(&wrong_amount));
let wrong_date = s
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 6), claim("high"))
.unwrap();
assert!(disagrees(&wrong_date));
// Refused: a receipt not on the entry, a draft, an unknown entry.
assert!(matches!(
s.attest_receipt(other, fx.entry, amount("40.00"), d(1, 5), claim("high")),
Err(StoreError::AuditRefused(_))
));
let draft = s.create_entry(d(2, 2), "Draft").unwrap();
assert!(matches!(
s.review_entry(draft, claim("high")),
Err(StoreError::AuditRefused(_))
));
assert!(s.review_entry(999, claim("high")).is_err());
// All three kinds replay, in order.
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
let got = s.attestations().unwrap();
assert_eq!(got.len(), 3);
assert!(got.windows(2).all(|w| w[0].seq < w[1].seq));
assert_eq!(got[0].method.reasoning_effort, Some(Effort::High));
assert_eq!(got[0].by, "verifier-1");
}
/// INVARIANT 3: a period is attested only on a run at the current head
/// that covers it; an entry since makes the run stale and the attestation
/// is refused until the audit is run again.
#[test]
fn stale_head_period_attestation_refused() {
let dir = test_dir("stale");
let (mut s, fx) = books(&dir, None);
let jan = s.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
// No audit yet.
assert!(matches!(
s.attest_period(jan, claim("high")),
Err(StoreError::AuditRefused(_))
));
// An audit through before the period's end does not cover it.
s.record_audit_run(run_of(&s, d(1, 15), Scope::Month))
.unwrap();
assert!(matches!(
s.attest_period(jan, claim("high")),
Err(StoreError::AuditRefused(_))
));
let run = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
.unwrap();
let (a, on) = s.attest_period(jan, claim("high")).unwrap();
assert_eq!(on.id, run.id);
assert_eq!(
a.what,
Attested::Period {
period_id: jan,
audit_run_id: run.id
}
);
// An entry since: the run is stale.
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
.unwrap();
s.add_line(e, fx.capital, Side::Credit, usd("3.00"))
.unwrap();
s.post_entry(e).unwrap();
let head = s.head();
match s.attest_period(jan, claim("high")) {
Err(StoreError::AuditRefused(why)) => assert!(why.contains("books.audit"), "{why}"),
other => panic!("expected a refusal, got {other:?}"),
}
assert_eq!(s.head(), head, "a refusal journals nothing");
s.verify().unwrap();
}
/// The integrity table: each code seeded alone.
#[test]
fn integrity_detects_each_seeded_defect() {
// A whole book finds nothing.
{
let dir = test_dir("whole");
let (s, _) = books(&dir, None);
assert_eq!(s.integrity_findings(), vec![]);
}
let seed = |code: &str| -> Vec<&'static str> {
let dir = test_dir(&format!("seed-{code}"));
let (mut s, fx) = books(&dir, None);
let found = match code {
"blob_missing" | "blob_damaged" | "extraction_blob_missing" => {
let (_, sha, _) = s
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
.unwrap();
let target = if code == "extraction_blob_missing" {
// The text the extractor read, content-addressed too.
let x = s.extractions().unwrap().pop().expect("an extraction");
x.text_sha256.expect("text")
} else {
sha
};
let path = dir.join("blobs").join(&target[..2]).join(&target);
let path = if path.exists() {
path
} else {
// The store's own layout.
walk(&dir.join("blobs"), &target).expect("the blob file")
};
if code == "blob_damaged" {
std::fs::write(&path, b"tampered").unwrap();
} else {
std::fs::remove_file(&path).unwrap();
}
s.integrity_findings()
}
"duplicate_blob" => {
drop(s);
let bytes = b"filed twice by an older version";
let (sha, _) = BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": null,
"category": "receipts", "file_name": name,
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
drop(w);
Store::open(&dir).unwrap().integrity_findings()
}
"access_log_damaged" => {
std::fs::write(
dir.join("access.jsonl"),
b"{\"not\": \"a chained record\"}\n",
)
.unwrap();
s.integrity_findings()
}
"projection_differs" => {
let db = ledger_store::database_in_use(&dir);
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute("DELETE FROM lines", []).unwrap();
drop(conn);
s.integrity_findings()
}
"journal_damaged" => {
let path = dir.join(JOURNAL_FILE);
let text = std::fs::read_to_string(&path).unwrap();
std::fs::write(&path, text.replacen("Printer paper", "Printer papers", 1)).unwrap();
s.integrity_findings()
}
"backup_stale" => backup_findings(&[BackupStanding {
target: "folder".into(),
stale: true,
last_error: None,
drill_error: None,
}]),
"drill_failed" => backup_findings(&[BackupStanding {
target: "folder".into(),
stale: false,
last_error: None,
drill_error: Some("restore did not reach the head".into()),
}]),
other => panic!("no seed for {other}"),
};
found.iter().map(|f| f.code).collect()
};
for row in audit::CODES {
let got = seed(row.code);
assert!(got.contains(&row.code), "{}: found {got:?}", row.code);
// The seed implies nothing else but its own consequences: a
// missing or damaged file is found once; a damaged journal can
// also differ from its replay.
for c in &got {
assert!(
*c == row.code
|| (row.code == "journal_damaged" && *c == "projection_differs")
// verify reads a file's text too, so a damaged or missing
// stored file is also a replay that cannot be made.
|| (row.code.starts_with("blob_") || row.code == "extraction_blob_missing")
&& [
"blob_missing",
"blob_damaged",
"extraction_blob_missing",
"projection_differs",
]
.contains(c),
"{}: also found {c}",
row.code
);
}
}
// No backups set up is the owner's standing choice, not a finding.
assert!(backup_findings(&[]).is_empty());
}
fn walk(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
for e in std::fs::read_dir(dir).ok()? {
let p = e.ok()?.path();
if p.is_dir() {
if let Some(f) = walk(&p, name) {
return Some(f);
}
} else if p.file_name().is_some_and(|n| n == name) {
return Some(p);
}
}
None
}
/// 8.0.6 item 2: the books the version before P8 wrote
/// (`old-books/p8_before`) open to the same journal, have no audit run or
/// claim, are whole (nothing for the integrity checks to find), refuse
/// the new writes until the owner's upgrade, and take them after it.
#[test]
fn books_the_version_before_p8_wrote_wait_for_the_upgrade() {
let from =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p8_before");
let dir = test_dir("p8-before");
std::fs::create_dir_all(dir.join("blobs")).unwrap();
std::fs::copy(from.join(JOURNAL_FILE), dir.join(JOURNAL_FILE)).unwrap();
fn copy(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).unwrap();
for e in std::fs::read_dir(from).unwrap() {
let e = e.unwrap();
if e.file_type().unwrap().is_dir() {
copy(&e.path(), &to.join(e.file_name()));
} else {
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
}
}
}
copy(&from.join("blobs"), &dir.join("blobs"));
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert!(!s.journal_format().has_feature(AUDITS));
assert!(s.audit_runs().unwrap().is_empty());
assert!(s.attestations().unwrap().is_empty());
assert_eq!(s.integrity_findings(), vec![]);
// The head is the last figure line; the filings after it don't move it.
let head = s.figure_head().unwrap();
assert!(head > 0 && head <= s.head().0);
let jan = s.ledger().list_periods()[0].id;
let before = journal_bytes(&dir);
assert!(matches!(
s.record_audit_run(run_of(&s, d(1, 31), Scope::Month)),
Err(StoreError::FormatUpgradePending { .. })
));
assert!(matches!(
s.attest_period(jan, claim("high")),
Err(StoreError::FormatUpgradePending { .. })
));
assert_eq!(journal_bytes(&dir), before, "refused: nothing journaled");
s.upgrade_format().unwrap();
let run = s
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
.unwrap();
s.attest_period(jan, claim("high")).unwrap();
assert_eq!(s.figure_head().unwrap(), head, "claims are not figures");
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
s.verify().unwrap();
assert_eq!(s.audit_runs().unwrap(), vec![run]);
}
/// Done-when 2: books with an unfooted cash flow, a posted entry with no
/// receipt, an open draft and a duplicate file give exactly those four
/// findings: the statements' and the store's, together.
#[test]
fn four_defects_give_exactly_four_findings() {
use ledger_core::audit::{AuditInput, statements};
let dir = test_dir("four");
let (mut s, fx) = {
let mut s = Store::create(&dir, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let capital = s
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let supplies = s
.add_account_with_tax(
"6000",
"Supplies",
AccountType::Expense,
None,
false,
Some(("supplies", None)),
)
.unwrap();
let e = s.create_entry(d(1, 1), "Opening").unwrap();
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
s.post_entry(e).unwrap();
s.attach_receipt(Some(e), "open.txt", "text/plain", b"deposit 500.00", None)
.unwrap();
// A purchase over the threshold with no receipt: posted through the
// primitive, as a hand entry made before the rule was tightened.
let bare = s.create_entry(d(1, 5), "Printer paper").unwrap();
s.add_line(bare, supplies, Side::Debit, usd("40.00"))
.unwrap();
s.add_line(bare, bank, Side::Credit, usd("40.00")).unwrap();
s.post_entry(bare).unwrap();
// An open draft.
let draft = s.create_entry(d(1, 9), "Stamps").unwrap();
s.add_line(draft, supplies, Side::Debit, usd("5.00"))
.unwrap();
s.add_line(draft, bank, Side::Credit, usd("5.00")).unwrap();
(s, (bank, card, bare))
};
// A duplicate: two records an older version filed for one file.
drop(s);
let bytes = b"filed twice by an older version";
let (sha, _) = BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (id, name) in [(10, "one.pdf"), (11, "two.pdf")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": null,
"category": "receipts", "file_name": name,
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
drop(w);
s = Store::open(&dir).unwrap();
let (bank, card, _) = fx;
// The statement of cash flows with a liability named as cash: the
// engine refuses it, and that refusal is the finding.
let params = ledger_core::CashFlowParams {
start: d(1, 1),
end: d(1, 31),
cash_accounts: vec![bank, card],
financing_accounts: vec![],
investing_accounts: vec![],
members: vec![],
};
let made = ledger_core::cash_flow(s.ledger(), &params)
.map(|c| c.reconciles)
.map_err(|e| e.to_string());
assert!(made.is_err(), "a liability is not a cash account");
let receipts = s.entries_with_receipts().unwrap();
let mut found = statements(
s.ledger(),
&AuditInput {
settings: s.ledger().settings(),
receipts: &receipts,
cash_flow: Some(&made),
settings_problems: &[],
undecided_1099: &[],
},
d(1, 31),
);
found.extend(s.integrity_findings());
ledger_core::audit::sort(&mut found);
let mut codes: Vec<&str> = found.iter().map(|f| f.code).collect();
codes.sort_unstable();
assert_eq!(
codes,
[
"cash_flow_unfooted",
"draft_open",
"duplicate_blob",
"evidence_missing"
],
"{found:#?}"
);
}
@@ -14,6 +14,9 @@
# cargo run --bin p4_before -- OUT (p4_before: run against 25b52580, the
# revision before P4: hand entries posted
# late, a reversal, open drafts, a close)
# cargo run --bin p5_before -- OUT (p5_before: run against b97234ac, the
# revision before P5: a revised
# convention, two decisions, two open items)
# cargo run --bin expect -- OUT (expected.json beside each journal)
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
# x_member_closes was written later, the same way, by main 00226a4c
@@ -0,0 +1,105 @@
//! Writes `p5_before`: books the version before P5 (main b97234ac) wrote
//! through its Store API, with the records P5's source corrections act on:
//! a convention revised once, two decisions (one superseding the other)
//! and two open items (one resolved), each with its source, beside a few
//! posted entries and a draft awaiting its evidence. No source correction
//! exists in that version; this version opens the same journal and must
//! report the same figures.
use chrono::NaiveDate;
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::Source;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(c: i64) -> Money {
Money::new(Decimal::new(c, 2), Currency::USD)
}
fn source(by: &str, day: u32) -> Source {
Source {
decided_by: by.into(),
decided_on: d(2026, 10, day),
citation: None,
note: None,
}
}
fn main() {
let root = PathBuf::from(std::env::args().nth(1).unwrap());
let dir = root.join("p5_before");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Store::create(&dir, Currency::USD).unwrap();
let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap();
let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap();
let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap();
let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
e
};
let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000);
s.post_entry(seed).unwrap();
let jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
s.post_entry(jan).unwrap();
let _waiting = post(&mut s, d(2026, 2, 3), "Awaiting a receipt", rent, bank, 4_000);
// How the company keeps its books: a close step, revised once.
let step = |body: &str, by: &str, day: u32| NewConvention {
id: "close-checklist".into(),
kind: ConventionKind::CloseStep,
title: "Close checklist".into(),
body_md: body.into(),
structured: None,
source: source(by, day),
};
s.set_convention(&step("Reconcile, then close.", "Owner", 20)).unwrap();
s.set_convention(&step("Reconcile, review drafts, then close.", "Owner", 22)).unwrap();
// Two decisions, the second replacing the first.
let first = s
.record_decision(&NewDecision {
subject: "Materiality".into(),
decision_md: "Five hundred dollars.".into(),
decided_by: vec!["Owner".into()],
decided_on: d(2026, 10, 20),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![],
})
.unwrap();
s.record_decision(&NewDecision {
subject: "Materiality".into(),
decision_md: "One thousand dollars.".into(),
decided_by: vec!["Owner".into(), "Pat Smith".into()],
decided_on: d(2026, 10, 25),
evidence_attachment: None,
supersedes: Some(first),
citation: Some("IRS Pub. 538".into()),
projects: vec![],
})
.unwrap();
// Two open items, one resolved.
let ask = s
.raise_open_item(&NewOpenItem {
title: "Ask the CPA about the vehicle".into(),
detail_md: None,
due: Some(d(2026, 11, 15)),
source: source("Owner", 20),
})
.unwrap();
s.raise_open_item(&NewOpenItem {
title: "Mail the annual report".into(),
detail_md: Some("Due with the state.".into()),
due: None,
source: source("Owner", 21),
})
.unwrap();
s.resolve_open_item(ask, "Answered: lease it.", &source("Pat Smith", 26)).unwrap();
s.verify().unwrap();
println!("WROTE {:?}", dir);
}
@@ -0,0 +1,85 @@
//! Writes `p8_before`: books the version before P8 (main b97234ac) wrote
//! through its Store API: a receipted opening and purchase, a 40.00
//! purchase with no receipt, a small one under the threshold, a reversal,
//! an invoice with a part payment, an open draft, and a closed January.
//! No audit run, attestation or attestation setting exists in that
//! version; this version opens the same journal and must report the same
//! figures, and an audit of it finds exactly the missing receipt and the
//! open draft.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, DocumentLine, Money, Side};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
fn main() {
let root = PathBuf::from(std::env::args().nth(1).unwrap());
let dir = root.join("p8_before");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Store::create(&dir, Currency::USD).unwrap();
let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap();
let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap();
let sales = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap();
let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap();
let software = s.add_account("6100", "Software", AccountType::Expense, None, false).unwrap();
let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
e
};
let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000);
s.post_entry(seed).unwrap();
s.attach_receipt(Some(seed), "opening.txt", "text/plain", b"Opening deposit 1000.00 2026-01-01", None)
.unwrap();
let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
s.post_entry(rent_jan).unwrap();
s.attach_receipt(Some(rent_jan), "rent.txt", "text/plain", b"Rent January 500.00 2026-01-05", None)
.unwrap();
// 40.00 is over the 25.00 threshold: no receipt, so an audit finds it.
let paper = post(&mut s, d(2026, 1, 7), "Printer paper", software, bank, 4_000);
s.post_entry(paper).unwrap();
// Under the threshold: needs none.
let stamps = post(&mut s, d(2026, 1, 8), "Stamps", software, bank, 1_200);
s.post_entry(stamps).unwrap();
let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000);
s.post_entry(wrong).unwrap();
s.attach_receipt(Some(wrong), "rent2.txt", "text/plain", b"Rent again 500.00 2026-01-12", None)
.unwrap();
s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap();
let customer = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(
customer,
d(2026, 1, 10),
d(2026, 2, 9),
vec![DocumentLine {
description: "Design".into(),
amount: cents(120_000),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
s.receive_payment(inv, d(2026, 1, 20), cents(40_000), bank).unwrap();
// An open draft in February; January closes around it.
let _open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500);
let jan = s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)).unwrap();
s.close_period(jan).unwrap();
let _ = sales;
s.verify().unwrap();
println!("WROTE {:?}", dir);
}
@@ -0,0 +1,154 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-05",
"description": "January rent",
"id": 2,
"lines": [
{
"account": 3,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-03",
"description": "Awaiting a receipt",
"id": 3,
"lines": [
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Draft"
}
],
"entry_parties": [],
"invoices": [],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "500",
"balanced": true,
"current_earnings": "-500",
"equity": "500",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "500",
"net_income": "-500",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "500",
"name": "Bank"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "6000",
"credit": "0",
"debit": "500",
"name": "Rent"
}
],
"total_credits": "1000",
"total_debits": "1000"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,23 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:39:45Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"1ec5c6e78cbef1e34d22de1c3376c7ec5051d3cf16d99d5d21fc0f62ab272574","seq":2,"ts":"2026-10-29T20:39:45Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"49c0bd7bc22f2804ad1af0a6ed1488dcaa74ffcfbfc3f1d34580574c3b5f577a","seq":3,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"e62de432ee6bbf20db6231ceb2caa523a423cb6104747d0fee9bb7caa3306a13","seq":4,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Rent","parent":null,"placeholder":false},"prev":"e718e3ee547698e7a8dba46b0fa2006579b265c8941a2a64a8780e0d6799c05e","seq":5,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"1aac79f391f066f3caa4286252c17094d5dcd78c5f1a3a5c3d26831976876581","seq":6,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"278ea897aa5a37a5198f4165d03464945379a6b3cb25070cbf7a5b6a9c65faea","seq":7,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"59535fd0d40e6820e354c3f2c2b9e0e03107c03392413e81d738c4229f6c8d40","seq":8,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fea68defea0343aa4cbc3b70c6d7c8dec9481d5df0bd6db78ddd7bf4c9a6b67","seq":9,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"40488694d734440f615cfaa57164f31f92da6c5c5133891671eba86ccbf99628","seq":10,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"ea486bc7b9029b186f156d4d8d7ad14b149fe6d24585bcaeb8707e13546a4109","seq":11,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"0836c22c91fb4ed3a0a230b49d6a8123592cd80f9630819c1f7ba957a51abf8f","seq":12,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"15cf6b35fd07465fdf86d765fbcacbbcd9fb8e378a9aca516c232a91d406cfbb","seq":13,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"}
{"payload":{"date":"2026-02-03","description":"Awaiting a receipt","id":3},"prev":"331e0f5b3610e7b6a1e20d3682c5f2be6ce1b827e182643bd0be0ae097fbf4c7","seq":14,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
{"payload":{"account":3,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"e2faf752e39dac02ea40ff2133c8d33a1fe85d84f5d3e81473b77a0284bd9b63","seq":15,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"4b4fac7a91be608d99f90c1aad38650b6e2442699cb103dd4c9f3dc2846e63d4","seq":16,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"body_md":"Reconcile, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Close checklist"},"prev":"0dd1ac379bd2987f018675f0c0ccc1558ddb6c5c32cd383bb5984c163f37b2c3","seq":17,"ts":"2026-10-29T20:39:45Z","type":"convention.set"}
{"payload":{"body_md":"Reconcile, review drafts, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-22"},"title":"Close checklist"},"prev":"ac97d62664a7b399d6fe38cfd62843068470fdc46f41f19a74c0388b42d42105","seq":18,"ts":"2026-10-29T20:39:45Z","type":"convention.set"}
{"payload":{"decided_by":["Owner"],"decided_on":"2026-10-20","decision_md":"Five hundred dollars.","id":1,"subject":"Materiality"},"prev":"4c7412cba11ebe3ab93e5a4c85a5f99f29913c952149f40a91a34e818d955a8e","seq":19,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"}
{"payload":{"citation":"IRS Pub. 538","decided_by":["Owner","Pat Smith"],"decided_on":"2026-10-25","decision_md":"One thousand dollars.","id":2,"subject":"Materiality","supersedes":1},"prev":"b89addb314620a56f585d6a3f997f7f658e1596bda44c6b56ffe51b4fe737fff","seq":20,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"}
{"payload":{"due":"2026-11-15","id":1,"source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Ask the CPA about the vehicle"},"prev":"4f1ce3e87d5bd5a4d689a0ef0a61422417c76d3af4d3793c03b12fecc5a73af1","seq":21,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"}
{"payload":{"detail_md":"Due with the state.","id":2,"source":{"decided_by":"Owner","decided_on":"2026-10-21"},"title":"Mail the annual report"},"prev":"7054527c8e4184297b46b801e6ce2c8237b467684c40ebce295957d0bab372cf","seq":22,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"}
{"payload":{"id":1,"resolution":"Answered: lease it.","source":{"decided_by":"Pat Smith","decided_on":"2026-10-26"}},"prev":"3ad740fbca7d8a6e8ce669a944631bc7d406f3f6820fb4b5c31c05fcbe26fca5","seq":23,"ts":"2026-10-29T20:39:46Z","type":"open_item.resolved"}
@@ -0,0 +1,461 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-05",
"description": "January rent",
"id": 2,
"lines": [
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-07",
"description": "Printer paper",
"id": 3,
"lines": [
{
"account": 5,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-08",
"description": "Stamps",
"id": 4,
"lines": [
{
"account": 5,
"amount": {
"amount": "12.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "12.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-12",
"description": "Rent paid twice",
"id": 5,
"lines": [
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-14",
"description": "Entered twice",
"id": 6,
"lines": [
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
}
],
"reversal_of": 5,
"status": "Posted"
},
{
"date": "2026-01-10",
"description": "Invoice 1",
"id": 7,
"lines": [
{
"account": 6,
"amount": {
"amount": "1200.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "1200.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-20",
"description": "Payment on invoice 1",
"id": 8,
"lines": [
{
"account": 6,
"amount": {
"amount": "400.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 1,
"amount": {
"amount": "400.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-12",
"description": "Coworking day pass",
"id": 9,
"lines": [
{
"account": 4,
"amount": {
"amount": "35.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "35.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Draft"
},
{
"date": "2026-01-31",
"description": "Close period 2026-01",
"id": 10,
"lines": [
{
"account": 3,
"amount": {
"amount": "1200.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 5,
"amount": {
"amount": "52.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 7,
"amount": {
"amount": "648.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [
[
7,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
8,
[
{
"party_id": 1,
"share": "derived"
}
]
]
],
"invoices": [
{
"amount_paid": "400.00",
"customer_id": 1,
"date": "2026-01-10",
"due_date": "2026-02-09",
"entry_id": 7,
"id": 1,
"lines": [
{
"amount": "1200.00",
"description": "Design"
}
],
"status": "Partial"
}
],
"payroll_runs": [],
"periods": [
{
"close_seq": 1,
"closing_entry": 10,
"end": "2026-01-31",
"id": 1,
"member_close": null,
"name": "2026-01",
"start": "2026-01-01",
"status": "Closed"
}
],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "1648",
"balanced": true,
"current_earnings": "0",
"equity": "1648",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "0",
"net_income": "0",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "848",
"name": "Bank"
},
{
"code": "1200",
"credit": "0",
"debit": "800",
"name": "Accounts Receivable"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "3999",
"credit": "648",
"debit": "0",
"name": "Retained Earnings"
}
],
"total_credits": "1648",
"total_debits": "1648"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,52 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:33:25Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"a41fd982ec883c0f7dbeec0382bcf10c69246a4d063cf04997f40ad96467f00d","seq":2,"ts":"2026-10-29T20:33:25Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"f0adba7a9a2da79cc2b28f4ab2a69238da5f3ad9269e76c1c21e0027fdf86c51","seq":3,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"093d7586712b4e3379fea7da2923ab4b25efa7be0d533e212ea4c31ecbb81332","seq":4,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":3,"name":"Sales","parent":null,"placeholder":false},"prev":"2b8f70ae6d4b736aa0cc523563b47cdd1be396e3fd3a5c2f84cceb001fbaff81","seq":5,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"d4fc3949ba8c20b553504561a2c22f96d6a4351713ae66e83fe34f601ead0b2d","seq":6,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"3088d962972a512f5d1a12a8dd4da1da6ae8d1388e67ea18666b87687401b97c","seq":7,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"5947b37f609878df3587e37c72411cc2505fb694aab1bc9a7199cecfd2897031","seq":8,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"d0aff2093f4f8114e04eb158ddfcfc24201846214141fea1def86cd965551006","seq":9,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"850e9125ac3b5d457e611882642600a7298fa3acdd386ce2b7c9494ecb29b0d0","seq":10,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"abc483bc19148cc173cf93402702b60ee5e31029fcdcfa704f10204c38a707ec","seq":11,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
{"payload":{"attachment_id":1,"blob_sha":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","category":"receipts","description":null,"entry_id":1,"file_name":"opening.txt","mime_type":"text/plain","size_bytes":34},"prev":"4a2665579fd49f5d11e27eaaca556990d58559dd1a9d8f23e9d274449ff61a8d","seq":12,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
{"payload":{"attachment_id":1,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","version":"0.1.0"},"prev":"d8c2d6bf43d24b9cf4b3c0a12b514cd9f77cf51f979ab54e17c6fc879fdc285f","seq":13,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"d048136f116634d3df443725f71b4f5a9ed2cfdc45a8dba9fc9a54bb993266ff","seq":14,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"6850fe7154d84ec18c876c9c4985607b9660e1a4d6ff1c42814358039d25224d","seq":15,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"24ecb6745877198c45149e53672d81a7e8b75051770c2eac046c373ad5020694","seq":16,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"76e855cebe94f29e2bbf65e4be0634976eec4164867ec4c2152e39b21a5b9ab0","seq":17,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
{"payload":{"attachment_id":2,"blob_sha":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","category":"receipts","description":null,"entry_id":2,"file_name":"rent.txt","mime_type":"text/plain","size_bytes":30},"prev":"72fb7f27dba8721febaffcc2dc93870d79272ca3208abfea27d1535f46fa8334","seq":18,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
{"payload":{"attachment_id":2,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","version":"0.1.0"},"prev":"bd55df8f6fce00e7a55262be3123985789504189f9bc8364a194fa886719fc6a","seq":19,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3},"prev":"2129339f3ce925cb9d0ff71f108ce7ba5c1dadd8432834effb2586e7632c1add","seq":20,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":5,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"3980e707c3feb852f13fff9daa38ae8bf64831d851a76b8a5e7e97eff3237506","seq":21,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"673065104b3196477ebed498501f952f62b0cc1ed8cb35cda30b00e3d4932590","seq":22,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":1,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c2c265a5318dfc5437b94272a5ceb654e93eb982815eea919ce11ca492e69f53","seq":23,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
{"payload":{"date":"2026-01-08","description":"Stamps","id":4},"prev":"e54f5a63600a423bc05f572cc2e5357b591afee176de438adbfc673e10028443","seq":24,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
{"payload":{"account":5,"amount":"12.00","currency":"USD","id":4,"side":"debit"},"prev":"c0acdf9d0c8fd94c66be0718354953d951f377d961da3846bba4fcfe9715577a","seq":25,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":4,"side":"credit"},"prev":"9231e3a5b347df6caabb01248419230918234919a32552ebb509706a79f4cfd0","seq":26,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-08","description":"Stamps","id":4,"lines":[{"account":5,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"66e4fd5f78d0499f6c748e283ba81325fb43d3277a73bc7ba02e1cc43a8e3d37","seq":27,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5},"prev":"e62d56bcde5b843e5b007e5d70bec0b39159e04c0e496533341af80fb2c04e88","seq":28,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":5,"side":"debit"},"prev":"b9270e6df82d8f9972ec1ce5046113bc460e1c28b48a1d23a3f5631dae23eb6c","seq":29,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":5,"side":"credit"},"prev":"91bd76318ce8bcdc02caaff76a44ade5b63aea97fc2e9406a914f87d91b7b87b","seq":30,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"994d4a25eb115362c405208ffc926af4898c61e0938d225c3cceb1aaff2520b1","seq":31,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"attachment_id":3,"blob_sha":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","category":"receipts","description":null,"entry_id":5,"file_name":"rent2.txt","mime_type":"text/plain","size_bytes":28},"prev":"97c54bdaa6d977bddbb61f0f797903f162a5445aadad0f86fbef119d26f03885","seq":32,"ts":"2026-10-29T20:33:27Z","type":"receipt.attached"}
{"payload":{"attachment_id":3,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","version":"0.1.0"},"prev":"6908282c4f663ee92e59574a7f772d341692a684975433afb0370fe7f25ad798","seq":33,"ts":"2026-10-29T20:33:27Z","type":"attachment.text_extracted"}
{"payload":{"date":"2026-01-14","description":"Entered twice","id":6,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"d8fae93f64fa2c2fafa13ed4ed3a6f578a6d7a2af15cdef1bd2b3bed86242665","seq":34,"ts":"2026-10-29T20:33:27Z","type":"entry.reversed"}
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"c525d0946d25b018a020b71072d59e39a6a842d85fa4c554af73965235b93aca","seq":35,"ts":"2026-10-29T20:33:27Z","type":"customer.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":6,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"99f31ecc1df1963071728eb1927d66574cbbc48579604183b569b0507f725aaa","seq":36,"ts":"2026-10-29T20:33:27Z","type":"account.added"}
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7},"prev":"000b3f21fbda30070ebdc436788844e60e7fb30a2c574a4fc96df1184e6a974f","seq":37,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":6,"amount":"1200.00","currency":"USD","id":7,"side":"debit"},"prev":"31912cd94f8cf8ab2381467d0c1f06e7b7d1aad7232dc34bf26976f8b07117df","seq":38,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":7,"side":"credit"},"prev":"b3544c286be658d2569eaec61f1ecf924b6345125ccdee5de44c7be69eb80d87","seq":39,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7,"lines":[{"account":6,"amount":"1200.00","currency":"USD","side":"debit"},{"account":3,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"780aa79b403d46831221d2f9de731eafe6e20e3b916fa6bcc712485234a53a41","seq":40,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":7,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"d13a0896487f5aec07d43136ad9fd10e70d4f048528ff4e7a32a9d23a74f7225","seq":41,"ts":"2026-10-29T20:33:27Z","type":"invoice.created"}
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8},"prev":"293d3e1a957fa177d28515cf7a4dfa324945bb73d8a2b535844fbaafa186ff1a","seq":42,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":6,"amount":"400.00","currency":"USD","id":8,"side":"credit"},"prev":"68c56c6c09a27ba436c95d4ebe2e0fb56511bf5db181ad424d7c6ed56cfd0bc3","seq":43,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"400.00","currency":"USD","id":8,"side":"debit"},"prev":"5284c73245f1f3a6d2820a6165fe720bc7b7034ad3cd07ba1320f30fed4acadc","seq":44,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8,"lines":[{"account":6,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"c2afa1d4c940bbc35df2ec94272080c5fa0dad93a9547cbec6d07ab11f4ba4f3","seq":45,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":8,"invoice_id":1},"prev":"30a6274b20559dc7f83f2b00f39da4c72bc4244c013ad63368d0f6362311e058","seq":46,"ts":"2026-10-29T20:33:27Z","type":"invoice.payment_received"}
{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":9},"prev":"adb2e790bd245c5ab0286870ad837296d0df062498c080b4349579b9f5f67ed9","seq":47,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
{"payload":{"account":4,"amount":"35.00","currency":"USD","id":9,"side":"debit"},"prev":"030bdf46cf4b8dae86f000cca47fd0f95d0f7dc01f3637729c082797846a1416","seq":48,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"35.00","currency":"USD","id":9,"side":"credit"},"prev":"b6e4796ab7517782b0e5ee77126e5701246d2b4234c505bc079ba1ce93ad4b04","seq":49,"ts":"2026-10-29T20:33:28Z","type":"entry.line_added"}
{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"d5653ccd2b9fdc574ebb68c068246e70b69639f26a3934faf41eeeb77dc5f8bb","seq":50,"ts":"2026-10-29T20:33:28Z","type":"period.created"}
{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"14a0f2b42d59a5a18e901c935c1fe15198d6a8d308917359b840504b74700cb8","seq":51,"ts":"2026-10-29T20:33:28Z","type":"account.added"}
{"payload":{"close_to":"retained_earnings","closing_entry":10,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":10,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":5,"amount":"52.00","currency":"USD","side":"credit"},{"account":7,"amount":"648.00","currency":"USD","side":"credit"}]},"id":1},"prev":"724a1d45168bcb5282dd894952b98aa4b364641158c42e9204758de8c2afeba2","seq":52,"ts":"2026-10-29T20:33:28Z","type":"period.closed"}
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more