Files
wanderbooks/crates/ledger-cli/tests/spec_mcp.rs
T
swaits e801908a88 test(mcp): exercise the write-off in the happy path (P4b S7)
spec_mcp's inv1 walks every tool once and fails on any the books never
answered: the happy path now creates an invoice that will not be paid,
uploads the collection letter, and writes it off with the evidence in the
same call, so books.write_off_invoice's output schema is checked like
every other tool's.
2026-10-27 12:44:45 -06:00

2768 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-blind tests for the books server, driven through a real
//! `wanderbooks server` as an agent reaches it (common.rs).
//!
//! Expected behavior comes only from the GOAL/SOURCE/INVARIANTS blocks of
//! `src/server.rs` and `src/policy.rs`, the server's connect text and handbook,
//! `docs/mcp-interface.md`, `docs/spec/sources/{mcp-tools,jsonrpc-2.0,
//! irs-records}.md`, and the server's own `tools/list` (the declared
//! interface: names, input and output schemas). Where those are silent the
//! tests don't assert.
//!
//! Every tool call goes through [`Mcp::tool`], which checks, whatever the
//! test: one response with the request's id, result xor error (JSON-RPC
//! 2.0 §5); a refused call (isError or a JSON-RPC error) leaves
//! `journal.jsonl` byte-identical (server invariant 2); a success has
//! `structuredContent` that validates against the tool's `outputSchema`
//! and the same JSON in a text block (invariant 1); no money key holds
//! a JSON number or a string outside `^-?\d+\.\d{2,}$` (invariant 3);
//! and a tool annotated readOnlyHint leaves the journal and the set of
//! files under the data dir as they were, success or refusal (plan 4.9,
//! "Annotations (P0)": the hint is true in behaviour).
use common::{Place, Wire};
use proptest::prelude::*;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::Duration;
mod common;
/// How long any single response may take before the server counts as hung.
/// Long enough for a server's first answer while a mutation run's jobs
/// contend for the disk (every journal append is synced).
const TIMEOUT: Duration = common::TIMEOUT;
/// Random cases for the state machine: 64 on every check; `MCP_CASES`
/// sets fewer for a mutation run, more for a deeper one.
fn cases() -> u32 {
std::env::var("MCP_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(64)
}
/// Keys whose values are money. Derived from the `outputSchema`s: every
/// string property described as an amount or "decimal string", plus the
/// payout totals (`gross`, `fees`, `adjustments`, `deposit`), the
/// reconciliation `difference`, and the doc's `debits`/`credits`/
/// `before`/`after`. Arrays and objects under these keys (e.g. `assets`
/// in `list_assets`) are walked, not checked.
const MONEY_KEYS: &[&str] = &[
"accumulated",
"adjustments",
"after",
"amount",
"amount_1099",
"assets",
"balance",
"balance_after",
"balance_before",
"before",
"billed",
"book_balance",
"book_value",
"cash_beginning",
"cash_ending",
"cash_from_financing",
"cash_from_investing",
"cash_from_operations",
"cleared_balance",
"cost",
"credit",
"credits",
"current",
"current_earnings",
"days_30",
"days_60",
"days_90",
"days_90_plus",
"debit",
"debits",
"deposit",
"depreciation",
"difference",
"equity",
"expenses",
"fees",
"final_depreciation",
"gain_loss",
"gross",
"gross_pay",
"invoiced",
"liabilities",
"movement",
"net_change_in_cash",
"net_income",
"net_pay",
"owed",
"paid",
"proceeds",
"received",
"receivable",
"remaining",
"revenue",
"salvage_value",
"statement_balance",
"threshold",
"total",
"total_credits",
"total_debits",
"total_gross",
"total_net",
];
// ---------------------------------------------------------------- harness
/// What a `tools/call` came back as.
#[derive(Debug, Clone)]
enum Out {
/// Success: the `structuredContent`.
Ok(Value),
/// `isError: true`: the first text block.
ToolErr(String),
/// A JSON-RPC error object.
RpcErr(Value),
}
impl Out {
fn ok(self, what: &str) -> Value {
match self {
Out::Ok(v) => v,
other => panic!("{what}: expected success, got {other:?}"),
}
}
fn refused(&self) -> bool {
!matches!(self, Out::Ok(_))
}
fn err_text(&self) -> String {
match self {
Out::Ok(v) => panic!("expected a refusal, got {v}"),
Out::ToolErr(t) => t.clone(),
Out::RpcErr(e) => e.to_string(),
}
}
}
struct Mcp {
wire: Wire,
next_id: u64,
data: PathBuf,
schemas: HashMap<String, Value>,
validators: HashMap<String, jsonschema::Validator>,
/// Tools that returned a success at least once.
exercised: BTreeSet<String>,
/// readOnlyHint tools that succeeded and were seen to change nothing.
read_only_checked: BTreeSet<String>,
/// Invariant 3 (amounts out) violations: `tool: path = value`.
money_violations: Vec<String>,
/// The test's place, when the session owns it (its server is closed
/// and its home removed after the session).
books_dir: Option<TestDir>,
}
/// A test's place (common.rs): its home, its server, its books.
type TestDir = Place;
fn test_dir(name: &str) -> TestDir {
Place::new(name)
}
/// Write a `wanderbooks.toml` with the given receipt threshold and extra
/// TOML (appended under `[books]` unless it opens its own table).
fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{extra}"
),
)
.unwrap();
path
}
impl Mcp {
/// Start `dir`'s server with `toml`'s settings, initialize, and load
/// `tools/list`.
fn start(dir: &TestDir, toml: &Path) -> Self {
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
let mut mcp = Self {
books_dir: None,
wire,
next_id: 1,
data: dir.books_data(),
schemas: HashMap::new(),
validators: HashMap::new(),
exercised: BTreeSet::new(),
read_only_checked: BTreeSet::new(),
money_violations: Vec::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-test", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
mcp.send_line(
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
);
let list = mcp.rpc("tools/list", json!({}));
for tool in list["result"]["tools"].as_array().expect("tools") {
let name = tool["name"].as_str().unwrap().to_string();
let schema = tool["outputSchema"].clone();
assert!(schema.is_object(), "{name} declares no outputSchema");
let validator = jsonschema::validator_for(&schema)
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not a valid schema: {e}"));
mcp.validators.insert(name.clone(), validator);
mcp.schemas.insert(name, tool.clone());
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// What a read-only tool must leave as it was: every file of the books
/// (their folder in the data root, the vault in it), by path, each with
/// the SHA-256 of its contents; but what every call writes by design
/// (common.rs `book_files`: the access log, the viewer's side channel,
/// the books' own backup, files handed over or on their way in). The
/// database is compared by what it holds (every row of every table, in
/// order: SQLite moves pages between the file and its write-ahead log
/// on its own, and a reader writes its read marks into the
/// shared-memory file), so the -wal and -shm files are left out.
fn data_files(&self) -> BTreeMap<PathBuf, String> {
common::book_files(&self.data)
.into_iter()
.map(|(rel, sha)| {
let p = self.data.join(&rel);
let name = rel.file_name().unwrap().to_string_lossy().into_owned();
if ledger_store::is_database_file(&name) {
(p.clone(), db_digest(&p))
} else {
(p, sha)
}
})
.collect()
}
fn read_only(&self, name: &str) -> bool {
self.schemas
.get(name)
.is_some_and(|t| t["annotations"]["readOnlyHint"] == json!(true))
}
fn send_line(&mut self, line: &str) {
self.wire.send_line(line);
}
/// The next JSON-RPC response (messages without an id, i.e. server
/// notifications, are skipped). `None` when the server sent none.
fn recv(&mut self, timeout: Duration) -> Option<Value> {
self.wire.recv(timeout)
}
/// Send a request and return its response, checking JSON-RPC 2.0 §5:
/// one response, same id, `jsonrpc: "2.0"`, result xor error.
fn rpc(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
self.send_line(&msg.to_string());
let resp = self
.recv(TIMEOUT)
.unwrap_or_else(|| panic!("no response to {method} (id {id}): server hung or died"));
check_response_shape(&resp);
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool and check invariants 1, 2, 3 and 7 on the outcome.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
let files = self.read_only(name).then(|| self.data_files());
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone())
} else {
let result = &resp["result"];
if result.get("isError") == Some(&json!(true)) {
let text = result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string();
Out::ToolErr(text)
} else {
Out::Ok(self.check_success(name, result))
}
};
if out.refused() {
assert!(
self.journal() == before,
"invariant 2: refused {name} {args} changed the journal ({out:?})"
);
}
if let Some(files) = files {
assert!(
self.journal() == before,
"readOnlyHint: {name} {args} changed the journal"
);
assert_eq!(
self.data_files(),
files,
"readOnlyHint: {name} {args} changed the files of the books"
);
if !out.refused() {
self.read_only_checked.insert(name.to_string());
}
}
out
}
/// Invariant 1 (schema + text block) and invariant 3 (amounts out).
fn check_success(&mut self, name: &str, result: &Value) -> Value {
let sc = result
.get("structuredContent")
.unwrap_or_else(|| panic!("{name}: success without structuredContent: {result}"))
.clone();
let validator = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = validator
.iter_errors(&sc)
.map(|e| format!("{} at {}", e, e.instance_path()))
.collect();
assert!(
errors.is_empty(),
"invariant 1: {name} result violates its outputSchema: {errors:?}\n{sc}"
);
let same_text = result["content"].as_array().is_some_and(|blocks| {
blocks.iter().any(|b| {
b["type"] == "text"
&& b["text"]
.as_str()
.and_then(|t| serde_json::from_str::<Value>(t).ok())
.is_some_and(|v| v == sc)
})
});
assert!(
same_text,
"invariant 1: {name} has no text block holding its structuredContent"
);
let mut bad = Vec::new();
money_walk(&sc, "$", None, &mut bad);
// A key the tool's outputSchema declares an integer is a count,
// not money (books.access_log's `total`).
let counts: Vec<String> = self.schemas[name]["outputSchema"]["properties"]
.as_object()
.map(|p| {
p.iter()
.filter(|(_, s)| s["type"] == "integer")
.map(|(k, _)| format!("$.{k} "))
.collect()
})
.unwrap_or_default();
for b in bad {
if counts.iter().any(|c| b.starts_with(c.as_str())) {
continue;
}
self.money_violations.push(format!("{name}: {b}"));
}
self.exercised.insert(name.to_string());
sc
}
/// `tool`, expecting success.
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
self.tool(name, args).ok(&format!("{name} {a}"))
}
/// `tool`, expecting a refusal (isError or JSON-RPC error).
fn refuse(&mut self, name: &str, args: Value) -> String {
let a = args.to_string();
let out = self.tool(name, args);
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
out.err_text()
}
/// Add accounts `(code, name, type)`; returns their ids.
fn accounts(&mut self, accts: &[(&str, &str, &str)]) -> Vec<u64> {
accts
.iter()
.map(|(code, name, ty)| {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)["account_id"]
.as_u64()
.unwrap()
})
.collect()
}
/// Draft an entry with `(account, side, amount)` lines; returns its id.
fn draft(&mut self, date: &str, desc: &str, lines: &[(&str, &str, &str)]) -> u64 {
let id = self.ok(
"books.draft_entry",
json!({"date": date, "description": desc}),
)["entry_id"]
.as_u64()
.unwrap();
for (account, side, amount) in lines {
self.ok(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
id
}
/// Upload `bytes` as an agent does (D-46): the upload_id.
fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
self.wire.upload(bytes, file_name, mime_type, purpose)
}
fn attach(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let upload = self.upload(bytes, "receipt.txt", "text/plain", "receipt");
let mut args = json!({"upload_id": upload});
if let Some(e) = entry {
args["entry_id"] = json!(e);
}
self.ok("books.attach_receipt", args)["attachment_id"]
.as_u64()
.unwrap()
}
fn post(&mut self, entry: u64) -> Out {
self.tool(
"books.post_entry",
json!({"entry_id": entry, "confirmed": true}),
)
}
fn entry_status(&mut self, entry: u64) -> String {
self.ok("books.get_entry", json!({"entry_id": entry}))["status"]
.as_str()
.unwrap()
.to_string()
}
}
fn check_response_shape(resp: &Value) {
assert_eq!(
resp["jsonrpc"], "2.0",
"not a JSON-RPC 2.0 response: {resp}"
);
let has_result = resp.get("result").is_some();
let has_error = resp.get("error").is_some();
assert!(
has_result ^ has_error,
"response must have exactly one of result/error: {resp}"
);
assert!(resp.get("id").is_some(), "response without id: {resp}");
if has_error {
assert!(
resp["error"]["code"].is_i64(),
"error code must be an integer: {resp}"
);
}
}
/// Invariant 3, amounts out: under a money key, no JSON number, and every
/// string matches `^-?\d+\.\d{2,}$`.
fn money_walk(v: &Value, path: &str, key: Option<&str>, bad: &mut Vec<String>) {
let money = key.is_some_and(|k| MONEY_KEYS.contains(&k));
match v {
Value::Number(_) if money => bad.push(format!("{path} = {v} (JSON number)")),
Value::String(s) if money && !is_money_out(s) => bad.push(format!("{path} = {s:?}")),
Value::Object(m) => {
for (k, child) in m {
money_walk(child, &format!("{path}.{k}"), Some(k), bad);
}
}
Value::Array(a) => {
for (i, child) in a.iter().enumerate() {
money_walk(child, &format!("{path}[{i}]"), key, bad);
}
}
_ => {}
}
}
/// `^-?\d+\.\d{2,}$`
fn is_money_out(s: &str) -> bool {
let s = s.strip_prefix('-').unwrap_or(s);
let Some((int, frac)) = s.split_once('.') else {
return false;
};
!int.is_empty()
&& int.bytes().all(|b| b.is_ascii_digit())
&& frac.len() >= 2
&& frac.bytes().all(|b| b.is_ascii_digit())
}
/// The amount-in grammar, server invariant 3: `-?digits[.digits]`.
fn is_amount_in(s: &str) -> bool {
let s = s.strip_prefix('-').unwrap_or(s);
let (int, frac) = match s.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (s, None),
};
!int.is_empty()
&& int.bytes().all(|b| b.is_ascii_digit())
&& frac.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()))
}
/// Canonical form of a grammatical decimal: no leading zeros, no trailing
/// fractional zeros. Compares two decimal strings by value.
fn canon(s: &str) -> String {
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r),
None => (false, s),
};
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
let int = int.trim_start_matches('0');
let frac = frac.trim_end_matches('0');
let int = if int.is_empty() { "0" } else { int };
let body = if frac.is_empty() {
int.to_string()
} else {
format!("{int}.{frac}")
};
if neg && body != "0" {
format!("-{body}")
} else {
body
}
}
/// A chart used by several tests.
const BASE_CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1200", "Accounts Receivable", "asset"),
("2000", "Accounts Payable", "liability"),
("2100", "Due to Shareholders", "liability"),
("3000", "Capital A", "equity"),
("3001", "Capital B", "equity"),
("4000", "Revenue", "revenue"),
("6000", "Rent", "expense"),
("6999", "General Expenses", "expense"),
];
// ------------------------------------------------- invariant 1: schemas
/// Every tool, read-only and mutating, on a scripted happy path; every
/// success validated by `Mcp::tool`. Returns the server for inspection.
fn happy_path(name: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(
&dir,
"1000000.00",
"financing_accounts = [\"2100\"]\n\
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
);
let mut m = Mcp::start(&dir, &toml);
// Empty books: every read-only tool.
m.ok("books.chart", json!({}));
m.ok(
"books.chart_template",
json!({"entity_type": "partnership", "owners": ["A", "B"]}),
);
let cats = m.ok("books.tax_categories", json!({}));
m.ok("books.trial_balance", json!({}));
m.ok("books.balance_sheet", json!({}));
m.ok("books.profit_and_loss", json!({}));
m.ok("books.list_assets", json!({}));
m.ok("books.list_classes", json!({}));
m.ok("books.list_payroll_runs", json!({}));
m.ok("books.list_periods", json!({}));
m.ok("books.list_receipts", json!({}));
m.ok("books.list_recurring", json!({}));
m.ok("books.list_schedules", json!({}));
m.ok("books.list_vendors", json!({}));
m.ok("books.parties", json!({}));
m.ok("books.digest", json!({"since": "2026-01-01"}));
// Chart.
m.accounts(BASE_CHART);
m.accounts(&[
("1300", "Prepaid Expenses", "asset"),
("1500", "Equipment", "asset"),
("1510", "Accumulated Depreciation", "asset"),
("2400", "Deferred Revenue", "liability"),
("3999", "Retained Earnings", "equity"),
("6010", "Platform Fees", "expense"),
("6500", "Depreciation Expense", "expense"),
]);
let chart = m.upload(
b"code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n",
"chart.csv",
"text/csv",
"import",
);
m.ok("books.import_chart_csv", json!({"upload_id": chart}));
m.ok(
"books.renumber_account",
json!({"account": "7000", "code": "7100"}),
);
m.ok(
"books.rename_account",
json!({"account": "7100", "name": "Gain or Loss on Sale", "reason": "clearer"}),
);
m.ok("books.settings", json!({}));
m.ok("books.roles", json!({}));
m.ok(
"books.assign_role",
json!({"role": "general_expense", "account": "7100"}),
);
m.ok(
"books.assign_role",
json!({"role": "general_expense", "account": null}),
);
let deduction = cats["categories"]
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "deduction")
.expect("a deduction category")["key"]
.clone();
m.ok(
"books.set_tax_category",
json!({"account": "6000", "category": deduction}),
);
// Classes, parties, 1099.
m.ok("books.add_class", json!({"name": "Rondo"}));
m.ok("books.list_classes", json!({}));
m.ok(
"books.add_project",
json!({"name": "Harbor app", "kind": "on_spec", "started_on": "2026-01-05"}),
);
m.ok(
"books.update_project",
json!({"project": "Rondo", "kind": "internal", "description": "The Rondo app"}),
);
m.ok("books.projects", json!({}));
m.ok("books.project", json!({"project": "Rondo"}));
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"]
.as_u64()
.unwrap();
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true, "decided_by": "Test Owner"}),
);
m.ok("books.list_vendors", json!({}));
// Payment methods (plan P3, defect 12).
m.ok(
"books.add_instrument",
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "1000"}),
);
m.ok("books.instruments", json!({}));
m.ok(
"books.add_party",
json!({"name": "Pat", "roles": ["employee"]}),
);
m.ok(
"books.update_party",
json!({"party": "Pat", "display_name": "Pat D.", "notes": "first hire"}),
);
m.ok(
"books.set_w4",
json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}),
);
m.ok(
"books.set_tin",
json!({"party": "Pat", "tin": "291-12-2846", "tin_type": "ssn"}),
);
m.ok(
"books.set_party_roles",
json!({"party": "Landlord", "add": ["landlord"]}),
);
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
let w9 = m.upload(w9_pdf, "w9.pdf", "application/pdf", "w9");
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "79-9714321", "tin_type": "ein", "w9_upload_id": w9}),
);
m.ok("books.get_w9", json!({"party": "Landlord"}));
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
// Owner contribution: draft, lines, receipt, preview, post.
let e1 = m.draft(
"2026-01-02",
"Owner contribution",
&[
("1000", "debit", "10000.00"),
("3000", "credit", "5000.00"),
("3001", "credit", "5000.00"),
],
);
m.attach(Some(e1), b"contribution receipt");
m.ok("books.post_entry", json!({"entry_id": e1}));
m.ok(
"books.post_entry",
json!({"entry_id": e1, "confirmed": true, "expected_total": "10000.00"}),
);
m.ok("books.get_entry", json!({"entry_id": e1}));
// Rent: vendor, class, unlinked receipt then link, clearing.
let e2 = m.ok(
"books.draft_entry",
json!({"date": "2026-01-15", "description": "January rent", "vendor": "Landlord"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": e2, "account": "6000", "side": "debit", "amount": "100.00", "class": "Rondo"}),
);
m.ok(
"books.add_line",
json!({"entry_id": e2, "account": "1000", "side": "credit", "amount": "100.00"}),
);
let r_old = m.attach(None, b"rent receipt v1");
m.ok(
"books.link_receipt",
json!({"attachment_id": r_old, "entry_id": e2}),
);
m.post(e2).ok("post rent");
m.ok(
"books.mark_cleared",
json!({"entry_id": e2, "line_index": 1, "cleared": true}),
);
m.ok(
"books.set_entry_vendor",
json!({"entry_id": e2, "vendor": vendor.to_string()}),
);
m.ok(
"books.set_entry_party",
json!({"entry_id": e2, "party": "Landlord"}),
);
// Paid with the debit card, then the tag cleared.
m.ok(
"books.set_entry_instrument",
json!({"entry_id": e2, "instrument": "Harbor Visa"}),
);
m.ok("books.set_entry_instrument", json!({"entry_id": e2}));
// Replace the receipt: attach new, unlink old, retire it.
m.attach(Some(e2), b"rent receipt v2");
m.ok(
"books.unlink_receipt",
json!({"attachment_id": r_old, "reason": "replaced by v2"}),
);
m.ok(
"books.withdraw_attachment",
json!({"attachment_id": r_old, "reason": "superseded"}),
);
m.ok("books.list_receipts", json!({"include_withdrawn": true}));
m.ok("books.list_receipts", json!({"entry_id": e2}));
m.ok("books.get_receipt", json!({"attachment_id": r_old}));
m.ok(
"books.get_receipt",
json!({"attachment_id": r_old, "view": "bytes"}),
);
let oa = m.upload(
b"operating agreement",
"agreement.txt",
"text/plain",
"document",
);
let doc = m.ok(
"books.file_document",
json!({"category": "legal", "upload_id": oa, "description": "OA"}),
)["attachment_id"]
.as_u64()
.unwrap();
let lease = m.upload(b"lease", "lease.txt", "text/plain", "document");
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "upload_id": lease, "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.link_attachment",
json!({"attachment_id": filed, "subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.attachments",
json!({"subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
);
// An upload of bytes already stored: begin_upload names the
// attachment that holds them.
let local = m.attach(None, b"a local receipt");
let up = m.ok(
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(b"a local receipt"), "size": 15, "mime_type": "text/plain",
"file_name": "local-receipt.txt", "purpose": "receipt"}),
);
assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
// Reversal: preview, then confirmed.
let e3 = m.draft(
"2026-01-20",
"Mistake",
&[("6999", "debit", "50.00"), ("1000", "credit", "50.00")],
);
m.attach(Some(e3), b"mistake receipt");
m.post(e3).ok("post mistake");
let rev = json!({"entry_id": e3, "date": "2026-01-21", "description": "Reverse mistake"});
m.ok("books.reverse_entry", rev.clone());
let mut rev_c = rev;
rev_c["confirmed"] = json!(true);
m.ok("books.reverse_entry", rev_c);
// FX.
m.ok(
"books.set_fx_rate",
json!({"currency": "EUR", "date": "2026-01-01", "rate": "1.10"}),
);
let fx = m.ok(
"books.draft_entry",
json!({"date": "2026-04-22", "description": "EUR draft"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": fx, "account": "6999", "side": "debit", "amount": "11.00",
"foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}),
);
// 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",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
"lines": [{"description": "Office rent", "amount": "300.00"}]}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.pay_bill",
json!({"document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}),
);
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
// 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",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.void_document",
json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}),
);
m.ok("books.control_check", json!({"as_of": "2026-06-30"}));
m.ok(
"books.documents",
json!({"kind": "invoices", "status": "all"}),
);
m.ok("books.document", json!({"ref": format!("invoice:{inv}")}));
// 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",
"lines": [{"description": "Consulting, never paid", "amount": "400.00"}]}),
)["id"]
.as_u64()
.unwrap();
let letter = m.upload(b"Final demand", "letter.txt", "text/plain", "document");
m.ok(
"books.write_off_invoice",
json!({"invoice": bad, "date": "2026-03-01", "reason": "collection exhausted",
"upload_id": letter, "confirmed": true}),
);
// Fixed assets.
let registered = m.ok(
"books.register_asset",
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00",
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
"accum_depr_account": "1510", "expense_account": "6500"}),
);
assert_eq!(registered["tag"], "S-0001", "\"Spec Co\" without \"Co\": S");
let asset = registered["asset_id"].as_u64().unwrap();
m.ok("books.asset_schedule", json!({"asset_id": asset}));
m.ok("books.asset_schedule", json!({"tag": "s-0001"}));
m.ok(
"books.label_asset",
json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}),
);
m.ok(
"books.set_asset_tax_basis",
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00", "fmv_at_contribution": "3000.00"}),
);
m.ok(
"books.correct_asset",
json!({"asset": "LAPTOP-1", "useful_life_months": 48, "reason": "registered with the wrong life"}),
);
m.ok("books.asset_labels", json!({}));
m.ok(
"books.asset_labels",
json!({"assets": ["LAPTOP-1"], "skip": 3}),
);
m.ok(
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
);
m.ok("books.list_assets", json!({}));
m.ok("books.run_depreciation", json!({"period": "2026-01"}));
let dispose = json!({"asset_id": asset, "date": "2026-06-15", "proceeds": "1000.00",
"proceeds_account": "1000", "gain_loss_account": "7100"});
m.ok("books.dispose_asset", dispose.clone());
let mut dispose_c = dispose;
dispose_c["confirmed"] = json!(true);
dispose_c["receipt_attachment_ids"] = json!([doc]);
m.ok("books.dispose_asset", dispose_c);
// Schedules and recurring.
m.ok(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "Insurance", "total": "1200.00",
"start": "2026-01-01", "months": 12, "balance_account": "1300", "pl_account": "6999"}),
);
m.ok("books.list_schedules", json!({}));
m.ok("books.run_schedules", json!({"through": "2026-03-31"}));
m.ok(
"books.run_schedules",
json!({"through": "2026-03-31", "confirmed": true}),
);
m.ok(
"books.create_recurring",
json!({"description": "Hosting", "frequency": "monthly", "start": "2026-04-30",
"end": "2026-06-30", "lines": [
{"account": "6999", "side": "debit", "amount": "10.00"},
{"account": "1000", "side": "credit", "amount": "10.00"}]}),
);
m.ok("books.list_recurring", json!({}));
m.ok("books.run_recurring", json!({"through": "2026-06-30"}));
// Payouts.
m.ok(
"books.record_payout",
json!({"date": "2026-02-28", "description": "App Store payout", "deposit_account": "1000",
"deposit": "85.00", "revenue": [{"account": "4000", "amount": "100.00"}],
"fees": [{"account": "6010", "amount": "15.00"}]}),
);
// Payroll.
m.ok(
"books.run_payroll",
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{
"employee": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00",
"state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50",
"ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}]}),
);
m.ok("books.list_payroll_runs", json!({}));
m.ok(
"books.remit_payroll_taxes",
json!({"date": "2026-02-20", "cash_account": "1000"}),
);
// Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "document");
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "upload_id": up}),
)["attachment_id"]
.as_u64()
.unwrap();
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
m.ok(
"books.import_bank_csv",
json!({"upload_id": up, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}),
);
m.ok(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}),
);
// January's statement: the contribution and the rent cleared.
m.ok(
"books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}),
);
let up = m.upload(
b"%PDF statement",
"2026-01.pdf",
"application/pdf",
"document",
);
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "upload_id": up}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.record_statement",
json!({"attachment_id": stmt, "account": "1000", "period_start": "2026-01-01",
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "9900.00"}),
);
m.ok("books.statements", json!({}));
m.ok("books.reconcile", json!({"statement_attachment": stmt}));
let rec = m.ok(
"books.reconcile",
json!({"statement_attachment": stmt, "confirmed": true}),
)["reconciliation"]["reconciliation_id"]
.as_u64()
.unwrap();
m.ok("books.reconciliations", json!({"account": "1000"}));
m.ok("books.show", json!({"reconciliation": rec}));
m.ok(
"books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
);
// Voided, its statement can be voided too, to record it again.
m.ok(
"books.void_statement",
json!({"attachment_id": stmt, "reason": "closing balance mistyped"}),
);
// Periods.
let p = m.ok(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
)["period_id"]
.as_u64()
.unwrap();
m.ok("books.list_periods", json!({}));
m.ok("books.close_period", json!({"period_id": p}));
m.ok(
"books.close_period",
json!({"period_id": p, "confirmed": true}),
);
// 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!({}));
m.ok("books.start", json!({"focus": "close"}));
m.ok("books.conventions", json!({}));
m.ok("books.decisions", json!({}));
m.ok("books.open_items", json!({}));
m.ok(
"books.set_convention",
json!({"id": "note-1", "kind": "note", "title": "A note",
"body_md": "Kept for the test.", "decided_by": "A"}),
);
m.ok(
"books.set_convention",
json!({"id": "contribution", "kind": "posting_template",
"title": "Joint contribution",
"body_md": "Cash in from both members, half each.",
"structured": {"lines": [
{"account": "1000", "side": "debit", "share": "1"},
{"account": "3000", "side": "credit", "share": "0.5"},
{"account": "3001", "side": "credit", "share": "0.5"}]},
"decided_by": "A", "decided_on": "2026-10-16"}),
);
let drafted = m.ok(
"books.draft_from_template",
json!({"template_id": "contribution", "date": "2026-06-01",
"amount": "100.01", "no_party_reason": "between the members"}),
);
m.ok(
"books.approve_entry",
json!({"entry_id": drafted["entry_id"], "approver": "A"}),
);
m.ok(
"books.retire_convention",
json!({"id": "note-1", "reason": "Done with it.", "decided_by": "A"}),
);
m.ok("books.conventions", json!({"include_retired": true}));
m.ok(
"books.record_decision",
json!({"subject": "Basis", "decision_md": "Accrual.",
"decided_by": ["A", "B"], "decided_on": "2026-01-01"}),
);
m.ok("books.decisions", json!({"include_superseded": true}));
let item = m.ok(
"books.raise_item",
json!({"title": "Mail the letter", "due": "2026-11-01", "decided_by": "A"}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.resolve_item",
json!({"id": item, "resolution": "Mailed.", "decided_by": "A"}),
);
m.ok("books.open_items", json!({"include_resolved": true}));
m.ok(
"books.set_setting",
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}],
"decided_by": "A"}),
);
m.ok("books.export_settings", json!({}));
m.ok("books.start", json!({}));
// The handbook: its index, and a section.
m.ok("books.handbook", json!({}));
m.ok("books.handbook", json!({"section": "contract"}));
// Reports over the populated books.
m.ok("books.chart", json!({}));
m.ok("books.trial_balance", json!({"as_of": "2026-12-31"}));
m.ok("books.balance_sheet", json!({"as_of": "2026-12-31"}));
m.ok(
"books.profit_and_loss",
json!({"start": "2026-01-01", "end": "2026-12-31"}),
);
m.ok("books.profit_and_loss", json!({"class": "Rondo"}));
m.ok(
"books.profit_and_loss_by_class",
json!({"start": "2026-01-01", "end": "2026-12-31"}),
);
m.ok(
"books.cash_flow",
json!({"start": "2026-01-01", "end": "2026-12-31"}),
);
m.ok("books.digest", json!({"since": "2026-01-01"}));
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
m.ok("books.vendor_1099", json!({"year": 2026}));
m.ok("books.vendor_1099", json!({"year": 2026, "csv": true}));
m.ok(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true, "csv": true}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge"}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge", "confirmed": true}),
);
m.ok("books.party", json!({"party": "Landlord", "year": 2026}));
m.ok("books.party", json!({"party": "Pat", "year": 2026}));
m.ok("books.party", json!({"party": "Client"}));
m.ok("books.verify", json!({}));
m.ok("books.show", json!({"party": "Landlord"}));
m.ok("books.show", json!({"entry_id": e1}));
m.ok(
"books.show",
json!({"report": "balance_sheet", "end": "2026-12-31"}),
);
for (format, report) in [
("csv", "trial_balance"),
("json", "profit_and_loss"),
("xlsx", "balance_sheet"),
("pdf", "profit_and_loss"),
("csv", "cash_flow"),
("csv", "general_ledger"),
("json", "members_equity"),
("csv", "tax_summary"),
] {
m.ok(
"books.export",
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}),
);
}
// The IIF takes as_of (every posted entry from the first through it),
// never a range.
m.ok(
"books.export",
json!({"format": "iif", "report": "general_journal", "as_of": "2026-12-31"}),
);
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
);
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false}),
);
// The access log, the backups' status, and a backup now (none is set
// up: it says so).
m.ok("books.access_log", json!({}));
m.ok("books.backup", json!({}));
m.ok("books.back_up_now", json!({}));
m.books_dir = Some(dir);
m
}
/// Invariant 1 over every tool. Fails listing any tool never exercised
/// successfully.
#[test]
fn inv1_every_tool_result_matches_its_output_schema() {
let m = happy_path("inv1");
let missing: Vec<&String> = m
.schemas
.keys()
.filter(|t| !m.exercised.contains(*t))
.collect();
assert!(missing.is_empty(), "tools never exercised: {missing:?}");
}
/// The read-only hint is true in behaviour: every tool annotated
/// readOnlyHint succeeded on the fixture company and left the journal,
/// every file under the data dir (its contents, the database by every row
/// it holds), the vault and the settings file exactly as they were
/// (checked in [`Mcp::tool`] on every call).
#[test]
fn read_only_tools_change_nothing() {
let m = happy_path("read-only");
let read_only: Vec<&String> = m.schemas.keys().filter(|t| m.read_only(t)).collect();
assert!(read_only.len() > 30, "only {read_only:?}");
let unchecked: Vec<&&String> = read_only
.iter()
.filter(|t| !m.read_only_checked.contains(**t))
.collect();
assert!(
unchecked.is_empty(),
"read-only tools never checked: {unchecked:?}"
);
}
/// Invariant 3, amounts out, weak form: no money key ever holds a JSON
/// number, over every tool's results.
#[test]
fn inv3_amounts_out_are_never_json_numbers() {
let m = happy_path("inv3-out-numbers");
let numbers: Vec<&String> = m
.money_violations
.iter()
.filter(|v| v.contains("JSON number"))
.collect();
assert!(numbers.is_empty(), "{numbers:#?}");
}
/// Invariant 3, amounts out, full form: every amount is a decimal string
/// with at least two places.
#[test]
fn inv3_amounts_out_have_at_least_two_places() {
let m = happy_path("inv3-out-places");
assert!(
m.money_violations.is_empty(),
"invariant 3 (amounts out): {:#?}",
m.money_violations
);
}
// ------------------------------------- invariants 2 and 7: state machine
/// An id argument: usually one the books handed out (by index into what
/// this run has created), sometimes a raw number that may not exist.
#[derive(Debug, Clone, Copy)]
enum Pick {
Known(usize),
Raw(u64),
}
impl Pick {
fn resolve(self, known: &[u64]) -> u64 {
match self {
Pick::Known(i) if !known.is_empty() => known[i % known.len()],
Pick::Known(i) => i as u64,
Pick::Raw(n) => n,
}
}
}
fn pick() -> impl Strategy<Value = Pick> {
prop_oneof![3 => (0usize..16).prop_map(Pick::Known), 1 => (0u64..12).prop_map(Pick::Raw)]
}
/// Ids handed out so far in a run.
#[derive(Default)]
struct Known {
entries: Vec<u64>,
attachments: Vec<u64>,
periods: Vec<u64>,
vendors: Vec<u64>,
bills: Vec<u64>,
}
/// One step of the random walk.
#[derive(Debug, Clone)]
enum Op {
Draft {
date: String,
desc: String,
},
/// Draft plus two lines (debit `account`, credit 1000), optionally
/// with a receipt: several calls, each checked.
Balanced {
date: String,
desc: String,
account: String,
amount: String,
receipt: bool,
},
AddLine {
entry: Pick,
account: String,
side: String,
amount: String,
},
Post {
entry: Pick,
confirmed: Option<bool>,
},
Reverse {
entry: Pick,
date: String,
confirmed: Option<bool>,
},
/// A receipt: these bytes, or (None) bytes given inline.
Attach {
entry: Option<Pick>,
content: Option<Vec<u8>>,
},
Link {
att: Pick,
entry: Pick,
},
Unlink {
att: Pick,
},
Withdraw {
att: Pick,
},
CreatePeriod {
start: String,
end: String,
},
ClosePeriod {
id: Pick,
confirmed: bool,
},
AddAccount {
code: String,
ty: String,
},
AddVendor {
name: String,
},
CreateBill {
party: Pick,
amount: String,
},
PayBill {
doc: Pick,
amount: String,
},
Payout {
deposit: String,
},
Renumber {
account: String,
code: String,
},
Read(u8),
UnknownTool,
MissingArgs,
WrongTypes,
}
fn date_strategy() -> impl Strategy<Value = String> {
prop_oneof![
8 => (1u32..=6, 1u32..=28).prop_map(|(m, d)| format!("2026-{m:02}-{d:02}")),
1 => prop::sample::select(vec![
"2026-02-30", "2026-13-01", "26-1-1", "", "2026/01/05", "yesterday", "2026-1-5",
])
.prop_map(String::from),
]
}
fn amount_strategy() -> impl Strategy<Value = String> {
prop_oneof![
6 => (1u64..=6000).prop_map(|c| format!("{}.{:02}", c / 100, c % 100)),
2 => prop::sample::select(vec!["25.00", "25.01", "20.00", "5"]).prop_map(String::from),
2 => prop::sample::select(vec![
"1e3", "+5", ".5", "5.", "1,000", " 5", "abc", "", "-", "-5.00", "0", "0.001", "NaN",
])
.prop_map(String::from),
]
}
fn account_strategy() -> impl Strategy<Value = String> {
prop::sample::select(vec![
"1000", "1000", "2000", "4000", "6000", "6000", "6999", "1", "3", "9999", "", "abc",
])
.prop_map(String::from)
}
fn desc_strategy() -> impl Strategy<Value = String> {
prop::sample::select(vec!["Coffee", "coffee ", "Rent", "Supplies"]).prop_map(String::from)
}
fn op_strategy() -> impl Strategy<Value = Op> {
let confirmed = || prop::option::of(any::<bool>());
prop_oneof![
2 => (date_strategy(), desc_strategy()).prop_map(|(date, desc)| Op::Draft { date, desc }),
5 => (date_strategy(), desc_strategy(),
prop::sample::select(vec!["6000", "6999", "2000", "9999"]),
amount_strategy(), any::<bool>())
.prop_map(|(date, desc, a, amount, receipt)| Op::Balanced {
date, desc, account: a.to_string(), amount, receipt,
}),
5 => (pick(), account_strategy(),
prop::sample::select(vec!["debit", "credit", "debit", "credit", "Debit", "sideways"]),
amount_strategy())
.prop_map(|(entry, account, s, amount)| Op::AddLine { entry, account, side: s.to_string(), amount }),
6 => (pick(), confirmed()).prop_map(|(entry, confirmed)| Op::Post { entry, confirmed }),
2 => (pick(), date_strategy(), confirmed())
.prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }),
3 => (prop::option::of(pick()), prop_oneof![
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(Some),
1 => Just(None),
])
.prop_map(|(entry, content)| Op::Attach { entry, content }),
1 => (pick(), pick()).prop_map(|(att, entry)| Op::Link { att, entry }),
1 => pick().prop_map(|att| Op::Unlink { att }),
1 => pick().prop_map(|att| Op::Withdraw { att }),
1 => (date_strategy(), date_strategy()).prop_map(|(start, end)| Op::CreatePeriod { start, end }),
1 => (pick(), any::<bool>()).prop_map(|(id, confirmed)| Op::ClosePeriod { id, confirmed }),
1 => (prop::sample::select(vec!["1000", "5000", "5001", "", "x"]),
prop::sample::select(vec!["asset", "expense", "revenue", "bogus"]))
.prop_map(|(c, t)| Op::AddAccount { code: c.to_string(), ty: t.to_string() }),
1 => prop::sample::select(vec!["Acme", "Acme", "", "1"]).prop_map(|n| Op::AddVendor { name: n.to_string() }),
1 => (pick(), amount_strategy()).prop_map(|(party, amount)| Op::CreateBill { party, amount }),
1 => (pick(), amount_strategy()).prop_map(|(doc, amount)| Op::PayBill { doc, amount }),
1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }),
1 => (account_strategy(), prop::sample::select(vec!["8000", "1000", ""]))
.prop_map(|(account, c)| Op::Renumber { account, code: c.to_string() }),
3 => (0u8..6).prop_map(Op::Read),
1 => Just(Op::UnknownTool),
1 => Just(Op::MissingArgs),
1 => Just(Op::WrongTypes),
]
}
/// The id a success handed out under `key`, if any.
fn new_id(out: &Out, key: &str) -> Option<u64> {
match out {
Out::Ok(v) => v[key].as_u64(),
_ => None,
}
}
fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
let opt = |v: Option<bool>| v.map_or(Value::Null, Value::Bool);
let (tool, args) = match op {
Op::Draft { date, desc } => (
"books.draft_entry",
json!({"date": date, "description": desc}),
),
Op::Balanced {
date,
desc,
account,
amount,
receipt,
} => {
let out = m.tool(
"books.draft_entry",
json!({"date": date, "description": desc}),
);
let Some(e) = new_id(&out, "entry_id") else {
return;
};
k.entries.push(e);
for (acct, side) in [(account.as_str(), "debit"), ("1000", "credit")] {
m.tool(
"books.add_line",
json!({"entry_id": e, "account": acct, "side": side, "amount": amount}),
);
}
if *receipt {
let bytes = format!("receipt for entry {e}");
let up = m.upload(bytes.as_bytes(), "r.txt", "text/plain", "receipt");
let out = m.tool(
"books.attach_receipt",
json!({"entry_id": e, "upload_id": up}),
);
k.attachments.extend(new_id(&out, "attachment_id"));
}
return;
}
Op::AddLine {
entry,
account,
side,
amount,
} => (
"books.add_line",
json!({"entry_id": entry.resolve(&k.entries), "account": account, "side": side,
"amount": amount}),
),
Op::Post { entry, confirmed } => (
"books.post_entry",
json!({"entry_id": entry.resolve(&k.entries), "confirmed": opt(*confirmed)}),
),
Op::Reverse {
entry,
date,
confirmed,
} => (
"books.reverse_entry",
json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo",
"confirmed": opt(*confirmed)}),
),
Op::Attach { entry, content } => {
let entry = entry.map(|p| p.resolve(&k.entries));
// New bytes go by their upload; bytes stored already (begin_upload
// names their attachment) are attached by an upload there is
// none of; none at all, by the inline argument D-46 removed.
let given = match content {
Some(bytes) => {
let begun = m.wire.begin_and_send(
bytes,
"r.bin",
"application/octet-stream",
"receipt",
);
let up = begun["upload_id"]
.as_str()
.map_or_else(|| format!("up_{}", "a".repeat(32)), str::to_string);
json!({"upload_id": up})
}
None => json!({"content_base64": "cmVjZWlwdA=="}),
};
let mut args = json!({"entry_id": entry});
for (key, v) in given.as_object().unwrap() {
args[key] = v.clone();
}
("books.attach_receipt", args)
}
Op::Link { att, entry } => (
"books.link_receipt",
json!({"attachment_id": att.resolve(&k.attachments),
"entry_id": entry.resolve(&k.entries)}),
),
Op::Unlink { att } => (
"books.unlink_receipt",
json!({"attachment_id": att.resolve(&k.attachments), "reason": "test"}),
),
Op::Withdraw { att } => (
"books.withdraw_attachment",
json!({"attachment_id": att.resolve(&k.attachments), "reason": "test"}),
),
Op::CreatePeriod { start, end } => (
"books.create_period",
json!({"name": "P", "start": start, "end": end}),
),
Op::ClosePeriod { id, confirmed } => (
"books.close_period",
json!({"period_id": id.resolve(&k.periods), "confirmed": confirmed}),
),
Op::AddAccount { code, ty } => (
"books.add_account",
json!({"code": code, "name": format!("Acct {code}"), "account_type": ty}),
),
Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})),
Op::CreateBill { party, amount } => (
"books.create_bill",
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
"due_date": "2026-03-31", "lines": [{"description": "thing", "amount": amount}]}),
),
Op::PayBill { doc, amount } => (
"books.pay_bill",
json!({"document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
"cash_account": "1000"}),
),
Op::Payout { deposit } => (
"books.record_payout",
json!({"date": "2026-03-31", "description": "payout", "deposit_account": "1000",
"deposit": deposit, "revenue": [{"account": "4000", "amount": "100.00"}],
"fees": [{"account": "6999", "amount": "15.00"}]}),
),
Op::Renumber { account, code } => (
"books.renumber_account",
json!({"account": account, "code": code}),
),
Op::Read(i) => match i {
0 => ("books.chart", json!({})),
1 => ("books.trial_balance", json!({"as_of": "2026-12-31"})),
2 => (
"books.get_entry",
json!({"entry_id": Pick::Known(0).resolve(&k.entries)}),
),
3 => ("books.list_receipts", json!({})),
4 => ("books.list_periods", json!({})),
_ => ("books.digest", json!({"since": "2026-01-01"})),
},
Op::UnknownTool => ("books.no_such_tool", json!({})),
Op::MissingArgs => ("books.add_line", json!({"entry_id": 1})),
Op::WrongTypes => (
"books.post_entry",
json!({"entry_id": "one", "confirmed": "yes"}),
),
};
// Mcp::tool checks invariants 1, 2, 3 (schema, text) and 7.
let out = m.tool(tool, args);
match tool {
"books.draft_entry" | "books.record_payout" => k.entries.extend(new_id(&out, "entry_id")),
"books.reverse_entry" => k.entries.extend(new_id(&out, "reversal_id")),
"books.attach_receipt" => k.attachments.extend(new_id(&out, "attachment_id")),
"books.create_period" => k.periods.extend(new_id(&out, "period_id")),
"books.add_vendor" => k.vendors.extend(new_id(&out, "id")),
"books.create_bill" => {
k.bills.extend(new_id(&out, "id"));
k.entries.extend(new_id(&out, "entry_id"));
}
_ => {}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: cases(), failure_persistence: None, ..ProptestConfig::default() })]
/// Invariants 2 and 7 under random call sequences: every call gets
/// exactly one response with its id, result xor error; any refusal
/// leaves the journal byte-identical; successes match their schemas;
/// and the server keeps answering afterwards.
#[test]
fn inv2_inv7_random_calls_keep_books_and_server_intact(
ops in prop::collection::vec(op_strategy(), 30..60)
) {
let dir = test_dir("sm");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let mut known = Known::default();
for op in &ops {
run_op(&mut m, &mut known, op);
}
// Still alive and consistent at the end.
m.ok("books.chart", json!({}));
let tb = m.ok("books.trial_balance", json!({"as_of": "2026-12-31"}));
prop_assert_eq!(&tb["balanced"], &json!(true));
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
}
// ---------------------------------------------- invariant 3: amounts in
const REFUSED_AMOUNTS: &[&str] = &[
"1e3", "1E3", "+5", ".5", "5.", "1_000", "1,000", " 5", "5 ", "", "-", "abc", "١٢٣", "5.0.0",
"0x10", "5..0", "--5", "5-", "Infinity", "NaN", "5",
];
/// Grammatical amounts with at most two places and a positive value: what
/// the core is expected to accept on a line.
const ACCEPTED_AMOUNTS: &[&str] = &["5", "5.00", "123.45", "5.5", "007.50", "1000000.00"];
fn amounts_server(name: &str) -> (TestDir, Mcp, u64) {
let dir = test_dir(name);
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft("2026-03-01", "amounts", &[]);
(dir, m, e)
}
#[test]
fn inv3_add_line_amount_grammar_table() {
let (_dir, mut m, e) = amounts_server("inv3-table");
for a in REFUSED_AMOUNTS {
m.refuse(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}),
);
}
for a in ACCEPTED_AMOUNTS {
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}),
);
// Amounts out: the stored line keeps the value, never rounded.
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
let last = entry["lines"].as_array().unwrap().last().unwrap().clone();
let out = last["amount"].as_str().expect("amount is a string");
assert_eq!(canon(out), canon(a), "line amount {out} for input {a}");
}
assert!(m.money_violations.is_empty(), "{:#?}", m.money_violations);
}
/// The amount grammar applies to every amount-taking tool, not just
/// add_line.
#[test]
fn inv3_other_tools_refuse_ungrammatical_amounts() {
let (_dir, mut m, e) = amounts_server("inv3-other");
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00"}),
);
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "1000", "side": "credit", "amount": "10.00"}),
);
let vendor = m.ok("books.add_vendor", json!({"name": "V"}))["id"].clone();
for bad in ["1e1", "+10.00", "10,00", " 10.00", "10.00 "] {
m.refuse(
"books.post_entry",
json!({"entry_id": e, "confirmed": true, "expected_total": bad}),
);
m.refuse(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "x", "amount": bad}]}),
);
m.refuse(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-03-31", "statement_balance": bad}),
);
m.refuse(
"books.record_payout",
json!({"date": "2026-03-31", "description": "p", "deposit_account": "1000",
"deposit": bad, "revenue": [{"account": "4000", "amount": "10.00"}]}),
);
m.refuse(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "s", "total": bad,
"start": "2026-01-01", "months": 12, "balance_account": "1200",
"pl_account": "6000"}),
);
}
}
fn amount_char() -> impl Strategy<Value = char> {
prop::sample::select(vec![
'0', '1', '5', '9', '0', '1', '5', '9', '.', '.', '-', '+', 'e', 'E', '_', ',', ' ', '١',
'x',
])
}
proptest! {
#![proptest_config(ProptestConfig { cases: 16, failure_persistence: None, ..ProptestConfig::default() })]
/// Random strings: ungrammatical ones are refused; grammatical ones
/// with at most two places and a positive value are accepted and come
/// back with the same value. Others (zero, negative, sub-cent) are the
/// core's business and aren't asserted.
#[test]
fn inv3_add_line_amount_grammar_random(
amounts in prop::collection::vec(
prop::collection::vec(amount_char(), 0..9).prop_map(|cs| cs.into_iter().collect::<String>()),
20..40,
)
) {
let (dir, mut m, e) = amounts_server("inv3-prop");
for a in &amounts {
let out = m.tool(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}),
);
if !is_amount_in(a) {
prop_assert!(out.refused(), "ungrammatical {:?} accepted", a);
continue;
}
let places = a.split_once('.').map_or(0, |(_, f)| f.len());
let positive = !a.starts_with('-') && canon(a) != "0";
if places <= 2 && positive && a.len() <= 7 {
prop_assert!(!out.refused(), "grammatical {:?} refused: {:?}", a, out);
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
let last = entry["lines"].as_array().unwrap().last().unwrap().clone();
prop_assert_eq!(canon(last["amount"].as_str().unwrap()), canon(a));
}
}
prop_assert!(m.money_violations.is_empty(), "{:#?}", m.money_violations);
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
}
// ----------------------------------------- invariant 4: preview changes nothing
#[test]
fn inv4_post_and_reverse_preview_without_confirmed() {
let dir = test_dir("inv4");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft(
"2026-04-01",
"Rent",
&[("6000", "debit", "40.00"), ("1000", "credit", "40.00")],
);
m.attach(Some(e), b"rent");
for args in [
json!({"entry_id": e}),
json!({"entry_id": e, "confirmed": false}),
json!({"entry_id": e, "confirmed": null}),
] {
let before = m.journal();
let p = m.ok("books.post_entry", args.clone());
assert_eq!(p["status"], "preview", "{args}: {p}");
assert!(m.journal() == before, "post_entry preview {args} wrote");
assert_eq!(m.entry_status(e), "draft");
}
let before = m.journal();
let posted = m.post(e).ok("confirmed post");
assert_eq!(posted["status"], "posted");
assert!(m.journal() != before, "confirmed post wrote nothing");
assert_eq!(m.entry_status(e), "posted");
let rev = json!({"entry_id": e, "date": "2026-04-02", "description": "undo"});
for extra in [json!({}), json!({"confirmed": false})] {
let mut args = rev.clone();
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
let before = m.journal();
let p = m.ok("books.reverse_entry", args.clone());
assert_eq!(p["status"], "preview", "{args}: {p}");
assert!(m.journal() == before, "reverse_entry preview {args} wrote");
}
let mut args = rev;
args["confirmed"] = json!(true);
let before = m.journal();
let r = m.ok("books.reverse_entry", args);
assert_eq!(r["status"], "posted", "{r}");
assert!(m.journal() != before);
let tb = m.ok("books.trial_balance", json!({"as_of": "2026-12-31"}));
assert_eq!(tb["balanced"], true);
}
// ------------------------------------------ invariant 5: receipt policy
#[test]
fn inv5_threshold_is_strictly_greater_than() {
let dir = test_dir("inv5-25");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let at = m.draft(
"2026-05-01",
"At threshold",
&[("6000", "debit", "25.00"), ("1000", "credit", "25.00")],
);
m.post(at).ok("25.00 posts without a receipt");
let over = m.draft(
"2026-05-02",
"Over threshold",
&[("6000", "debit", "25.01"), ("1000", "credit", "25.01")],
);
let preview = m.ok("books.post_entry", json!({"entry_id": over}));
assert_eq!(preview["receipt_missing"], true, "{preview}");
let err = m.post(over).err_text();
assert!(err.contains("receipt_required"), "{err}");
assert_eq!(m.entry_status(over), "draft");
m.attach(Some(over), b"receipt for 25.01");
m.post(over).ok("25.01 posts with a receipt");
}
#[test]
fn inv5_zero_threshold_needs_a_receipt_on_every_entry() {
let dir = test_dir("inv5-0");
let toml = books_toml(&dir, "0.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft(
"2026-05-01",
"A penny",
&[("6000", "debit", "0.01"), ("1000", "credit", "0.01")],
);
let err = m.post(e).err_text();
assert!(err.contains("receipt_required"), "{err}");
m.attach(Some(e), b"penny receipt");
m.post(e).ok("with a receipt");
// Keep posted: the last receipt can't be unlinked from a covered entry.
let receipts = m.ok("books.list_receipts", json!({"entry_id": e}));
let att = receipts["receipts"][0]["attachment_id"].clone();
let err = m.refuse(
"books.unlink_receipt",
json!({"attachment_id": att, "reason": "try"}),
);
assert!(err.contains("receipt_required"), "{err}");
}
#[test]
fn inv5_splitting_does_not_dodge_the_threshold() {
let dir = test_dir("inv5-split");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let a = m.draft(
"2026-05-03",
"Office supplies",
&[("6999", "debit", "20.00"), ("1000", "credit", "20.00")],
);
m.post(a).ok("first half posts");
// Same day, same description (case and surrounding whitespace aside),
// same debit account: grouped, 40.00 > 25.00.
let b = m.draft(
"2026-05-03",
" OFFICE supplies ",
&[("6999", "debit", "20.00"), ("1000", "credit", "20.00")],
);
let err = m.post(b).err_text();
assert!(err.contains("receipt_required"), "{err}");
// Not the signature: another day, or another debit account.
let c = m.draft(
"2026-05-04",
"Office supplies",
&[("6999", "debit", "20.00"), ("1000", "credit", "20.00")],
);
m.post(c).ok("another day");
let d = m.draft(
"2026-05-03",
"Office supplies",
&[("6000", "debit", "20.00"), ("1000", "credit", "20.00")],
);
m.post(d).ok("another debit account");
// A receipt clears it.
m.attach(Some(b), b"supplies receipt");
m.post(b).ok("split with a receipt");
}
#[test]
fn inv5_reversals_are_exempt() {
let dir = test_dir("inv5-rev");
let toml = books_toml(&dir, "0.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft(
"2026-05-01",
"Big",
&[("6000", "debit", "500.00"), ("1000", "credit", "500.00")],
);
m.attach(Some(e), b"big receipt");
m.post(e).ok("post");
m.ok(
"books.reverse_entry",
json!({"entry_id": e, "date": "2026-05-02", "description": "undo", "confirmed": true}),
);
}
/// Policy invariant 4: a threshold that isn't a decimal, or is below
/// zero, is refused. The books keep their settings, so it is refused as it
/// is set (books.set_setting), journaling nothing, and the rule in force
/// stays.
#[test]
fn inv5_bad_threshold_refused_when_set() {
let dir = test_dir("inv5-set");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let before = m.journal();
assert!(!before.is_empty(), "the books keep a journal");
for bad in ["-1", "abc", "-0.01", "", "1,000", "ten"] {
m.refuse(
"books.set_setting",
json!({"changes": [{"key": "policy.evidence.threshold", "value": bad}],
"decided_by": "Test Owner"}),
);
}
assert_eq!(m.journal(), before, "a refused threshold journals nothing");
let rule = m.ok("books.settings", json!({}))["receipt_rule"].clone();
assert!(rule.as_str().unwrap().contains("25.00"), "{rule}");
}
// --------------------------------------- invariant 6: ambiguous references
#[test]
fn inv6_ambiguous_account_reference_refused() {
let dir = test_dir("inv6-acct");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
// Account "2" first, then others; find one whose id is 2 and whose
// code is not "2".
let ids = m.accounts(&[
("2", "Two by code", "expense"),
("1000", "Bank", "asset"),
("6000", "Rent", "expense"),
]);
assert!(
ids.contains(&2) && ids[0] != 2,
"need an account with id 2 that is not code \"2\": ids {ids:?}"
);
let e = m.draft("2026-06-01", "ambiguity", &[]);
let err = m.refuse(
"books.add_line",
json!({"entry_id": e, "account": "2", "side": "debit", "amount": "10.00"}),
);
assert!(!err.is_empty());
m.refuse(
"books.reconcile",
json!({"account": "2", "statement_date": "2026-06-30", "statement_balance": "0.00"}),
);
m.refuse(
"books.set_tax_category",
json!({"account": "2", "category": null}),
);
m.refuse(
"books.renumber_account",
json!({"account": "2", "code": "6100"}),
);
// Unique references work: a code nobody's id, an id nobody's code.
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00"}),
);
let unique_id = ids
.iter()
.copied()
.find(|&i| i != 2 && !["2", "1000", "6000"].contains(&i.to_string().as_str()))
.expect("an id that is no account's code");
m.ok(
"books.add_line",
json!({"entry_id": e, "account": unique_id.to_string(), "side": "credit", "amount": "10.00"}),
);
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
assert_eq!(entry["lines"].as_array().unwrap().len(), 2, "{entry}");
}
#[test]
fn inv6_ambiguous_vendor_reference_refused() {
let dir = test_dir("inv6-vendor");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let acme = m.ok("books.add_vendor", json!({"name": "Acme"}))["id"]
.as_u64()
.unwrap();
// A vendor literally named after Acme's id.
let named = m.ok("books.add_vendor", json!({"name": acme.to_string()}))["id"]
.as_u64()
.unwrap();
assert_ne!(acme, named);
let amb = acme.to_string();
m.refuse(
"books.set_vendor_1099",
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
);
m.refuse(
"books.draft_entry",
json!({"date": "2026-06-01", "description": "x", "vendor": amb}),
);
let e = m.draft("2026-06-01", "tag me", &[]);
m.refuse(
"books.set_entry_vendor",
json!({"entry_id": e, "vendor": amb}),
);
// Unique: the name "Acme", and the second vendor's id (no vendor has
// that name).
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false, "decided_by": "Test Owner"}),
);
let out = m.ok(
"books.set_entry_vendor",
json!({"entry_id": e, "vendor": named.to_string()}),
);
assert!(out.get("vendor").is_some(), "{out}");
}
#[test]
fn inv6_ambiguous_class_reference_refused() {
let dir = test_dir("inv6-class");
let toml = books_toml(&dir, "1000000.00", "");
old_classes(&dir, &["Alpha", "1"]);
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let (alpha, named) = (1u64, 2u64);
// No new name of digits only.
m.refuse("books.add_class", json!({"name": "3"}));
let e = m.draft("2026-06-01", "classy", &[]);
// add_line with an ambiguous class is refused too, but writes the
// line first: see add_line_bad_class_leaves_journal_unchanged.
m.refuse("books.profit_and_loss", json!({"class": alpha.to_string()}));
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00", "class": "Alpha"}),
);
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "1000", "side": "credit", "amount": "10.00",
"class": named.to_string()}),
);
m.ok("books.profit_and_loss", json!({"class": "Alpha"}));
}
/// Books an earlier version wrote holding these classes, in id order: a
/// name of digits only can't be added now (it would read as an id), so
/// only old books hold the id/name pairs invariant 6 refuses.
fn old_classes(dir: &TestDir, names: &[&str]) {
let books = dir.books_data();
drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap());
let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap();
for (i, n) in names.iter().enumerate() {
w.append("class.added", json!({"id": i + 1, "name": n}))
.unwrap();
}
}
/// Invariant 2: a refused add_line leaves the journal unchanged, here when
/// the line's class doesn't resolve (unknown, or ambiguous per invariant
/// 6).
#[test]
fn add_line_bad_class_leaves_journal_unchanged() {
let dir = test_dir("bug-class");
let toml = books_toml(&dir, "25.00", "");
old_classes(&dir, &["Alpha", "1"]);
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft("2026-06-01", "classy", &[]);
for class in ["Nope", "99", "1"] {
// Mcp::tool asserts the journal is unchanged on refusal.
m.refuse(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00",
"class": class}),
);
}
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
assert_eq!(entry["lines"], json!([]), "{entry}");
}
/// Invariant 6 and "validate all tool inputs": profit_and_loss_by_class
/// declares a `class` filter; an unknown or ambiguous class must be
/// refused, not ignored.
#[test]
fn profit_and_loss_by_class_validates_class() {
let dir = test_dir("bug-pnl-class");
let toml = books_toml(&dir, "1000000.00", "");
old_classes(&dir, &["Alpha", "1"]);
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.refuse("books.profit_and_loss_by_class", json!({"class": "Nope"}));
m.refuse("books.profit_and_loss_by_class", json!({"class": "1"}));
}
/// Invariant 2 for other class-taking tools: a payout with an unknown
/// class is refused and creates no draft.
#[test]
fn payout_bad_class_leaves_journal_unchanged() {
let dir = test_dir("payout-class");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.refuse(
"books.record_payout",
json!({"date": "2026-03-31", "description": "p", "deposit_account": "1000",
"deposit": "85.00", "revenue": [{"account": "4000", "amount": "100.00", "class": "Nope"}],
"fees": [{"account": "6999", "amount": "15.00", "class": "Nope"}]}),
);
}
// --------------------------------------------- JSON-RPC 2.0 conformance
/// mcp-tools.md: "Unknown tools" are protocol errors (JSON-RPC errors),
/// not isError results.
#[test]
fn jsonrpc_unknown_tool_is_a_protocol_error() {
let dir = test_dir("rpc-tool");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let out = m.tool("books.no_such_tool", json!({}));
assert!(
matches!(out, Out::RpcErr(_)),
"unknown tool should be a JSON-RPC error, got {out:?}"
);
m.ok("books.chart", json!({}));
}
/// jsonrpc-2.0.md §5.1: unknown method → -32601.
#[test]
fn jsonrpc_unknown_method_is_32601() {
let dir = test_dir("rpc-method");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let resp = m.rpc("books/does_not_exist", json!({}));
assert_eq!(resp["error"]["code"], -32601, "{resp}");
m.ok("books.chart", json!({}));
}
/// jsonrpc-2.0.md §5 and §5.1: invalid JSON is refused, never run, and
/// the server keeps serving. Over the server's Streamable HTTP door
/// (mcp-transports: "the server MUST return an HTTP error status code";
/// its body MAY be a JSON-RPC error with no id) that is a 4xx, and any
/// error it carries is -32700 with id null. (An agent's malformed line is
/// answered -32700 by its bridge: ledger-host bridge.rs,
/// the_agents_input_is_read_message_by_message.)
#[test]
fn jsonrpc_parse_error_is_refused_with_32700() {
let dir = test_dir("rpc-parse");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.send_line("{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": ");
let status = m.wire.status;
assert!(
(400..500).contains(&status),
"{status} {:?}",
m.wire.refused
);
if let Some(resp) = m.recv(Duration::from_secs(5)) {
check_response_shape(&resp);
assert_eq!(resp["error"]["code"], -32700, "{resp}");
assert_eq!(resp["id"], Value::Null, "{resp}");
}
m.ok("books.chart", json!({}));
}
/// jsonrpc-2.0.md §5: an Invalid Request is refused; an answer to it
/// carries an `id`, the request's when it can be read, else null. Over the
/// server's door (as above) that is a 4xx, with any error it carries
/// -32600.
#[test]
fn jsonrpc_invalid_request_response_has_id() {
let dir = test_dir("rpc-invalid");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
for (junk, id) in [
("[]", Value::Null),
("{\"jsonrpc\": \"2.0\", \"id\": 98}", json!(98)),
] {
m.send_line(junk);
let status = m.wire.status;
assert!(
(400..500).contains(&status),
"{junk}: {status} {:?}",
m.wire.refused
);
if let Some(resp) = m.recv(Duration::from_secs(5)) {
assert_eq!(resp["error"]["code"], -32600, "{resp}");
assert!(
resp.get("id").is_some(),
"{junk}: response has no id: {resp}"
);
// A detectable id should be echoed; Null when it isn't.
assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}");
}
}
m.ok("books.chart", json!({}));
}
/// Whatever the server does with a malformed line, it keeps serving
/// (invariant 7).
#[test]
fn jsonrpc_server_survives_malformed_input() {
let dir = test_dir("rpc-survive");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
for junk in [
"{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": ",
"not json at all",
"[]",
"{\"jsonrpc\": \"2.0\", \"id\": 98}",
] {
m.send_line(junk);
// Drain anything it answered with, without requiring an answer
// (the missing id is jsonrpc_invalid_request_response_has_id).
while let Some(v) = m.recv(Duration::from_millis(300)) {
assert_eq!(v["jsonrpc"], "2.0", "{v}");
assert!(v.get("result").is_some() ^ v.get("error").is_some(), "{v}");
}
m.ok("books.chart", json!({}));
}
}
/// Missing or mistyped arguments: mcp-tools.md lists "invalid arguments"
/// as protocol errors and "invalid input data" as tool errors, so either
/// is accepted; the books must not change and the server must survive.
#[test]
fn jsonrpc_invalid_arguments_refused_cleanly() {
let dir = test_dir("rpc-args");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.refuse("books.add_line", json!({"entry_id": 1}));
m.refuse("books.post_entry", json!({"entry_id": "one"}));
m.refuse(
"books.draft_entry",
json!({"date": 20260101, "description": "x"}),
);
m.refuse("books.add_account", json!({}));
m.ok("books.chart", json!({}));
}
#[test]
fn helpers_self_check() {
for ok in ["0.00", "-12.34", "1.234"] {
assert!(is_money_out(ok), "{ok}");
}
for bad in ["0", "1.2", "1e3", "", "-", ".50", "1,000.00"] {
assert!(!is_money_out(bad), "{bad}");
}
for ok in ["0", "5", "-5", "5.00", "007.5"] {
assert!(is_amount_in(ok), "{ok}");
}
for bad in REFUSED_AMOUNTS {
assert!(!is_amount_in(bad), "{bad}");
}
assert_eq!(canon("007.50"), "7.5");
assert_eq!(canon("5.00"), "5");
assert_eq!(canon("-0.00"), "0");
let mut bad = Vec::new();
money_walk(
&json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}),
"$",
None,
&mut bad,
);
assert_eq!(bad.len(), 2, "{bad:?}");
}
// ------------------------------------------- settings (the books keep them)
/// A setting changed (books.set_setting) is read by the next call, with no
/// restart, and two changes in a row are both seen.
#[test]
fn a_changed_setting_is_read_on_the_next_call() {
let dir = test_dir("reread");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let s = m.ok("books.settings", json!({}));
assert_eq!(s["company"], "Spec Co");
for name in ["Aaaa Co", "Bbbb Co"] {
m.ok(
"books.set_setting",
json!({"changes": [{"key": "company.name", "value": name}],
"decided_by": "Test Owner"}),
);
let s = m.ok("books.settings", json!({}));
assert_eq!(s["company"], name);
}
}
/// A lowered receipt threshold refuses the next post without a receipt.
#[test]
fn a_lowered_threshold_is_enforced() {
let dir = test_dir("lowered-threshold");
let toml = books_toml(&dir, "1000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let first = m.draft(
"2026-03-01",
"small",
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
);
m.post(first).ok("under the threshold");
m.ok(
"books.set_setting",
json!({"changes": [{"key": "policy.evidence.threshold", "value": "0.00"}],
"decided_by": "Test Owner"}),
);
let second = m.draft(
"2026-03-02",
"small",
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
);
let out = m.post(second);
assert!(out.err_text().contains("receipt_required"), "{out:?}");
assert_eq!(m.entry_status(second), "draft");
}
/// books.settings and the digest list settings that disagree with the
/// chart, each naming the setting.
#[test]
fn settings_that_disagree_with_the_chart_are_problems() {
let dir = test_dir("problems");
let toml = books_toml(
&dir,
"25.00",
"financing_accounts = [\"6000\"]\nclose_into = \"3000\"\n",
);
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let s = m.ok("books.settings", json!({}));
let problems: Vec<String> = s["problems"]
.as_array()
.unwrap()
.iter()
.map(|p| p.as_str().unwrap().to_string())
.collect();
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(
problems[0].contains("books.financing_accounts lists 6000"),
"{problems:?}"
);
assert!(
problems[0].ends_with("remove it from books.financing_accounts with books.set_setting"),
"{problems:?}"
);
m.ok(
"books.set_setting",
json!({"changes": [{"key": "books.cash_accounts", "value": ["1010"]},
{"key": "books.close_into", "value": "4000"}],
"decided_by": "Test Owner"}),
);
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
let problems = digest["settings_problems"].to_string();
assert!(
problems.contains("books.cash_accounts names 1010, which isn't in the chart"),
"{problems}"
);
assert!(
problems.contains("books.close_into names 4000"),
"{problems}"
);
assert!(
problems.contains("books.financing_accounts lists 6000"),
"{problems}"
);
}
/// books.assign_role steers what an invoice posts, and books.roles shows
/// it; a refusal names the reason.
#[test]
fn assigned_roles_steer_invoices() {
let dir = test_dir("roles");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(&[
("1000", "Bank", "asset"),
("1110", "Trade Receivables", "asset"),
("4100", "Consulting Income", "revenue"),
("6000", "Rent", "expense"),
]);
let roles = m.ok("books.roles", json!({}));
let receivables = roles["roles"]
.as_array()
.unwrap()
.iter()
.find(|r| r["role"] == "receivables")
.unwrap()
.clone();
assert_eq!(receivables["default_code"], "1200");
assert_eq!(receivables["assigned"], false);
assert_eq!(receivables["account_code"], Value::Null);
let err = m.refuse(
"books.assign_role",
json!({"role": "receivables", "account": "6000"}),
);
assert!(err.contains("accounts receivable"), "{err}");
let err = m.refuse(
"books.assign_role",
json!({"role": "receivable", "account": "1110"}),
);
assert!(err.contains("no role"), "{err}");
m.ok(
"books.assign_role",
json!({"role": "receivables", "account": "1110"}),
);
let roles = m.ok(
"books.assign_role",
json!({"role": "revenue", "account": "4100"}),
);
let revenue = roles["roles"]
.as_array()
.unwrap()
.iter()
.find(|r| r["role"] == "revenue")
.unwrap()
.clone();
assert_eq!(revenue["assigned"], true);
assert_eq!(revenue["account_code"], "4100");
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
);
let tb = m.ok("books.trial_balance", json!({})).to_string();
assert!(tb.contains("1110") && tb.contains("4100"), "{tb}");
assert!(!tb.contains("\"1200\"") && !tb.contains("\"4000\""), "{tb}");
}
/// The database at `path` by what it holds: every row of every table, in
/// rowid order, as text, hashed. Read through a read-only connection
/// beside the server's (WAL mode lets a reader in while it writes).
fn db_digest(path: &Path) -> String {
let conn =
rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
tables.sort();
let mut dump = String::new();
for t in &tables {
dump.push_str(&format!("table {t}\n"));
let mut stmt = conn
.prepare(&format!("SELECT * FROM \"{t}\" ORDER BY rowid"))
.unwrap();
let cols = stmt.column_count();
let mut rows = stmt.query([]).unwrap();
while let Some(row) = rows.next().unwrap() {
for i in 0..cols {
dump.push_str(&format!("{:?}\t", row.get_ref(i).unwrap()));
}
dump.push('\n');
}
}
ledger_store::journal::sha256_hex(dump.as_bytes())
}
/// P4b S1 over the real server (rules.md B-38, D-45): a payment carries
/// its method and reference and answers with the invoice's derived
/// figures; an invoice with a payment standing can't be voided (the
/// refusal names the payment); one entered twice is voided and shows as
/// void; a hand line to receivables is refused; and the control check
/// ties receivables to the open invoices, with nothing for the digest.
#[test]
fn documents_void_and_tie_out_over_the_wire() {
let dir = test_dir("p4b_s1");
let toml = books_toml(&dir, "600.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
m.ok(
"books.add_instrument",
json!({"kind": "bank", "label": "Harbor checking", "last4": "1234", "account": "1000"}),
);
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
let paid = m.ok(
"books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00",
"cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}),
);
assert_eq!(paid["remaining"], "300.00", "{paid}");
assert_eq!(paid["status"], "partial", "{paid}");
let payment = paid["entry_id"].as_u64().unwrap();
let entry = m.ok("books.get_entry", json!({"entry_id": payment}));
assert!(
entry["description"].as_str().unwrap().contains("tr_8812"),
"{entry}"
);
assert_eq!(entry["instrument"], "Harbor checking", "{entry}");
let why = m
.tool(
"books.void_document",
json!({"kind": "invoice", "id": inv, "date": "2026-02-11", "reason": "wrong client"}),
)
.err_text();
assert!(why.contains(&format!("entry {payment}")), "{why}");
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
let void = m.ok(
"books.void_document",
json!({"kind": "invoice", "id": dup, "date": "2026-02-02", "reason": "entered twice"}),
);
assert_eq!(void["status"], "void", "{void}");
let e = m.ok(
"books.draft_entry",
json!({"date": "2026-02-15", "description": "By hand", "no_party_reason": "test"}),
)["entry_id"]
.as_u64()
.unwrap();
assert!(
m.tool(
"books.add_line",
json!({"entry_id": e, "account": "1200", "side": "debit", "amount": "50.00"}),
)
.refused()
);
let check = m.ok("books.control_check", json!({"as_of": "2026-06-30"}));
assert_eq!(check["agrees"], true, "{check}");
let ar = check["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["role"] == "receivables")
.cloned()
.unwrap();
assert_eq!(ar["code"], "1200", "{check}");
assert_eq!(ar["balance"], "300.00", "{check}");
assert_eq!(ar["documents_total"], "300.00", "{check}");
assert_eq!(ar["difference"], "0.00", "{check}");
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(digest["control_problems"], json!([]), "{digest}");
let verified = m.ok("books.verify", json!({}));
assert!(
verified["checked"].as_array().unwrap().iter().any(|c| c
.as_str()
.unwrap()
.starts_with("receivables and payables equal")),
"{verified}"
);
}
/// P4b S2 over the real server: books.documents lists what is owed, due
/// date first, and books.document shows one with its timeline; a
/// reversed payment shows twice (the payment, which no longer stands,
/// and its reversal, which does: the two net to nothing), and the invoice
/// owes its whole total again.
#[test]
fn documents_list_and_timeline_over_the_wire() {
let dir = test_dir("p4b_s2");
let toml = books_toml(&dir, "600.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
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,
"lines": [{"description": "Consulting", "amount": amount}]}),
)["id"]
.as_u64()
.unwrap()
};
let late = invoice(&mut m, "2026-02-01", "2026-03-01", "500.00");
let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00");
let pay = m.ok(
"books.receive_payment",
json!({"document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.reverse_entry",
json!({"entry_id": pay, "date": "2026-02-12", "description": "the transfer bounced", "confirmed": true}),
);
let open = m.ok(
"books.documents",
json!({"kind": "invoices", "as_of": "2026-03-15"}),
);
let refs: Vec<&str> = open["documents"]
.as_array()
.unwrap()
.iter()
.map(|d| d["ref"].as_str().unwrap())
.collect();
assert_eq!(
refs,
[format!("invoice:{late}"), format!("invoice:{later}")],
"{open}"
);
assert_eq!(open["outstanding_total"], "580.00", "{open}");
assert_eq!(open["documents"][0]["overdue_days"], 14, "{open}");
let overdue = m.ok(
"books.documents",
json!({"kind": "invoices", "status": "overdue", "as_of": "2026-03-15"}),
);
assert_eq!(
overdue["documents"].as_array().unwrap().len(),
1,
"{overdue}"
);
let due_soon = m.ok(
"books.documents",
json!({"kind": "invoices", "due_by": "2026-03-31", "as_of": "2026-03-15"}),
);
assert_eq!(
due_soon["documents"].as_array().unwrap().len(),
1,
"{due_soon}"
);
assert_eq!(
m.ok("books.documents", json!({"kind": "bills"}))["documents"],
json!([])
);
let one = m.ok(
"books.document",
json!({"ref": format!("invoice:{late}"), "as_of": "2026-03-15"}),
);
assert_eq!(one["outstanding"], "500.00", "{one}");
assert_eq!(one["paid"], "0.00", "{one}");
assert_eq!(one["status"], "open", "{one}");
let moves: Vec<(String, bool, bool)> = one["timeline"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e["kind"].as_str().unwrap().to_string(),
e["reversal"].as_bool().unwrap(),
e["stands"].as_bool().unwrap(),
)
})
.collect();
assert_eq!(
moves,
[
("origin".to_string(), false, true),
("payment".to_string(), false, false),
("payment".to_string(), true, true),
],
"{one}"
);
// Its PDF, by download (P4b S3): re-created from the books.
let with_pdf = m.ok(
"books.document",
json!({"ref": format!("invoice:{late}"), "as_of": "2026-03-15", "pdf": true}),
);
let bytes = m.wire.fetch(&with_pdf["pdf"]);
assert!(bytes.starts_with(b"%PDF"), "{with_pdf}");
assert_eq!(with_pdf["pdf"]["file_name"], format!("invoice-{late}.pdf"));
assert!(
m.tool("books.document", json!({"ref": "bill:1", "pdf": true}))
.refused()
);
assert!(
m.tool("books.document", json!({"ref": "invoice"}))
.refused()
);
assert!(
m.tool(
"books.documents",
json!({"kind": "invoices", "status": "late"})
)
.refused()
);
}