refactor!: one way to serve books: the stdio server is gone (D-44)
check / check (push) Canceled after 0s

The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-23 19:45:18 -06:00
1 parent 0df7f52ac3
commit 7741e96eb3
87 files changed
+3477 -6476

No files matched your search

+5 -14
View File
@@ -4,15 +4,11 @@ version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store."
description = "The books server's MCP tools for wanderbooks: the AI-first bookkeeping interface over ledger-store."
[lib]
path = "src/lib.rs"
[[bin]]
name = "ledger-mcp"
path = "src/main.rs"
[dependencies]
ledger-core = { path = "../ledger-core" }
ledger-store = { path = "../ledger-store" }
@@ -25,9 +21,8 @@ ledger-package = { path = "../ledger-package" }
base64 = "0.22"
# A download's Repr-Digest (local.rs).
sha2 = "0.10"
mime_guess = "2"
rust_decimal = "1"
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
rmcp = { version = "3", features = ["server", "schemars", "transport-streamable-http-server"] }
axum = "0.8"
http-body-util = "0.1"
tower = "0.5"
@@ -36,7 +31,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync", "net", "signal", "time"] }
tokio-util = { version = "0.7", features = ["rt", "io", "io-util"] }
# A call that panics is recorded before the panic goes on (access.rs).
futures-util = "0.3"
@@ -54,12 +49,8 @@ libc = "0.2"
workspace = true
[dev-dependencies]
# The tests open the CPA package.
zip = "9.0.1"
# The unit tests drive the tools as a client would (the integration tests,
# through a real books server, are ledger-cli's: plan D-44).
tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
jsonschema = { version = "0.58", default-features = false }
proptest = "1"
tempfile = "3"
# read_only_tools_change_nothing compares the database by its rows (spec_mcp.rs).
rusqlite = { version = "0.32", features = ["bundled"] }
+1 -1
View File
@@ -4,7 +4,7 @@ You are the bookkeeping agent for these books. The engine enforces the accountin
- These tools are the only way to the books. Never look for the books (their journal, receipts or vault) in local files.
- Start every session with books.start: the rules in force, this company's conventions, what is open, and next steps. Follow the company's conventions (books.conventions).
- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). One exception, for books that still read a settings file (books.settings says source "file"): that file may be edited until books.import_settings moves it in. No other local file.
- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). No local file is yours to edit.
- Money is exact decimal. Always pass amounts as decimal strings ("123.45"), never floats. Amounts in every response are decimal strings: treat them as exact, and echo them to the human as written.
- Never post directly: draft, add lines, preview, show the human the preview, then post (the entries section).
- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.
+1 -1
View File
@@ -1,6 +1,6 @@
# CPA handoff
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package.
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in, then tell them where it is. The viewer's "Export for CPA" makes the same package.
- Never file the package, or a file from it, into the books.
- 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.
+3 -3
View File
@@ -1,8 +1,8 @@
# Files in and out
- A file comes in one of two ways, named in the tool that files it (books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv, books.import_chart_csv):
- an upload, for any file up to 25 MiB (books served by `wanderbooks mcp`; on another connection only inline): books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
- an upload, for any file up to 25 MiB: books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
- inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught.
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
- Files the books make (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 (books served by `wanderbooks mcp`; elsewhere it comes back inline). 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 gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (only for books served by `wanderbooks mcp`).
- Files the books make (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 gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name.
@@ -11,17 +11,6 @@ The books keep their own record of how they are kept. Every agent, anywhere, rea
- policy.require_tax_category is warn (unset: a revenue or expense account without a tax category is created, and the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; the accounts invoices, bills and payroll create are never refused, and their results name them). Before setting refuse, categorize with the human every account books.digest lists in uncategorized_accounts.
- books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing.
## Books that still read a settings file
If books.settings says source "file", these books predate settings in the books and still read the settings file books.settings names. Move it in once, with the human:
1. Read that file's text, whole and unedited, and pass it to books.import_settings as toml_text (and, if books.settings shows config_json_present, the receipt policy as config_json_text: {"receipt_policy": {"require_receipts_above", "receipt_required_accounts", "receipt_exempt_accounts"}} from books.settings). The import is refused unless the text is the file the server reads.
2. Show the human the preview: every change, the notes (what isn't a books setting and where it goes), anything unknown, and the file's comments, which are not imported: record what matters in them (where a value came from, a discrepancy, something pending) as a decision, an open item or a convention.
3. Confirm with confirmed: true, decided_by the human. The import is refused if any setting would read differently afterwards.
4. If the file named the vault's key file ([vault] key_file), the server must already be started naming the same file, and the viewer too, or the vault won't open: tell the human before the import.
From then on no file is read; books.set_setting changes settings. Until then, that file is the one local file you may edit, and books.set_setting is refused (it would drop the rest of the file).
## Conventions
How this company keeps its books, as the owners decided, is recorded as conventions (books.set_convention, read with books.conventions; books.start lists them):
+2 -3
View File
@@ -1,5 +1,5 @@
//! Who is calling, what their token allows, and the record of each call
//! (plan 3.3, I-9), for books served from the data root.
//! (plan 3.3, I-9), for every book the books server serves.
//!
//! ```text
//! GOAL: a call reaches a book only with a token for that book and for
@@ -9,8 +9,7 @@
//! logged, and logging fails closed"), section 7's record.
//! INVARIANTS:
//! 1. The HTTP door (local.rs) puts a Caller on every request it lets
//! through; a call that reaches a book with an access log and has no
//! Caller is refused.
//! through; a call that reaches a book with no Caller is refused.
//! 2. A call runs only if its Caller's token has the scope it needs
//! (scopes.rs); otherwise it is refused, unrun.
//! 3. Every call, run or refused, gets one record, written before its
+11 -51
View File
@@ -19,11 +19,9 @@
//! ReceiptPolicy::rule), never a fixed one; the connect text states
//! the parties rule in force too.
//! 3. No connect text or handbook section names a filesystem path, a
//! helper command, a pull request, or where the books are; none
//! names the settings file, which is the one books.settings names.
//! Books that keep their own settings are told no local file is
//! theirs to edit; books that still read a settings file, that it is
//! the one they may edit, until it is imported.
//! helper command, a pull request, or where the books are; the books
//! keep their own settings, and the agent is told no local file is
//! its to edit.
//! 4. The connect text's first paragraph stands alone: the entry
//! point, the local-files rule, decimal strings, and the entry flow.
//! 5. The company's name is shown as one line of plain text (no
@@ -246,23 +244,9 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
// (with the hint back), then the hint and the account codes the rule
// lists (counted, with books.settings listing them), and last the
// words that only help.
// Where settings live: in the books (changed through the tools), or,
// for books that still read a settings file, that one file.
let settings_rule = |why: bool| {
if settings.journaled {
" Settings and conventions live in the books: change them only with these tools."
.to_string()
} else {
format!(
" The only local file you may edit is the settings file books.settings names{}.",
if why {
" (until books.import_settings)"
} else {
""
}
)
}
};
// Where settings live: in the books, changed through the tools.
let settings_rule =
" Settings and conventions live in the books: change them only with these tools.";
let rest = |why: bool, codes: usize, trace: bool, terse: bool| {
let rule = settings.policy.rule_listing(currency, codes);
let because = |s: &'static str| if why { s } else { "" };
@@ -287,7 +271,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
} else {
""
},
settings_rule(why),
settings_rule,
)
};
let mut text = String::new();
@@ -358,13 +342,7 @@ mod tests {
longest_rule(&long, PartyPolicy::Suggest),
longest_rule(&long, PartyPolicy::Off),
];
// Each as books still reading a settings file, and as books that
// keep their own settings.
let journaled = every.clone().map(|mut s| {
s.journaled = true;
s
});
for s in every.into_iter().chain(journaled) {
for s in every {
let text = connect_text(&s, "USD");
let chars = text.chars().count();
assert!(chars <= CONNECT_MAX, "{chars} chars: {text}");
@@ -391,8 +369,8 @@ mod tests {
.any(|&n| text.contains(&s.policy.rule_listing("USD", n))),
"{text}"
);
// What every entry must be, and where settings live: the one
// file the agent may edit, or none.
// What every entry must be, and where settings live: in the
// books.
for must in [
"balances exactly",
"real account from books.chart",
@@ -401,14 +379,8 @@ mod tests {
] {
assert!(text.contains(must), "lacks {must:?}: {text}");
}
assert_eq!(
text.contains("the settings file books.settings names"),
!s.journaled,
"{text}"
);
assert_eq!(
assert!(
text.contains("Settings and conventions live in the books"),
s.journaled,
"{text}"
);
// The parties rule in force (INVARIANT 2), and only it.
@@ -611,22 +583,10 @@ mod tests {
for why in [
"(their schedules are the evidence)",
"(derived from the books)",
"(until books.import_settings)",
"(the parties setting requires it)",
] {
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
}
// The same company once its settings are in the books.
s.journaled = true;
let text = connect_text(&s, "USD");
assert!(text.chars().count() <= CONNECT_MAX);
for why in [
"(their schedules are the evidence)",
"(the parties setting requires it)",
"Settings and conventions live in the books",
] {
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
}
assert!(!text.contains("local file you may edit"), "{text}");
}
}
+11 -265
View File
@@ -1,19 +1,9 @@
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
//!
//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks
//! dev-stdio` (one executable, plan D-22) both call it.
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth),
//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each
//! schema: ledger_store::db_file_for), `blobs/` (content-addressed receipts),
//! and optionally `config.json`. If no store exists, one is created with the
//! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir, and reload whenever it changes
//! (settings.rs). Files come in only as tool input (inline, at most 256
//! KiB): uploads need the books server's socket (`wanderbooks mcp`).
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
//! every book of the data root on the root's local sockets, which agents
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as
//! tool input (inline, at most 256 KiB) or as uploads, and the files the
//! books make go out as downloads.
pub mod access;
pub mod admin;
@@ -22,59 +12,20 @@ mod handbook;
#[cfg(unix)]
pub mod local;
pub mod opening;
mod paths;
mod policy;
pub mod scopes;
mod server;
mod settings;
mod tin_files;
mod types;
mod wire;
use ledger_core::Currency;
use ledger_store::Store;
use policy::ReceiptPolicy;
use rmcp::ServiceExt;
use serde::Deserialize;
use server::BooksServer;
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
use std::path::PathBuf;
/// Server configuration, JSON. All fields optional.
#[derive(Debug, Default, Deserialize)]
struct Config {
/// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)]
base_currency: Option<String>,
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
}
fn default_data_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".local/share/wanderbooks")
}
fn print_usage(prog: &str) {
eprintln!(
"usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR]"
);
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
eprintln!(
" --books-toml company settings, for books that don't keep their own yet (default <data-dir>/../wanderbooks.toml, optional)"
);
eprintln!(
" --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)"
);
eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
);
}
/// How [`run`] stops when it can't go on: the reason is already printed
/// on stderr, and the number is the process's exit code (2: a bad argument; 1: books from a newer wanderbooks). A
/// library never ends the process itself, so the books it opened are
/// closed properly on the way out; the binaries turn this into the exit.
/// How a mode of the one executable stops when it can't go on: the
/// reason is already printed on stderr, and the number is the process's
/// exit code (2: a bad argument). A library never ends the process
/// itself, so the books it opened are closed properly on the way out;
/// the executable turns this into the exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Exit(pub i32);
@@ -86,211 +37,6 @@ impl std::fmt::Display for Exit {
impl std::error::Error for Exit {}
/// Run the books server with `args` (the command line after the program
/// name). `prog` names the program in the usage text: `ledger-mcp`, or
/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad
/// argument or books from a newer wanderbooks return [`Exit`] after a
/// plain message.
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut data_dir = default_data_dir();
let mut config_path: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut vault_key_file: Option<PathBuf> = None;
let mut http_addr: Option<String> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--data-dir" => {
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
}
"--config" => {
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
}
"--books-toml" => {
books_toml = Some(PathBuf::from(
args.next().ok_or("--books-toml needs a path")?,
));
}
"--vault-key-file" => {
vault_key_file = Some(PathBuf::from(
args.next().ok_or("--vault-key-file needs a path")?,
));
}
"--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?);
}
"--help" | "-h" => {
print_usage(prog);
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
print_usage(prog);
return Err(Box::new(Exit(2)));
}
}
}
// Books in the data root are the books server's alone (plan 3.1):
// a second writer beside it is refused before anything is opened.
if ledger_store::root::inside_a_root(&data_dir) {
eprintln!(
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
);
return Err(Box::new(Exit(2)));
}
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
// config.json's base currency matters only to books being created: it
// is read then, and never otherwise (books that keep their own
// settings read no file).
let read_config = || -> Result<Config, Box<dyn std::error::Error>> {
if config_path.exists() {
let raw = std::fs::read_to_string(&config_path)?;
Ok(serde_json::from_str(&raw)
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?)
} else {
Ok(Config::default())
}
};
// Logs go to stderr: stdout is the MCP channel.
eprintln!("ledger-mcp: data dir {}", data_dir.display());
// An explicit --books-toml must exist; the default (beside the data
// dir) is optional. Either is reloaded whenever it changes.
let source = settings::Source {
required: books_toml.is_some(),
books_toml: books_toml.unwrap_or_else(|| {
data_dir
.parent()
.map(|p| p.join("wanderbooks.toml"))
.unwrap_or_else(|| PathBuf::from("wanderbooks.toml"))
}),
config_json: config_path.clone(),
};
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
match open_waiting_for_lock(&data_dir) {
Ok(store) => store,
// Books from a newer wanderbooks are fine; this server is old.
// Say so in plain words (the error's text), and leave them be.
Err(e @ ledger_store::StoreError::NewerJournal { .. }) => {
eprintln!("ledger-mcp: {e}");
return Err(Box::new(Exit(1)));
}
Err(e) => return Err(e.into()),
}
} else {
let config = read_config()?;
let code = config.base_currency.as_deref().unwrap_or("USD");
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
eprintln!("ledger-mcp: creating new store (base currency {code})");
Store::create(&data_dir, currency)?
};
// The journal is the source of truth: verify the chain, the replay, and
// the whole projection before serving a single tool call, rebuilding
// the projection if it drifted.
let report = store
.verify_and_heal()
.map_err(|e| format!("store verify failed: {e}"))?;
if let Some(why) = &report.healed {
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
}
// The viewer's side channel, made as the books open (ledger_store::ops
// INVARIANT 2), so a viewer already open shows its presence before
// the first books.show. Not the books: a failure only says so.
if let Err(e) = ledger_store::ops::prepare(&data_dir) {
eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})");
}
// Books that keep their own settings never read a settings file, not
// even to start (settings INVARIANT 1); others read it as before.
let mut live = if store.ledger().settings().is_journaled() {
settings::Live::journaled(source)
} else {
settings::Live::watching(source)?
}
.with_vault_key_file(vault_key_file);
live.sync(store.ledger().settings(), store.base_currency().code());
eprintln!(
"ledger-mcp: company {:?} (settings from {})",
live.current().company.name,
if live.is_journaled() {
"the books"
} else {
"the settings file"
}
);
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
if let Some(addr) = http_addr {
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
rt.block_on(serve_http(addr, store, live))?;
return Ok(());
}
eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
rt.block_on(async {
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
let running = server.serve((input, output)).await?;
let _ = running.waiting().await;
let _ = writer.await;
Ok::<(), Box<dyn std::error::Error>>(())
})
}
/// Serve MCP over Streamable HTTP (for network clients).
async fn serve_http(
addr: String,
store: Store,
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use std::sync::Arc;
// One store for every connection: the async mutex keeps it
// single-writer, and tool bodies run on blocking threads (server.rs).
let store = server::SharedStore::new(store);
let service = StreamableHttpService::new(
move || {
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone());
Ok(server)
},
Arc::new(LocalSessionManager::default()),
Default::default(),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind(&addr).await?;
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
axum::serve(listener, router).await?;
Ok(())
}
/// Open the books, waiting up to ten seconds for another writer to let go.
/// A restarted server (a client reconnecting) often starts while the old
/// process is still exiting with the lock; waiting turns that race into a
/// short delay. A writer that keeps the books longer is a real conflict,
/// and the refusal says so.
fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result<Store, ledger_store::StoreError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match Store::open(data_dir) {
Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
other => return other,
}
}
}
#[cfg(test)]
mod exit_tests {
use super::Exit;
+16 -49
View File
@@ -60,9 +60,7 @@
//! ```
use crate::admin::{self, Request, Response};
use crate::policy::ReceiptPolicy;
use crate::server::{BooksServer, Layout, SharedStore};
use crate::settings::Live;
use crate::server::{BooksServer, Served, SharedStore};
use ledger_store::Store;
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
use ledger_store::auth::{Principals, Scope, Token};
@@ -294,61 +292,30 @@ impl Books {
self.hide(why.clone())
);
}
let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code());
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
let database = (store.database_file(), store.other_databases());
let layout = Layout::Root(self.root.path().to_path_buf());
let access = Arc::new(
AccessLog::open(store.data_dir())
.map_err(|e| format!("its access log can't be used: {e}"))?,
);
let uploads =
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_uploads = uploads.clone();
let exports =
ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_exports = exports.clone();
// The viewer's side channel, made by the server (ops.rs
// INVARIANT 2): a viewer can show its presence from the start.
ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?;
let live = Arc::new(Mutex::new(live));
let book_dir = store.data_dir().to_path_buf();
let book_id = book_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let store = SharedStore::new(store);
let backups = Arc::new(
crate::backup::Backups::open(&book_id, &book_dir, store.clone())
.map_err(|e| self.hide(e))?,
);
let server_backups = backups.clone();
let log = access.clone();
let shared = store.clone();
let served = Served::open(store).map_err(|e| self.hide(e))?;
let config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(true)
.with_max_request_body_bytes(MAX_BODY)
.enforce_origin_validation()
.with_cancellation_token(self.sessions.child_token());
let parts = served.clone();
// Every session's server is made the one way (server.rs Served).
let service = StreamableHttpService::new(
move || {
Ok(
BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone())
.with_layout(layout.clone())
.with_access(log.clone())
.with_uploads(server_uploads.clone())
.with_exports(server_exports.clone())
.with_backups(server_backups.clone()),
)
},
move || Ok(BooksServer::new(&parts)),
Arc::new(sessions()),
config,
);
let Served {
store: shared,
access,
uploads,
exports,
backups,
..
} = served;
Ok(Book::Open {
service: Box::new(service),
store: shared,
@@ -884,9 +851,9 @@ impl Books {
}
}
/// The settings a settings file moves in at adoption, checked as
/// `books.import_settings` checks them: they parse, they apply, and the
/// vault key file it names (which the books don't keep) is the one given.
/// The settings a settings file moves in at adoption, checked: they
/// parse, they apply, and the vault key file it names (which the books
/// don't keep) is the one given.
fn settings_to_move(
toml: &str,
config_json: Option<&str>,
-12
View File
@@ -1,12 +0,0 @@
//! `ledger-mcp`: the books server over stdio (or `--http ADDR`). The same
//! server is `wanderbooks dev-stdio`; both call [`ledger_mcp::run`].
fn main() -> Result<(), Box<dyn std::error::Error>> {
match ledger_mcp::run("ledger-mcp", std::env::args().skip(1)) {
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
Some(exit) => std::process::exit(exit.0),
None => Err(e),
},
done => done,
}
}
-132
View File
@@ -1,132 +0,0 @@
//! Paths as the server judges them: where a path really leads.
use std::path::{Component, Path, PathBuf};
/// `path` made absolute and resolved as the kernel would follow it, name
/// by name: every symlink followed (a dangling one too, to where it
/// points), `.` and `..` folded in order, and whatever doesn't exist kept
/// as written. For an existing path this is its canonical form; for a
/// missing one, where it would be.
pub fn resolved(path: &Path) -> PathBuf {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("/"))
.join(path)
};
// The names still to walk, last first.
let mut todo: Vec<std::ffi::OsString> = Vec::new();
let mut out = PathBuf::new();
// An absolute path starts the walk over at its root.
let push_all = |todo: &mut Vec<std::ffi::OsString>, p: &Path, out: &mut PathBuf| {
*out = PathBuf::new();
let mut parts = Vec::new();
for c in p.components() {
match c {
Component::Prefix(_) | Component::RootDir => out.push(c.as_os_str()),
other => parts.push(other.as_os_str().to_os_string()),
}
}
todo.extend(parts.into_iter().rev());
};
push_all(&mut todo, &abs, &mut out);
// Links followed at most this many times (as the kernel's ELOOP).
let mut hops = 0;
while let Some(name) = todo.pop() {
if name == "." {
continue;
}
if name == ".." {
out.pop();
continue;
}
let next = out.join(&name);
let link = std::fs::symlink_metadata(&next)
.is_ok_and(|m| m.file_type().is_symlink())
.then(|| std::fs::read_link(&next).ok())
.flatten();
match link {
Some(target) if hops < 40 => {
hops += 1;
if target.is_absolute() {
push_all(&mut todo, &target, &mut out);
} else {
todo.extend(
target
.components()
.map(|c| c.as_os_str().to_os_string())
.rev(),
);
}
}
_ => out = next,
}
}
out
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
/// `resolved` follows as many links as the kernel does (40, as
/// `canonicalize` does), and no more.
#[test]
fn resolved_follows_forty_links_and_no_more() {
let dir = tempfile::tempdir().unwrap();
let b = dir.path().canonicalize().unwrap();
let file = b.join("receipt.pdf");
std::fs::write(&file, "x").unwrap();
let link = |n: usize| b.join(format!("link-{n}"));
symlink(&file, link(1)).unwrap();
for n in 2..=41 {
symlink(link(n - 1), link(n)).unwrap();
}
assert_eq!(link(40).canonicalize().unwrap(), file);
assert_eq!(resolved(&link(40)), file);
assert!(link(41).canonicalize().is_err(), "the kernel stops at 40");
assert_ne!(resolved(&link(41)), file);
}
/// `resolved` places a path where the kernel would follow it: the
/// canonical form for one that exists, and through a dangling link to
/// where it points.
#[test]
fn resolved_follows_every_link_a_dangling_one_too() {
let dir = tempfile::tempdir().unwrap();
let b = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(b.join("Downloads/sub")).unwrap();
std::fs::create_dir_all(b.join("data")).unwrap();
std::fs::write(b.join("Downloads/receipt.pdf"), "x").unwrap();
std::fs::write(b.join("data/journal.jsonl"), "x").unwrap();
symlink(
b.join("Downloads/receipt.pdf"),
b.join("Downloads/good-link"),
)
.unwrap();
symlink(b.join("data"), b.join("Downloads/data-link")).unwrap();
for p in [
b.join("Downloads/good-link"),
b.join("Downloads/data-link/journal.jsonl"),
b.join("Downloads/sub/../receipt.pdf"),
b.join("Downloads/./good-link"),
] {
assert_eq!(resolved(&p), p.canonicalize().unwrap(), "{}", p.display());
}
symlink("../elsewhere/gone.pdf", b.join("Downloads/rel-dangling")).unwrap();
assert_eq!(
resolved(&b.join("Downloads/rel-dangling")),
b.join("elsewhere/gone.pdf")
);
symlink(b.join("Downloads/loop-b"), b.join("Downloads/loop-a")).unwrap();
symlink(b.join("Downloads/loop-a"), b.join("Downloads/loop-b")).unwrap();
// A loop ends (the hop limit) inside the folder it loops in.
assert!(resolved(&b.join("Downloads/loop-a")).starts_with(b.join("Downloads")));
assert_eq!(
resolved(&b.join("Downloads/missing/../../elsewhere/x")),
b.join("elsewhere/x")
);
}
}
+6 -47
View File
@@ -25,9 +25,8 @@
//! the one there) needs books:post (review of slice 4).
//! 6. Where the books' backups go is books:admin's, as backing up is
//! (a backup is the whole book, its vault and key included):
//! setting a `backup.*` key, or importing settings that hold one,
//! needs books:admin; other settings books:post as before (review of
//! slice 7).
//! setting a `backup.*` key needs books:admin; other settings
//! books:post as before (review of slice 7).
//! ```
use ledger_store::auth::Scope;
@@ -46,14 +45,9 @@ pub enum Needs {
/// books:admin for a change to a `backup.*` setting, books:post for
/// any other (INVARIANT 6).
AdminForBackupSettings,
/// As PostWhenConfirmed, and books:admin when the settings imported
/// hold a `[backup]` table (INVARIANT 6).
ImportSettings,
}
use Needs::{
AdminForBackupSettings, Always, ImportSettings, PostForApprovalRules, PostWhenConfirmed,
};
use Needs::{AdminForBackupSettings, Always, PostForApprovalRules, PostWhenConfirmed};
const R: Needs = Always(Scope::Read);
const W: Needs = Always(Scope::Write);
const P: Needs = Always(Scope::Post);
@@ -62,12 +56,11 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 119] = [
pub const TABLE: [(&str, Needs); 118] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
("books.set_setting", AdminForBackupSettings),
("books.import_settings", ImportSettings),
("books.export_settings", R),
("books.conventions", R),
("books.set_convention", K),
@@ -220,20 +213,6 @@ pub fn needs(
});
if backup { Scope::Admin } else { Scope::Post }
}
ImportSettings => {
let backup = args
.and_then(|a| a.get("toml_text"))
.and_then(serde_json::Value::as_str)
.and_then(|t| t.parse::<toml::Table>().ok())
.is_some_and(|t| t.contains_key("backup"));
if backup {
Scope::Admin
} else if confirmed(args) {
Scope::Post
} else {
Scope::Write
}
}
PostForApprovalRules => {
let kind = args
.and_then(|a| a.get("kind"))
@@ -259,7 +238,7 @@ fn confirmed(args: Option<&serde_json::Map<String, serde_json::Value>>) -> bool
pub fn shown(tool: &str, scopes: &std::collections::BTreeSet<Scope>) -> bool {
match TABLE.iter().find(|(name, _)| *name == tool) {
Some((_, Always(scope))) => scopes.contains(scope),
Some((_, PostWhenConfirmed | PostForApprovalRules | ImportSettings)) => {
Some((_, PostWhenConfirmed | PostForApprovalRules)) => {
scopes.contains(&Scope::Write) || scopes.contains(&Scope::Post)
}
Some((_, AdminForBackupSettings)) => {
@@ -333,25 +312,6 @@ mod tests {
);
assert_eq!(set(&[" Backup.Folder"]), Some(Scope::Admin));
assert_eq!(needs("books.set_setting", None), Some(Scope::Post));
let import = |text: &str, confirmed: bool| {
needs(
"books.import_settings",
args(json!({"toml_text": text, "confirmed": confirmed})).as_ref(),
)
};
assert_eq!(
import("[company]\nname = \"A\"\n", false),
Some(Scope::Write)
);
assert_eq!(import("[company]\nname = \"A\"\n", true), Some(Scope::Post));
assert_eq!(
import("[backup]\nfolder = \"/x\"\n", false),
Some(Scope::Admin)
);
assert_eq!(
import("[backup]\nfolder = \"/x\"\n", true),
Some(Scope::Admin)
);
// Shown to whoever can set some setting.
let post: std::collections::BTreeSet<Scope> = [Scope::Post].into();
let write: std::collections::BTreeSet<Scope> = [Scope::Write].into();
@@ -359,7 +319,6 @@ mod tests {
assert!(shown("books.set_setting", &post));
assert!(shown("books.set_setting", &admin));
assert!(!shown("books.set_setting", &write));
assert!(shown("books.import_settings", &write));
}
#[test]
@@ -372,7 +331,7 @@ mod tests {
.and_then(|p| p.as_object())
.is_some_and(|p| p.contains_key("confirmed"));
let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap();
assert_eq!(takes, matches!(*needs, C | ImportSettings), "{}", tool.name);
assert_eq!(takes, *needs == C, "{}", tool.name);
let takes_kind = tool
.input_schema
.get("properties")
File diff suppressed because it is too large. Load diff
+37 -465
View File
@@ -1,91 +1,56 @@
//! The server's settings: the books' own (journaled, ledger_core::
//! settings), or, for books that hold none yet, the settings files
//! (`wanderbooks.toml`, and the receipt policy in `books-data/config.json`)
//! reloaded whenever they change.
//! settings), the defaults until the first is set.
//!
//! ```text
//! GOAL: every request sees the settings in force, one version for the
//! whole request; books that keep their own settings never read a
//! file; a broken file edit never takes the server down.
//! whole request, and they are only ever the books' own.
//! SOURCE: plan/served.md I-3 (the journal holds all books state; the
//! server never reads a settings file for books that hold their
//! settings), 2.1 ("dev-stdio keeps legacy_files mode until a book
//! is adopted"); DECISION (owner, 2026-10-15): file settings are
//! always auto-reloaded.
//! server never reads a settings file), 3.4 (a book in the data
//! root keeps its own settings), D-44 (one way to serve books: no
//! settings file is read by any of them).
//! INVARIANTS:
//! 1. Books whose journal holds settings (any settings.set) use those,
//! and from then on no file is read: sync takes them whenever the
//! journal's last settings.set changes.
//! 2. Otherwise every request (tool call, resource read) sees the files'
//! contents as of its start: a request after an edit sees the edit
//! (compared by content, not by timestamp, so two edits in one second
//! are both seen).
//! 3. A file that fails to load leaves the last good settings in force,
//! and the failure is reported (books.settings, stderr) until a later
//! edit loads.
//! 4. A settings file that doesn't exist and wasn't asked for (the
//! default wanderbooks.toml beside the books) means the defaults; one
//! named with --books-toml that goes missing is a failure (3).
//! 5. Only the settings change while serving: the data dir, the books'
//! base currency, and a vault key file given on the command line are
//! fixed when the server starts (the key file given wins over any
//! file's [vault] key_file).
//! 6. A book in the data root (plan 3.4) has no settings file at all:
//! its settings are its own, the defaults until the first is set,
//! and no file is ever read for it.
//! 1. The settings in force are the books' own: the defaults until
//! their journal holds any settings.set, then those; sync takes them
//! whenever the journal's last settings.set changes, and only then.
//! 2. No settings file is ever read.
//! ```
use crate::policy::ReceiptPolicy;
use ledger_config::Company;
use std::path::PathBuf;
use std::sync::Arc;
/// `[books]` account roles for the cash flow statement, used when a call
/// doesn't pass its own: cash, financing, and investing account codes.
#[derive(Clone, Debug, Default)]
pub struct CashRoles {
/// `[books] cash_accounts`.
/// `books.cash_accounts`.
pub cash: Vec<String>,
/// `[books] financing_accounts`.
/// `books.financing_accounts`.
pub financing: Vec<String>,
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
pub investing: Vec<String>,
}
/// What the settings files say, as last loaded.
/// The settings in force, as the server uses them.
#[derive(Clone, Debug, Default)]
pub struct Settings {
/// Which entries need a receipt.
pub policy: ReceiptPolicy,
/// `[company]`.
/// The company (`company.*`).
pub company: Company,
/// `[books]` cash flow roles.
/// The cash flow roles.
pub roles: CashRoles,
/// The prefix of generated asset tags (`[books] asset_tag_prefix`, or
/// The prefix of generated asset tags (`books.asset_tag_prefix`, or
/// the company's initials).
pub asset_tag_prefix: String,
/// `[vault] key_file`: the TIN vault's key file (unset: the vault's
/// default, `ledger_vault::default_key_file`).
pub vault_key_file: Option<PathBuf>,
/// `[books] parties`: suggest, require, or never mention each entry's
/// `policy.parties`: suggest, require, or never mention each entry's
/// counterparty.
pub parties: ledger_config::PartyPolicy,
/// The company's time zone (an IANA name), if set: what "today" means.
pub timezone: Option<String>,
/// Whether these are the books' own settings (journaled) rather than a
/// file's.
pub journaled: bool,
}
impl Settings {
/// The vault key file in force: `[vault] key_file`, else the default
/// outside the books (`~/.config/wanderbooks/vault.key`).
pub fn key_file(&self) -> PathBuf {
self.vault_key_file
.clone()
.unwrap_or_else(ledger_vault::default_key_file)
}
/// The settings the books keep (`ledger_core::settings`), as the
/// server uses them.
pub fn from_books(books: &ledger_core::settings::Settings, currency: &str) -> Self {
@@ -93,7 +58,6 @@ impl Settings {
Settings {
policy: ledger_config::codec::receipt_policy(books),
asset_tag_prefix: b.asset_tag_prefix(),
vault_key_file: None,
parties: b.parties,
timezone: b.timezone.clone(),
roles: CashRoles {
@@ -102,7 +66,6 @@ impl Settings {
investing: b.investing_accounts,
},
company: b.company,
journaled: true,
}
}
@@ -116,179 +79,24 @@ impl Settings {
}
}
/// Where the settings come from.
#[derive(Clone, Debug)]
pub struct Source {
/// The books' wanderbooks.toml.
pub books_toml: PathBuf,
/// Whether it must exist (named with --books-toml) or may be absent
/// (the default beside the books: then the defaults apply).
pub required: bool,
/// `config.json`, for its receipt policy (optional).
pub config_json: PathBuf,
}
/// Both files' bytes (`None`: absent), to tell when either changed.
type Seen = (Option<Vec<u8>>, Option<Vec<u8>>);
impl Source {
fn read(&self) -> Seen {
(
std::fs::read(&self.books_toml).ok(),
std::fs::read(&self.config_json).ok(),
)
}
/// Load the settings now.
pub fn load(&self) -> Result<Settings, String> {
let books = if self.required || self.books_toml.exists() {
ledger_config::load(&self.books_toml)?
} else {
ledger_config::BooksSettings::default()
};
// config.json sits in the data dir, which no message names.
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)
.map_err(|e| e.replace(&self.config_json.display().to_string(), "config.json"))?;
Ok(Settings {
asset_tag_prefix: books.asset_tag_prefix(),
vault_key_file: books.vault_key_file.clone(),
parties: books.parties,
timezone: books.timezone.clone(),
journaled: false,
policy,
roles: CashRoles {
cash: books.cash_accounts,
financing: books.financing_accounts,
investing: books.investing_accounts,
},
company: books.company,
})
}
}
/// The settings in force, and how they got there.
/// The settings in force, and the books' settings.set they came from.
#[derive(Debug)]
pub struct Live {
current: Arc<Settings>,
source: Option<Source>,
seen: Seen,
error: Option<String>,
loads: u64,
/// The seq of the books' last settings.set taken (INVARIANT 1).
journal_seq: Option<u64>,
/// A vault key file given on the command line (INVARIANT 5).
vault_key_file: Option<PathBuf>,
/// Whether the settings files may be read: false once the books keep
/// their own (INVARIANT 1).
read_files: bool,
/// A book in the data root: its settings are only ever its own
/// (INVARIANT 6).
own: bool,
}
/// What books.settings reports about the files.
#[derive(Clone, Debug)]
pub struct Status {
/// The wanderbooks.toml read, if the settings come from files.
pub books_toml: Option<PathBuf>,
/// Whether that file exists now.
pub books_toml_exists: bool,
/// Whether a config.json is read (it sits in the data dir, which is
/// never named).
pub config_json_present: bool,
/// Why the last change failed to load (the last good settings stay in
/// force), or `None`.
pub error: Option<String>,
/// How many times the settings have loaded, the first included.
pub loads: u64,
/// Whether the settings are the books' own (journaled): then no file
/// is read.
pub journaled: bool,
}
impl Live {
/// Settings that never reload (tests, and servers built in code).
pub fn fixed(settings: Settings) -> Self {
Self {
current: Arc::new(settings),
source: None,
seen: (None, None),
error: None,
loads: 1,
journal_seq: None,
vault_key_file: None,
read_files: false,
own: false,
}
}
/// For a book in the data root (INVARIANT 6): no settings file, now
/// or later; `sync` brings the books' settings in once they hold any.
/// For a book (INVARIANT 1): the defaults; `sync` brings the books'
/// settings in once they hold any.
pub fn own() -> Self {
Self {
current: Arc::new(Settings {
journaled: true,
..Settings::default()
}),
source: None,
seen: (None, None),
error: None,
loads: 0,
current: Arc::new(Settings::default()),
journal_seq: None,
vault_key_file: None,
read_files: false,
own: true,
}
}
/// Load from `source`, and reload from it whenever it changes. Fails
/// only on this first load: a server doesn't start on broken settings.
pub fn watching(source: Source) -> Result<Self, String> {
let seen = source.read();
let settings = source.load()?;
Ok(Self {
current: Arc::new(settings),
source: Some(source),
seen,
error: None,
loads: 1,
journal_seq: None,
vault_key_file: None,
read_files: true,
own: false,
})
}
/// For books that keep their own settings: nothing is read from
/// `source` (INVARIANT 1), not even at start, so a broken settings
/// file beside them can't stop the server; `sync` brings the books'
/// settings in.
pub fn journaled(source: Source) -> Self {
Self {
current: Arc::new(Settings {
journaled: true,
..Settings::default()
}),
source: Some(source),
seen: (None, None),
error: None,
loads: 0,
journal_seq: None,
vault_key_file: None,
read_files: false,
own: false,
}
}
/// Use this vault key file whatever the settings say (INVARIANT 5).
pub fn with_vault_key_file(mut self, path: Option<PathBuf>) -> Self {
self.vault_key_file = path;
if let Some(p) = &self.vault_key_file {
Arc::make_mut(&mut self.current).vault_key_file = Some(p.clone());
}
self
}
/// Take the books' own settings when they hold any and they changed
/// since the last look (INVARIANT 1). The server calls this whenever
/// it holds the books.
@@ -296,230 +104,34 @@ impl Live {
if !books.is_journaled() || books.last_seq() == self.journal_seq {
return;
}
let mut s = Settings::from_books(books, currency);
s.vault_key_file = self.vault_key_file.clone();
self.current = Arc::new(s);
self.current = Arc::new(Settings::from_books(books, currency));
self.journal_seq = books.last_seq();
self.read_files = false;
self.error = None;
self.loads += 1;
}
/// The settings files' texts as the server reads them now
/// (`wanderbooks.toml`, `config.json`; `None` for one absent), when the
/// settings come from files; `None` when the books keep their own or
/// the settings were built in code.
pub fn file_texts(&self) -> Option<(Option<String>, Option<String>)> {
let source = self.source.as_ref().filter(|_| self.read_files)?;
Some((
std::fs::read_to_string(&source.books_toml).ok(),
std::fs::read_to_string(&source.config_json).ok(),
))
}
/// The vault key file given on the command line, if any.
pub fn vault_key_override(&self) -> Option<PathBuf> {
self.vault_key_file.clone()
}
/// Whether the settings in force are the books' own.
pub fn is_journaled(&self) -> bool {
self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
}
/// The settings in force.
pub fn current(&self) -> Arc<Settings> {
self.current.clone()
}
/// Reload if a file changed since the last look: the server calls this
/// once at the start of every request, so the whole request sees one
/// version.
pub fn refresh(&mut self) {
if !self.read_files {
// The books keep their own settings (or the settings were
// built in code): no file is read (INVARIANT 1).
return;
}
if let Some(source) = &self.source {
let now = source.read();
if now != self.seen {
self.seen = now;
match source.load() {
Ok(mut s) => {
if let Some(p) = &self.vault_key_file {
s.vault_key_file = Some(p.clone());
}
self.current = Arc::new(s);
self.error = None;
self.loads += 1;
eprintln!("ledger-mcp: reloaded {}", source.books_toml.display());
}
Err(e) => {
eprintln!(
"ledger-mcp: settings not reloaded (keeping the last good ones): {e}"
);
self.error = Some(e);
}
}
}
}
}
/// Change the settings in force (builders; never reloads them away
/// unless a watched file changes).
#[cfg(test)]
pub fn edit(&mut self, f: impl FnOnce(&mut Settings)) {
f(Arc::make_mut(&mut self.current));
}
/// Where the settings come from and whether the last change loaded.
pub fn status(&mut self) -> Status {
self.refresh();
let journaled = self.is_journaled();
Status {
books_toml: self
.source
.as_ref()
.filter(|_| !journaled)
.map(|s| s.books_toml.clone()),
books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()),
config_json_present: self.source.as_ref().is_some_and(|s| s.config_json.exists()),
error: self.error.clone(),
loads: self.loads,
journaled,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::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 dir(tag: &str) -> TestDir {
let d =
std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TestDir(d)
}
fn source(d: &std::path::Path, required: bool) -> Source {
Source {
books_toml: d.join("wanderbooks.toml"),
required,
config_json: d.join("config.json"),
}
}
/// INVARIANT 3: an absent default means the defaults, and the file
/// loads once it appears; an unchanged file is not reloaded.
/// INVARIANT 1: books that hold no settings keep the defaults; their
/// settings come in once per change, and an unchanged journal leaves
/// the settings in force as they are.
#[test]
fn an_absent_default_is_the_defaults_until_it_appears() {
let d = dir("absent");
let mut live = Live::watching(source(&d, false)).unwrap();
fn books_take_their_own_settings_once_per_change() {
let mut live = Live::own();
assert_eq!(live.current().company.name, Company::default().name);
live.refresh();
assert_eq!(live.status().loads, 1, "nothing changed");
std::fs::write(
d.join("wanderbooks.toml"),
"[company]\nname = \"Late Co\"\n",
)
.unwrap();
live.refresh();
assert_eq!(live.current().company.name, "Late Co");
assert_eq!(live.status().loads, 2);
assert!(live.status().books_toml_exists);
}
/// INVARIANT 3: a named file must exist to start; INVARIANT 2: the
/// first load fails on broken settings, a later one keeps the last.
#[test]
fn a_named_file_must_load_to_start() {
let d = dir("named");
assert!(Live::watching(source(&d, true)).is_err());
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
assert!(Live::watching(source(&d, true)).is_err());
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = \"One\"\n").unwrap();
let mut live = Live::watching(source(&d, true)).unwrap();
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = 5\n").unwrap();
live.refresh();
assert_eq!(live.current().company.name, "One");
assert!(live.status().error.is_some());
}
/// config.json sits in the data dir, which no tool result names: a
/// broken one is reported by name, never by its path.
#[test]
fn a_broken_config_json_never_names_the_data_dir() {
let d = dir("json-broken");
std::fs::write(d.join("config.json"), "{ not json").unwrap();
let err = Live::watching(source(&d, false)).unwrap_err();
assert!(err.contains("config.json"), "{err}");
assert!(!err.contains(d.to_str().unwrap()), "{err}");
let status = Live::fixed(Settings::default()).status();
assert!(!status.config_json_present);
}
/// The receipt policy in config.json is watched too.
#[test]
fn the_config_json_policy_reloads() {
let d = dir("json");
std::fs::write(
d.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"25.00\"\n",
)
.unwrap();
let mut live = Live::watching(source(&d, true)).unwrap();
let before = live.current().policy.rule("USD");
std::fs::write(
d.join("config.json"),
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
)
.unwrap();
live.refresh();
assert_ne!(live.current().policy.rule("USD"), before);
assert!(live.status().error.is_none(), "{:?}", live.status().error);
}
/// Settings built in code never reload.
#[test]
fn fixed_settings_stay() {
let mut live = Live::fixed(Settings::default());
live.refresh();
let s = live.status();
assert_eq!((s.loads, s.books_toml), (1, None));
}
/// Books that keep their own settings: no file is read, even a broken
/// one; their settings come in once per change (INVARIANT 1).
#[test]
fn journaled_books_take_their_own_settings_once_per_change() {
let d = dir("journaled");
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
let mut live = Live::journaled(source(&d, true));
assert!(live.current().journaled);
assert!(live.is_journaled());
assert!(live.file_texts().is_none());
let mut books = ledger_core::settings::Settings::default();
let before = live.current();
live.sync(&books, "USD");
assert_eq!(live.status().loads, 0, "books without settings");
assert!(
Arc::ptr_eq(&before, &live.current()),
"books without settings"
);
let src = ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
@@ -533,54 +145,14 @@ mod tests {
books.apply(&[change("Kept Co")], &src, 7).unwrap();
live.sync(&books, "USD");
assert_eq!(live.current().company.name, "Kept Co");
assert_eq!(live.status().loads, 1);
let taken = live.current();
live.sync(&books, "USD");
assert_eq!(live.status().loads, 1, "unchanged: not taken again");
assert!(
Arc::ptr_eq(&taken, &live.current()),
"unchanged: not taken again"
);
books.apply(&[change("Kept Co 2")], &src, 9).unwrap();
live.sync(&books, "USD");
assert_eq!(live.current().company.name, "Kept Co 2");
assert_eq!(live.status().loads, 2);
assert!(live.current().journaled && live.is_journaled());
}
/// Settings built in code are the books' own once synced.
#[test]
fn fixed_settings_synced_are_journaled() {
let mut live = Live::fixed(Settings::default());
assert!(!live.is_journaled());
let mut books = ledger_core::settings::Settings::default();
let src = ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: None,
note: None,
};
books
.apply(
&[ledger_core::settings::Change {
key: "company.name".into(),
value: Some(serde_json::json!("Kept Co")),
}],
&src,
3,
)
.unwrap();
live.sync(&books, "USD");
assert!(live.is_journaled());
}
/// Books in the data root keep their own settings from the start
/// (INVARIANT 6): before they hold any, the settings in force say so
/// too, so no message sends anyone to a settings file; none is read.
#[test]
fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() {
let mut live = Live::own();
assert!(live.current().journaled);
assert!(live.is_journaled());
assert!(live.file_texts().is_none());
live.sync(&ledger_core::settings::Settings::default(), "USD");
assert!(live.current().journaled);
let s = live.status();
assert!(s.journaled && s.books_toml.is_none(), "{s:?}");
}
}
-3
View File
@@ -66,9 +66,6 @@ impl BooksServer {
params: Parameters<AccessLogParams>,
) -> Result<Json<AccessLogOut>, String> {
let p = params.0;
if self.access.is_none() {
return Err("these books keep no access log: only books served from the books' private folder do".into());
}
let limit = p.limit.unwrap_or(DEFAULT_RECORDS);
if limit == 0 || limit > MAX_RECORDS {
return Err(format!("limit is 1 to {MAX_RECORDS}"));
+8 -14
View File
@@ -7,9 +7,7 @@
//! SOURCE: plan 3.7 ("books.backup {status|run} reports it": two tools,
//! one read-only), D-30.
//! INVARIANTS:
//! 1. Only books served from the data root are backed up: over stdio
//! both tools are hidden, and refuse.
//! 2. A status reads the backup record only; a run makes and pushes a
//! 1. A status reads the backup record only; a run makes and pushes a
//! backup (backup.rs) and answers the status after it.
//! ```
@@ -86,14 +84,6 @@ impl From<crate::backup::Status> for BackupOut {
}
}
impl BooksServer {
fn backups(&self) -> Result<&std::sync::Arc<crate::backup::Backups>, String> {
self.backups.as_ref().ok_or_else(|| {
"these books aren't backed up by this server: only books kept in the books' own private folder are".to_string()
})
}
}
#[tool_router(router = backup_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
@@ -103,7 +93,7 @@ impl BooksServer {
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn backup(&self, _params: Parameters<BackupParams>) -> Result<Json<BackupOut>, String> {
Ok(Json(self.backups()?.status().await.into()))
Ok(Json(self.backups.status().await.into()))
}
#[tool(
@@ -121,7 +111,11 @@ impl BooksServer {
&self,
params: Parameters<BackUpNowParams>,
) -> Result<Json<BackupOut>, String> {
let b = self.backups()?;
Ok(Json(b.run_with("asked", params.0.new_disk).await?.into()))
Ok(Json(
self.backups
.run_with("asked", params.0.new_disk)
.await?
.into(),
))
}
}
+38 -43
View File
@@ -17,17 +17,17 @@
//! 2. begin_upload makes nothing for a receipt or document whose bytes
//! are stored already: it names the attachment (not withdrawn) that
//! holds them.
//! 3. Uploads exist only for books served from the data root (the
//! server's socket carries the bytes); over stdio, only inline.
//! 3. An upload is kept for the token that began it, and so is an
//! export: a call with no token (never one the books server lets
//! through) begins and takes none.
//! 4. An upload is filed only by a tool of the purpose it was begun for
//! (a receipt or document, a CSV import, a W-9); another tool's
//! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
//! before it is decoded.
//! 6. A file the books make for an agent (D-29) is handed over as an
//! export the caller's token fetches with `wanderbooks download`
//! (books served from the data root), or over stdio inline; never
//! by a path.
//! export the caller's token fetches with `wanderbooks download`;
//! never inline, never by a path.
//! ```
use super::*;
@@ -37,8 +37,10 @@ use serde::{Deserialize, Serialize};
/// The most bytes given inline (plan 2.1: 256 KiB).
pub(crate) const INLINE_MAX: usize = 256 * 1024;
/// Why an upload is refused on a connection that has none (INVARIANT 3).
const STDIO_ONLY_INLINE: &str = "this connection takes files inline only (content_base64 with its sha256, at most 256 KiB): a larger one needs these books served by `wanderbooks mcp`; tell the human";
/// Why a call with no token begins, takes or is handed no file
/// (INVARIANT 3).
const NO_CALLER: &str =
"this call came without a token for these books: a file is kept only for the token that asked";
/// What the bytes of a file a tool files came as (INVARIANT 1), and the
/// upload purposes the tool takes (INVARIANT 4).
@@ -146,7 +148,7 @@ fn plain_file_name(name: &str) -> String {
impl BooksServer {
/// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export
/// for the caller's token, or inline over stdio.
/// for the caller's token.
pub(crate) fn hand_over(
&self,
book: &str,
@@ -160,23 +162,9 @@ impl BooksServer {
None => file_name.to_string(),
Some(_) => plain_file_name(file_name),
};
let mut out = HandedFile {
file_name: file_name.clone(),
mime_type: mime.to_string(),
size_bytes: bytes.len() as u64,
sha256: ledger_store::journal::sha256_hex(&bytes),
download: None,
expires_at: None,
content_base64: None,
};
let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else {
out.content_base64 = Some(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
));
return Ok(out);
};
let kept = exports
let token = self.caller_token().ok_or(NO_CALLER)?;
let kept = self
.exports
.put(
&token,
&file_name,
@@ -185,14 +173,22 @@ impl BooksServer {
chrono::Utc::now().timestamp(),
)
.map_err(|e| e.to_string())?;
out.download = Some(format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
token_flag(),
kept.id
));
out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
Ok(out)
// Always a real time: a kept export expires EXPORT_TTL from now.
let expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.ok_or("the file's expiry is past the calendar")?;
Ok(HandedFile {
download: format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
token_flag(),
kept.id
),
expires_at,
size_bytes: bytes.len() as u64,
sha256: ledger_store::journal::sha256_hex(&bytes),
file_name,
mime_type: mime.to_string(),
})
}
/// The token of the call running on this thread (its id), if any.
@@ -210,10 +206,9 @@ impl BooksServer {
),
(Some(id), None) => {
// INVARIANT 3.
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let got = uploads
let token = self.caller_token().ok_or(NO_CALLER)?;
let got = self
.uploads
.take(id.trim(), &token, chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
// INVARIANT 4: dropped, the claim gives it back.
@@ -268,7 +263,7 @@ impl BooksServer {
#[tool(
name = "books.begin_upload",
title = "Begin an upload",
description = "Send a file of up to 25 MiB (books served by `wanderbooks mcp`; elsewhere only inline) without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
description = "Send a file of up to 25 MiB without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -313,11 +308,10 @@ impl BooksServer {
existing_attachment_id: Some(id),
}));
}
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let token = self.caller_token().ok_or(NO_CALLER)?;
let now = chrono::Utc::now();
let grant = uploads
let grant = self
.uploads
.grant(
&token,
&sha,
@@ -328,9 +322,10 @@ impl BooksServer {
now.timestamp(),
)
.map_err(|e| e.to_string())?;
// Always a real time: a grant lapses UPLOAD_TTL from now.
let expires = chrono::DateTime::from_timestamp(grant.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_default();
.ok_or("the upload's expiry is past the calendar")?;
Ok(Json(BeginUploadOut {
command: Some(format!(
"wanderbooks upload --book {book}{} {} -- <the file's path>",
+15 -287
View File
@@ -1,7 +1,7 @@
//! How the books are kept, through the server: `books.start`, settings
//! (`books.settings`, `books.set_setting`, `books.import_settings`,
//! `books.export_settings`), conventions, decisions, open items, drafts
//! from posting templates, and approvals (plan/served.md P1, 4.2, 4.9).
//! (`books.settings`, `books.set_setting`, `books.export_settings`),
//! conventions, decisions, open items, drafts from posting templates,
//! and approvals (plan/served.md P1, 4.2, 4.9).
//!
//! ```text
//! GOAL: an agent anywhere learns how these books are kept from the books
@@ -12,17 +12,14 @@
//! INVARIANTS:
//! 1. Every change names who decided it and when (decided_by;
//! decided_on defaults to today in the company's time zone).
//! 2. A settings file is never half moved in: books.set_setting refuses
//! while the books still read a settings file (import it first), and
//! books.import_settings is refused unless, after it, the server
//! reads every setting as it does now (company, receipt rule, cash
//! flow roles, asset tag prefix, parties, time zone, vault key).
//! 2. The books keep their own settings, and only books.set_setting
//! changes them (old books' settings file is read once, by
//! `wanderbooks books adopt`).
//! 3. Identity values (the company's EIN) leave the server masked.
//! 4. books.start is read-only and at most START_MAX bytes.
//! ```
use super::*;
use crate::settings::Settings as Live;
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::{self as core_settings, Change, KEYS, Sensitivity, Source};
use schemars::JsonSchema;
@@ -242,10 +239,6 @@ pub struct StartOut {
pub head: HeadOut,
/// needs_onboarding (no chart of accounts yet) or active.
pub state: String,
/// Where the settings come from: "books" (journaled, the books' own)
/// or "file" (these books still read a settings file: import it with
/// books.import_settings).
pub settings_source: String,
/// The rules in force.
pub rules: RulesOut,
/// Every setting set, with who set it.
@@ -322,54 +315,12 @@ pub struct SetSettingOut {
pub problems: Vec<String>,
}
/// Parameters for `books.import_settings`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportSettingsParams {
/// The settings file's text (wanderbooks.toml), as written.
pub toml_text: String,
/// The receipt policy file's text ({"receipt_policy": {...}}), when
/// books.settings says the server reads one (config_json_present):
/// rebuild it from books.settings' receipt rule and account lists.
#[serde(default)]
pub config_json_text: Option<String>,
/// Without true: a preview of every change, note and unknown entry;
/// nothing is recorded.
#[serde(default)]
pub confirmed: bool,
/// Import even though the file holds entries the books don't know
/// (listed in the preview as unknown; they are not imported).
#[serde(default)]
pub skip_unknown: bool,
/// Who decided to move the settings in, and when.
#[serde(flatten)]
pub source: SourceIn,
}
/// Result of `books.import_settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ImportSettingsOut {
/// "preview" or "imported".
pub status: String,
/// The changes that make the books' settings what the file says.
pub changes: Vec<ChangeOut>,
/// What the file says that isn't a books setting, and where it goes.
pub notes: Vec<String>,
/// Entries the books don't know: not imported (confirm with
/// skip_unknown, or record them as conventions first).
pub unknown: Vec<String>,
/// The file's comments, in order. They are not settings and are not
/// imported: record what matters in them (a value's source, a
/// discrepancy, something pending) as a decision, an open item, a
/// convention, or a setting's note, before or after confirming.
pub comments: Vec<String>,
}
/// Result of `books.export_settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportSettingsOut {
/// The settings as a settings file (TOML), each with who decided it
/// as a comment; identity values masked. A copy: editing it changes
/// nothing until imported.
/// nothing.
pub toml_text: String,
}
@@ -740,51 +691,12 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut {
}
}
/// Where two server readings of the settings differ (INVARIANT 2).
fn reads_differently(a: &Live, b: &Live) -> Vec<&'static str> {
let threshold = |s: &Live| ledger_config::decimal(s.policy.require_receipts_above.trim());
[
("company", a.company.name == b.company.name),
(
"fiscal year",
a.company.fiscal_year_start == b.company.fiscal_year_start,
),
("members", a.company.members == b.company.members),
("basis", a.company.basis == b.company.basis),
("tax form", a.company.tax_form == b.company.tax_form),
("close target", a.company.close_into == b.company.close_into),
(
"close sweep",
a.company.close_sweep == b.company.close_sweep,
),
("receipt threshold", threshold(a) == threshold(b)),
(
"receipt required accounts",
a.policy.receipt_required_accounts == b.policy.receipt_required_accounts,
),
(
"receipt exempt accounts",
a.policy.receipt_exempt_accounts == b.policy.receipt_exempt_accounts,
),
("cash accounts", a.roles.cash == b.roles.cash),
("financing accounts", a.roles.financing == b.roles.financing),
("investing accounts", a.roles.investing == b.roles.investing),
("asset tag prefix", a.tag_prefix() == b.tag_prefix()),
("parties", a.parties == b.parties),
("time zone", a.timezone == b.timezone),
]
.into_iter()
.filter(|(_, same)| !same)
.map(|(n, _)| n)
.collect()
}
impl BooksServer {
/// The books' `books.start` (INVARIANT 4).
fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut {
let cfg = self.settings();
let ledger = store.ledger();
let kept = self.settings_as_kept(store);
let kept = store.ledger().settings().clone();
let books = &kept;
let today = today();
let (fy_start, fy_end) = cfg.company.fiscal_year_containing(today);
@@ -843,7 +755,6 @@ impl BooksServer {
projects.sort_by_key(|a| (a.name.to_lowercase(), a.id));
let mut p_cut = projects.len().saturating_sub(START_PROJECTS);
projects.truncate(START_PROJECTS);
let journaled = cfg.journaled;
let focus = focus.unwrap_or("daily");
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
tool: tool.into(),
@@ -862,15 +773,6 @@ impl BooksServer {
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
));
}
// Books that read a settings file move it in before anything else
// changes a setting (books.set_setting refuses until then).
if !journaled {
next_steps.push(step(
"books.import_settings",
None,
"These books still read a settings file. Move it into the books (preview, show the human, confirm), so every agent sees one record and no file is read.",
));
}
if onboarding || focus == "onboarding" {
next_steps.push(step(
"books.handbook",
@@ -893,8 +795,8 @@ impl BooksServer {
}
// The books' backups (backup.rs): none set up, or one behind or
// failing.
if let Some(b) = &self.backups {
let st = b.standing(store);
{
let st = self.backups.standing(store);
if st.note.is_some() {
next_steps.push(step(
"books.backup",
@@ -958,12 +860,10 @@ impl BooksServer {
"active"
}
.into(),
settings_source: if journaled { "books" } else { "file" }.into(),
rules: RulesOut {
evidence: cfg.policy.rule(self.currency()),
parties: cfg.parties.key().into(),
// What the server enforces: the books' own setting (a
// settings file's is never enforced).
// What the server enforces: the books' own setting.
approval_rules: match ledger.settings().approval_rules() {
core_settings::ApprovalMode::Off => "off",
core_settings::ApprovalMode::Enforce => "enforce",
@@ -1047,19 +947,6 @@ impl BooksServer {
out
}
/// Refuse a settings change while the books still read a settings
/// file that sets something (INVARIANT 2): it would drop the rest.
fn ensure_settings_in_books(&self) -> Result<(), String> {
let status = self.live().status();
if !status.journaled && (status.books_toml_exists || status.config_json_present) {
return Err(
"these books still read their settings file: move it in with books.import_settings first (preview, then confirm), so no setting is lost; then change settings here"
.into(),
);
}
Ok(())
}
/// The settings in force after a settings change: take the books'.
fn sync_settings(&self, store: &Store) {
self.live().sync(store.ledger().settings(), self.currency());
@@ -1090,7 +977,7 @@ impl BooksServer {
#[tool(
name = "books.set_setting",
title = "Change settings",
description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing. Refused while the books still read a settings file: import it first (books.import_settings).",
description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -1104,7 +991,6 @@ impl BooksServer {
) -> Result<Json<SetSettingOut>, String> {
let p = params.0;
let source = p.source.source()?;
self.ensure_settings_in_books()?;
if p.changes.is_empty() {
return Err("changes is empty: pass at least one {key, value}".into());
}
@@ -1133,173 +1019,16 @@ impl BooksServer {
}))
}
#[tool(
name = "books.import_settings",
title = "Move a settings file into the books",
description = "Move a settings file (its TOML text, and the receipt policy file's text if the server reads one) into the books, after which no file is read. Without confirmed: a preview of every change, what the file says that isn't a books setting (notes), and entries the books don't know (unknown). Show it to the human; confirm with confirmed: true. Refused if afterwards any setting would read differently than now.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn import_settings(
&self,
params: Parameters<ImportSettingsParams>,
) -> Result<Json<ImportSettingsOut>, String> {
let p = params.0;
let source = p.source.source()?;
let mut store = self.lock()?;
let status = self.live().status();
if let Some(e) = &status.error {
return Err(format!(
"the settings file doesn't load ({e}): fix it first, so the import moves in the settings in force"
));
}
let current = store.ledger().settings().clone();
let import = ledger_config::codec::import(
&p.toml_text,
p.config_json_text.as_deref(),
&current,
self.currency(),
)?;
let differing = |theirs: &[Change]| -> Vec<String> {
let mut keys: Vec<String> = import
.changes
.iter()
.filter(|c| !theirs.contains(c))
.chain(theirs.iter().filter(|c| !import.changes.contains(c)))
.map(|c| c.key.clone())
.collect();
keys.sort();
keys.dedup();
keys
};
if status.journaled {
// The books keep their own settings: an import may only confirm
// them (INVARIANT 2); changes go through books.set_setting.
let differs = differing(&[]);
if !differs.is_empty() {
return Err(format!(
"these books keep their own settings, and this text differs from them in {}: change settings with books.set_setting (the books record who decided each change)",
differs.join(", ")
));
}
} else if let Some((toml, json)) = self.live().file_texts() {
// INVARIANT 2: the text is the file the server reads, exactly:
// its import is the server's own reading of its files.
let own = ledger_config::codec::import(
toml.as_deref().unwrap_or(""),
json.as_deref(),
&current,
self.currency(),
)?;
let differs = differing(&own.changes);
if !differs.is_empty() {
return Err(format!(
"this text isn't the settings the server reads now: it differs in {}. Pass the settings file books.settings names, whole, as toml_text{}",
differs.join(", "),
if json.is_some() {
", and the receipt policy as config_json_text: {\"receipt_policy\": {...}} with books.settings' threshold and its required and exempt account lists"
} else {
""
}
));
}
}
// And, after it, the server reads every setting as now (the
// company, the receipt rule, the cash flow roles, the asset tag
// prefix, parties, the time zone, approvals).
let mut after = current.clone();
after
.apply(&import.changes, &source, u64::MAX)
.map_err(bad)?;
let now = self.settings();
let then = Live::from_books(&after, self.currency());
let differs = reads_differently(&now, &then);
if !differs.is_empty() {
return Err(format!(
"after this import the server would read these settings differently than it does now: {}",
differs.join(", ")
));
}
if !status.journaled && after.approval_rules() != ledger_core::settings::ApprovalMode::Off {
return Err(
"the server doesn't enforce approval rules for books that read a settings file: take approval_rules out of the file, import it, then set policy.approval_rules with books.set_setting"
.into(),
);
}
// The vault key the server uses now, and after: the books never
// keep its path, so a file's [vault] key_file has to come from the
// command line from then on (settings INVARIANT 5).
let key_now = now.key_file();
let key_then = self
.live()
.vault_key_override()
.unwrap_or_else(ledger_vault::default_key_file);
if key_now != key_then {
return Err(
"the settings file names the vault's key file ([vault] key_file), which the books don't keep: start the server with --vault-key-file naming that same file before moving the settings in, or the vault would stop opening"
.into(),
);
}
let changes = |list: &[Change]| -> Vec<ChangeOut> {
list.iter()
.map(|c| ChangeOut {
key: c.key.clone(),
was: shown(&c.key, current.get(&c.key).map(|r| &r.value)),
value: shown(&c.key, c.value.as_ref()),
})
.collect()
};
// The preview refuses what the import would: the same checks.
store
.ledger()
.check_settings_adopting(&import.changes, &source, true)
.map_err(bad)?;
let mut out = ImportSettingsOut {
status: "preview".into(),
changes: changes(&import.changes),
notes: import.notes.clone(),
unknown: import.unknown.clone(),
comments: import.comments.clone(),
};
if !p.confirmed {
return Ok(Json(out));
}
if !import.unknown.is_empty() && !p.skip_unknown {
return Err(format!(
"the file holds entries the books don't know ({}): they would not be imported. Show them to the human; record any that matter as conventions (books.set_convention), then confirm with skip_unknown: true",
import.unknown.join(", ")
));
}
let made = store
.adopt_settings(&import.changes, &source)
.map_err(bad)?;
self.sync_settings(&store);
out.status = "imported".into();
out.changes = changes(&made);
Ok(Json(out))
}
#[tool(
name = "books.export_settings",
title = "Settings as a file",
description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading or for other books: editing it changes nothing here until imported.",
description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading: editing it changes nothing here.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export_settings(&self) -> Result<Json<ExportSettingsOut>, String> {
let store = self.lock()?;
// The books' own: the defaults until the first is set.
let s = store.ledger().settings();
// Books in the data root read no file: theirs are the defaults
// until the first is set.
if !s.is_journaled() && !self.live().status().journaled {
return Err(
"these books still read their settings file, which is the copy: move it in with books.import_settings first"
.into(),
);
}
Ok(Json(ExportSettingsOut {
toml_text: ledger_config::codec::export(s, self.currency()),
}))
@@ -1655,8 +1384,7 @@ pub(super) fn keys_out(books: &core_settings::Settings) -> Vec<SettingKeyOut> {
identity: k.sensitivity == Sensitivity::Identity,
value: r.map(|r| core_settings::display(k, &r.value, false)),
set_by: r.map(|r| source_out(&r.source)),
// Seq 0: read from the settings file, not journaled.
seq: r.map(|r| r.seq).filter(|q| *q > 0),
seq: r.map(|r| r.seq),
}
})
.collect()
+58 -72
View File
@@ -536,13 +536,13 @@ pub struct Vendor1099Params {
#[serde(default)]
pub csv: bool,
/// The cash account codes payments are counted out of, e.g.
/// `["1000"]`. Defaults to `[books] cash_accounts`, as the CPA
/// `["1000"]`. Defaults to `books.cash_accounts`, as the CPA
/// package's do: one or the other is required.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// The financing liability codes (what the members advanced, loans)
/// payments are counted out of. Defaults to `[books]
/// financing_accounts`, as the CPA package's do.
/// payments are counted out of. Defaults to
/// `books.financing_accounts`, as the CPA package's do.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Count only the payments dated on or before this day of `year`
@@ -782,13 +782,23 @@ pub struct ChartTemplateOut {
/// Chart CSV (code,name,type,parent_code,tax_category) for
/// `books.import_chart_csv`, after review.
pub csv: String,
/// The settings that go with it, as settings-file TOML for
/// `books.import_settings`: tax form, owners, where period closes go.
pub settings_toml: String,
/// The settings that go with it, as changes for `books.set_setting`
/// (after review, with who decided): tax form, owners, where period
/// closes go.
pub settings: Vec<TemplateSettingOut>,
/// What to decide or know.
pub notes: Vec<String>,
}
/// One setting a chart template goes with.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TemplateSettingOut {
/// The key.
pub key: String,
/// Its value, as `books.set_setting` takes it.
pub value: serde_json::Value,
}
/// Parameters for `books.renumber_account`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RenumberAccountParams {
@@ -807,10 +817,9 @@ pub struct RenumberAccountOut {
pub previous_code: String,
/// The code it has now.
pub code: String,
/// Settings that still name the old code (as books.set_setting keys,
/// or the settings file's labels for books whose settings are in a
/// file), and conventions whose template or approval rule names it:
/// change each (books.set_setting, books.set_convention).
/// Settings that still name the old code (as books.set_setting keys),
/// and conventions whose template or approval rule names it: change
/// each (books.set_setting, books.set_convention).
pub config_mentions: Vec<String>,
}
@@ -832,10 +841,6 @@ pub struct MemberSettingOut {
/// Result of `books.settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SettingsOut {
/// Where the settings come from: "books" (the books keep them; change
/// them with books.set_setting) or "file" (a settings file these books
/// still read: move it in with books.import_settings).
pub source: String,
/// The company's time zone (IANA), or null: the computer's.
pub timezone: Option<String>,
/// Today, in that time zone.
@@ -845,27 +850,14 @@ pub struct SettingsOut {
/// Every setting the books know: what it takes, its value (null:
/// unset, its default applies), and who set it.
pub keys: Vec<crate::server::SettingKeyOut>,
/// The wanderbooks.toml the server reads (null: the books keep their
/// own settings, or settings built in).
pub books_toml: Option<String>,
/// Whether that file exists (absent: the defaults apply).
pub books_toml_exists: bool,
/// Whether the server reads a receipt policy from a configuration
/// file of its own.
pub config_json_present: bool,
/// Why the last edit failed to load, or null. While set, the settings
/// below are the last ones that loaded: fix the file.
pub reload_error: Option<String>,
/// How many times the settings have loaded (the first included); it
/// goes up each time an edit loads.
pub loads: u64,
/// `[company] name`.
/// `company.name`.
pub company: String,
/// `[company] fiscal_year_start`, "MM-DD".
/// `company.fiscal_year_start`, "MM-DD".
pub fiscal_year_start: String,
/// The return filed (`tax_form`, or from `tax_classification`), or null.
/// The return filed (`company.tax_form`, or from
/// `company.tax_classification`), or null.
pub tax_form: Option<String>,
/// `[books] accounting_basis`.
/// `books.accounting_basis`.
pub basis: String,
/// The receipt rule in force (it shows a few account codes of each
/// kind, shortened; the two lists below hold them all).
@@ -877,22 +869,22 @@ pub struct SettingsOut {
/// every line is in one needs none): the policy's whole list, as
/// written.
pub receipt_exempt_accounts: Vec<String>,
/// `[books] parties`: "off", "suggest", or "require".
/// `policy.parties`: "off", "suggest", or "require".
pub parties: String,
/// `[[company.members]]`.
/// `company.members`.
pub members: Vec<MemberSettingOut>,
/// `[books] cash_accounts`.
/// `books.cash_accounts`.
pub cash_accounts: Vec<String>,
/// `[books] financing_accounts`.
/// `books.financing_accounts`.
pub financing_accounts: Vec<String>,
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
pub investing_accounts: Vec<String>,
/// `[books] close_into`.
/// `books.close_into`.
pub close_into: Option<String>,
/// `[books] close_sweep`.
/// `books.close_sweep`.
pub close_sweep: Vec<String>,
/// Settings that disagree with the books, each saying what to change in
/// wanderbooks.toml (or the chart). Empty: nothing to fix.
/// Settings that disagree with the books, each saying what to change
/// (with books.set_setting, or in the chart). Empty: nothing to fix.
pub problems: Vec<String>,
}
@@ -1415,7 +1407,7 @@ pub struct DraftEntryParams {
/// `vendor`, both must name the same vendor.
pub party: Option<String>,
/// Why the entry has no counterparty (a transfer between the company's
/// own accounts, an accrual), when `[books] parties = "require"` and
/// own accounts, an accrual), when `policy.parties = "require"` and
/// none applies. Not with `party`, `parties`, or `vendor`.
pub no_party_reason: Option<String>,
/// Several parties, each with their share (as `books.set_entry_party`
@@ -1605,7 +1597,7 @@ pub struct PostEntryOut {
/// receipt threshold (same date, description, and debit accounts).
/// Empty when no split is detected.
pub split_detected: Vec<u64>,
/// `[books] parties`: "off", "suggest", or "require".
/// `policy.parties`: "off", "suggest", or "require".
pub party_policy: String,
/// The entry's counterparty, when it names exactly one.
pub party_id: Option<u64>,
@@ -2089,11 +2081,11 @@ pub struct DigestOut {
/// lost; tell the human, and if they had asked for a change moments before,
/// check it is there and redo it if not.
pub journal_repairs: Vec<JournalRepair>,
/// Settings in wanderbooks.toml that need changing: an edit that
/// didn't load, or a setting that disagrees with the chart. Fix each
/// with the human, then check books.settings.
/// Settings that need changing: a setting that disagrees with the
/// chart. Fix each with the human (books.set_setting), then check
/// books.settings.
pub settings_problems: Vec<String>,
/// Cash and card accounts (`[books] cash_accounts`, and liabilities
/// Cash and card accounts (`books.cash_accounts`, and liabilities
/// with a recorded statement) with activity not reconciled through the
/// end of last month: record each statement and reconcile it.
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
@@ -2167,17 +2159,17 @@ pub struct ExportParams {
#[schemars(description = export_as_of_doc())]
pub as_of: Option<String>,
/// Cash account codes for cash_flow, and for iif its BANK accounts.
/// Defaults to `[books] cash_accounts`; one or the other is required
/// Defaults to `books.cash_accounts`; one or the other is required
/// for cash_flow.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing for cash_flow.
/// Defaults to `[books] financing_accounts`, else empty.
/// Defaults to `books.financing_accounts`, else empty.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the
/// company makes, investments it buys (ASC 230-10-20). Defaults to
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
#[serde(default)]
pub investing_accounts: Vec<String>,
}
@@ -2261,8 +2253,8 @@ pub struct RegisterAssetParams {
/// Depreciation expense account, id or code.
pub expense_account: String,
/// The asset tag for its label (letters, digits, - _ . /; at most 32).
/// Omit to generate the next one ("FA-0001", from `[books]
/// asset_tag_prefix` or the company's initials).
/// Omit to generate the next one ("FA-0001", from
/// `books.asset_tag_prefix` or the company's initials).
pub tag: Option<String>,
/// The manufacturer's serial number, if known.
pub serial: Option<String>,
@@ -2750,7 +2742,7 @@ pub struct DisposeAssetParams {
/// receipt policy applies.
pub receipt_attachment_ids: Option<Vec<u64>>,
/// The buyer (party id or name), named on the disposal entry. Under
/// `[books] parties = "require"` a sale with proceeds needs one; a
/// `policy.parties = "require"` a sale with proceeds needs one; a
/// retirement with no proceeds and no buyer is marked as having no
/// counterparty.
#[serde(default)]
@@ -3266,7 +3258,7 @@ pub struct RemitPayrollTaxesParams {
/// Cash account id or code the remittance comes from.
pub cash_account: String,
/// The tax agency paid (party id or name, e.g. the IRS), named on the
/// remittance entry. Required under `[books] parties = "require"`.
/// remittance entry. Required under `policy.parties = "require"`.
#[serde(default)]
pub agency: Option<String>,
}
@@ -3640,17 +3632,17 @@ pub struct ExportCpaPackageParams {
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
/// Cash account codes the cash flow statement reconciles to, e.g.
/// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other
/// `["1000"]`. Defaults to `books.cash_accounts`; one or the other
/// is required.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing. Defaults to `[books]
/// financing_accounts`, else empty.
/// Liability account codes treated as financing. Defaults to
/// `books.financing_accounts`, else empty.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the
/// company makes, investments it buys (ASC 230-10-20). Defaults to
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
#[serde(default)]
pub investing_accounts: Vec<String>,
/// Include every receipt for the year's entries (every current file
@@ -3895,16 +3887,11 @@ pub struct HandedFile {
pub size_bytes: u64,
/// Its SHA-256, hex.
pub sha256: String,
/// The command that saves it in a folder you name (run it; books
/// served by `wanderbooks mcp`). Kept 15 minutes.
#[serde(skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
/// When it lapses (UTC), with `download`.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// Over stdio: its bytes, base64.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
/// The command that saves it in a folder you name (run it). Kept 15
/// minutes.
pub download: String,
/// When it lapses (UTC).
pub expires_at: String,
}
/// Result of `books.get_w9`: the W-9 file, handed over.
@@ -4026,7 +4013,7 @@ pub struct SetEntryPartyParams {
/// shares that don't add up to what it moved.
pub parties: Option<Vec<PartyShareParam>>,
/// With `party` null: mark the entry as having no counterparty, with
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
/// why (journaled as `entry.no_party`). Satisfies `policy.parties =
/// "require"`.
pub no_party_reason: Option<String>,
}
@@ -4159,12 +4146,11 @@ pub struct ListPartiesOut {
/// reversal counts on its original's side and nets it to zero. (Not the
/// books' accounting method.)
///
/// Money is what left or reached the `[books] cash_accounts` (every asset
/// Money is what left or reached the `books.cash_accounts` (every asset
/// account but receivables when none are set), the cards (the
/// business-card payment methods' liabilities, `books.instruments`; while
/// none is recorded, liabilities whose names say "card", not "gift
/// card"), and the `[books]
/// financing_accounts` (what the members
/// card"), and the `books.financing_accounts` (what the members
/// advanced, loans): a card charge or a member paying for the company is
/// paid to the payee then; cash that pays one of these down is paid to
/// its creditor (a reimbursement to the members, a card payment to the
-236
View File
@@ -1,236 +0,0 @@
//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the
//! transport doesn't do.
//!
//! ```text
//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says
//! it gets, even one rmcp would drop.
//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request,
//! id echoed, or null when it can't be detected; 5.1: -32700 Parse
//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches.
//! INVARIANTS:
//! 1. A line that isn't JSON gets {"code": -32700} with id null.
//! 2. JSON that isn't a request, notification, or response object (an
//! array, a scalar, an object without "jsonrpc": "2.0" and a string
//! "method" or a result/error) gets {"code": -32600}, with the
//! line's id when it has a valid one (string, number, null), else
//! null.
//! 3. Everything else goes to rmcp unchanged; a blank line is ignored.
//! 4. Output lines are written whole: replies made here never split a
//! line rmcp writes.
//! ```
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
/// What to do with one line from the client.
#[derive(Debug, PartialEq)]
pub enum Line {
/// Hand it to rmcp.
Forward,
/// Ignore it (blank).
Skip,
/// Answer it here with this JSON-RPC response.
Reply(Value),
}
/// Classify one line (INVARIANTS 1-3).
pub fn classify(line: &str) -> Line {
if line.trim().is_empty() {
return Line::Skip;
}
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")),
};
let Some(obj) = v.as_object() else {
return Line::Reply(error(Value::Null, -32600, "Invalid Request"));
};
let id = match obj.get("id") {
Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(),
_ => Value::Null,
};
let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
let id_ok = match obj.get("id") {
None => true,
Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null),
};
let request = obj.get("method").is_some_and(Value::is_string);
let response =
obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error"));
if version_ok && id_ok && (request || response) {
Line::Forward
} else {
Line::Reply(error(id, -32600, "Invalid Request"))
}
}
fn error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
/// Run the wire between the real `input`/`output` and rmcp's ends:
/// returns (what rmcp reads, what rmcp writes) and the task that must be
/// awaited after rmcp finishes, so the last lines are flushed.
pub fn spawn<R, W>(
input: R,
mut output: W,
) -> (
tokio::io::DuplexStream,
tokio::io::DuplexStream,
tokio::task::JoinHandle<()>,
)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
const BUF: usize = 1 << 20;
let (rmcp_in, mut feed) = tokio::io::duplex(BUF);
let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF);
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let replies = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Ok(Some(line)) = lines.next_line().await {
match classify(&line) {
Line::Skip => {}
Line::Forward => {
if feed
.write_all(format!("{line}\n").as_bytes())
.await
.is_err()
{
break;
}
}
Line::Reply(v) => {
let _ = replies.send(v.to_string());
}
}
}
// Dropping `feed` ends rmcp's input.
});
tokio::spawn(async move {
let mut lines = BufReader::new(from_rmcp).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send(line).is_err() {
break;
}
}
});
let writer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
if output
.write_all(format!("{line}\n").as_bytes())
.await
.is_err()
|| output.flush().await.is_err()
{
break;
}
}
});
(rmcp_in, rmcp_out, writer)
}
#[cfg(test)]
mod tests {
use super::*;
fn code(l: Line) -> (i64, Value) {
match l {
Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()),
other => panic!("{other:?}"),
}
}
#[test]
fn classifies_by_the_jsonrpc_spec() {
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)),
(-32700, Value::Null)
);
assert_eq!(code(classify("not json")), (-32700, Value::Null));
assert_eq!(code(classify("[]")), (-32600, Value::Null));
assert_eq!(code(classify("[1]")), (-32600, Value::Null));
assert_eq!(code(classify("5")), (-32600, Value::Null));
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","id":98}"#)),
(-32600, json!(98))
);
assert_eq!(
code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)),
(-32600, json!("a"))
);
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)),
(-32600, Value::Null)
);
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","method":5}"#)),
(-32600, Value::Null)
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
Line::Forward
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
Line::Forward
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#),
Line::Forward
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#),
Line::Forward
);
assert_eq!(
code(classify(
r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"#
)),
(-32600, json!(7))
);
assert_eq!(classify(" "), Line::Skip);
}
/// Counts the lines written to it; every flush fails.
struct FailingFlush(std::sync::Arc<std::sync::atomic::AtomicUsize>);
impl AsyncWrite for FailingFlush {
fn poll_write(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
let lines = buf.iter().filter(|&&b| b == b'\n').count();
self.0.fetch_add(lines, std::sync::atomic::Ordering::SeqCst);
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Err(std::io::Error::other("closed")))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
/// A client that can't take a flushed reply is gone: the writer stops
/// after the first, rather than writing on into a dead channel.
#[tokio::test]
async fn a_failed_flush_stops_the_writer() {
let written = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let input = std::io::Cursor::new(b"not json\nnor this\n".to_vec());
let (rmcp_in, rmcp_out, writer) = spawn(input, FailingFlush(written.clone()));
drop((rmcp_in, rmcp_out));
writer.await.unwrap();
assert_eq!(written.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}
File diff suppressed because it is too large. Load diff
-287
View File
@@ -1,287 +0,0 @@
//! Concurrency over HTTP: several agents at once against one store.
//!
//! In `--http` mode every connection gets its own server over one shared,
//! locked store (main.rs `serve_http`). Oracle (server.rs INVARIANTS 2 and
//! 5, under contention): clients posting at the same time leave books
//! that verify, a trial balance that balances, every posted entry with
//! its receipt (threshold 0.00), and each draft posted exactly once even
//! when two clients race to post it; a restart replays the same books.
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, ClientConfig};
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::StreamableHttpClientTransport;
use serde_json::{Value, json};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
type Client = RunningService<RoleClient, ClientConfig>;
const CLIENTS: usize = 6;
const ENTRIES: usize = 12;
struct Server(Child);
impl Drop for Server {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for c in bytes.chunks(3) {
let n = (u32::from(c[0]) << 16)
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
| u32::from(*c.get(2).unwrap_or(&0));
for (i, shift) in [18, 12, 6, 0].iter().enumerate() {
if i <= c.len() {
out.push(T[((n >> shift) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
async fn connect(url: &str) -> Client {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let transport = StreamableHttpClientTransport::from_uri(url.to_string());
match ClientConfig::default().serve(transport).await {
Ok(c) => return c,
Err(e) if Instant::now() > deadline => panic!("connect: {e}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
/// Call a tool: Ok(structured result) or Err(the tool's error text).
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
let args = args.as_object().cloned().unwrap_or_default();
let r = c
.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args))
.await
.map_err(|e| format!("protocol: {e}"))?;
let text = r
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.unwrap_or_default();
if r.is_error == Some(true) {
Err(text)
} else {
Ok(r.structured_content
.unwrap_or_else(|| serde_json::from_str(&text).unwrap()))
}
}
async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
let e = call(
c,
"books.draft_entry",
json!({"date": "2026-03-01", "description": tag}),
)
.await
.unwrap();
let id = e["entry_id"].as_u64().unwrap();
let amount = format!("{}.{:02}", cents / 100, cents % 100);
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
call(
c,
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
)
.await
.unwrap();
}
if with_receipt {
call(
c,
"books.attach_receipt",
json!({"entry_id": id, "file_name": format!("{tag}.pdf"),
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
)
.await
.unwrap();
}
id
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn agents_posting_at_once_leave_whole_books() {
let dir = std::env::temp_dir().join(format!("ledger-mcp-http-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
// Declared before the server, so dropped after it: pass or panic.
let _cleanup = TestDir(dir.clone());
let toml = dir.join("wanderbooks.toml");
std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap();
let data = dir.join("books-data");
let port = free_port();
let addr = format!("127.0.0.1:{port}");
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
let server = Server(
Command::new(bin)
.arg("--data-dir")
.arg(&data)
.arg("--books-toml")
.arg(&toml)
.arg("--http")
.arg(&addr)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let url = format!("http://{addr}/mcp");
let setup = connect(&url).await;
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("6000", "Supplies", "expense"),
] {
call(
&setup,
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)
.await
.unwrap();
}
// Drafts every client will race to post.
let mut contested = Vec::new();
for k in 0..ENTRIES {
contested.push(draft(&setup, &format!("contested {k}"), 100 + k as u64, true).await);
}
let mut tasks = Vec::new();
for n in 0..CLIENTS {
let url = url.clone();
let contested = contested.clone();
tasks.push(tokio::spawn(async move {
let c = connect(&url).await;
let (mut posted, mut refused, mut won) = (0usize, 0usize, 0usize);
for (k, &race) in contested.iter().enumerate() {
// A receipted entry posts; a bare one is refused.
let receipted = k % 3 != 0;
let id = draft(
&c,
&format!("c{n} e{k}"),
(n * 1000 + k + 1) as u64,
receipted,
)
.await;
match call(
&c,
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
)
.await
{
Ok(_) => posted += 1,
Err(_) => refused += 1,
}
assert_eq!(posted + refused, k + 1);
// Race everyone for the contested draft.
if call(
&c,
"books.post_entry",
json!({"entry_id": race, "confirmed": true}),
)
.await
.is_ok()
{
won += 1;
}
}
let _ = c.cancel().await;
(posted, refused, won)
}));
}
let (mut posted, mut refused, mut won) = (0, 0, 0);
for t in tasks {
let (p, r, w) = t.await.unwrap();
posted += p;
refused += r;
won += w;
}
let per_client_bare = (0..ENTRIES).filter(|k| k % 3 == 0).count();
assert_eq!(
refused,
CLIENTS * per_client_bare,
"every bare entry refused"
);
assert_eq!(posted, CLIENTS * (ENTRIES - per_client_bare));
assert_eq!(won, ENTRIES, "each contested draft posted exactly once");
let v = call(&setup, "books.verify", json!({})).await.unwrap();
assert_eq!(v["status"], "ok", "{v}");
let tb = call(&setup, "books.trial_balance", json!({}))
.await
.unwrap();
assert_eq!(tb["balanced"], true, "{tb}");
// Every posted entry has its receipt (threshold 0.00).
for id in 1..=(ENTRIES + CLIENTS * ENTRIES) as u64 {
let e = call(&setup, "books.get_entry", json!({"entry_id": id}))
.await
.unwrap();
if e["status"] == "posted" {
assert!(
e["receipts"].as_array().is_some_and(|r| !r.is_empty()),
"entry {id} posted without a receipt: {e}"
);
}
}
let head = v["head"].clone();
let _ = setup.cancel().await;
drop(server);
// A restart replays the same books.
let port = free_port();
let addr = format!("127.0.0.1:{port}");
let _server = Server(
Command::new(bin)
.arg("--data-dir")
.arg(&data)
.arg("--books-toml")
.arg(&toml)
.arg("--http")
.arg(&addr)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
let c = connect(&format!("http://{addr}/mcp")).await;
let v2 = call(&c, "books.verify", json!({})).await.unwrap();
assert_eq!(v2["status"], "ok");
assert_eq!(
v2["head"], head,
"the restarted server sees the same journal"
);
let _ = c.cancel().await;
let _ = std::fs::remove_dir_all(&dir);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-350
View File
@@ -1,350 +0,0 @@
//! P3's seams through the tools: on the seam review's books (a year of a
//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), built
//! in-process with the real `Store`, closed, then served by the real
//! binary over stdio from the same folder and its settings file, every
//! figure a tool reports is the engine's for the same accounts and dates,
//! and the tools agree with each other: the cash flow statement, party
//! totals on both bases, the 1099 worksheet as of a day, the fixed
//! assets, the members' equity statement and the CPA package.
#[path = "../../ledger-package/tests/seams/books.rs"]
mod books;
use books::{books, d, dec};
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::mpsc::{Receiver, channel};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(180);
const START: &str = "2026-01-01";
struct Mcp {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
fn start(dir: &Path) -> Self {
let run = dir.join("run");
std::fs::create_dir_all(&run).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(dir.join("wanderbooks.toml"))
.env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut mcp = Mcp {
stdin,
rx,
child,
next_id: 1,
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "p3-seams", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
writeln!(
mcp.stdin,
"{}",
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.unwrap();
mcp
}
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});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
loop {
let line = self
.rx
.recv_timeout(TIMEOUT)
.unwrap_or_else(|_| panic!("no response to {method}"));
let v: Value = serde_json::from_str(&line).unwrap();
if v.get("id") == Some(&json!(id)) {
return v;
}
}
}
/// A tool's structured result; a refusal fails the test.
fn ok(&mut self, name: &str, args: Value) -> Value {
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let r = &resp["result"];
assert!(
resp.get("error").is_none() && r.get("isError") != Some(&json!(true)),
"{name} {args}: {resp}"
);
r["structuredContent"].clone()
}
}
fn m(v: &Value) -> rust_decimal::Decimal {
dec(v
.as_str()
.unwrap_or_else(|| panic!("a decimal string: {v}")))
}
/// What the engine says, worked out before the server opens the books.
struct Expected {
end: String,
cash_flow: ledger_core::CashFlowStatement,
members_equity: ledger_core::MembersEquityStatement,
register: ledger_core::AssetRegister,
vendor_1099: ledger_core::Vendor1099Report,
depreciation: rust_decimal::Decimal,
iif: String,
}
/// Every figure the tools report on the seam review's books is the
/// engine's for the same accounts and dates, on every date (B-33's "the
/// tool's for the same accounts and through"), and party totals for the
/// year are the engine's on both bases.
#[test]
fn the_tools_figures_are_the_engines_on_every_date() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let l = b.store.ledger();
let settings = ledger_package::Settings {
company: &b.company,
cash_accounts: &b.cash,
financing_accounts: &b.financing,
investing_accounts: &[],
};
let capitals = ledger_package::member_capitals(l, &b.company.members).unwrap();
let mut expected = Vec::new();
for end in ["2026-06-30", "2026-09-15", "2026-12-31"] {
let e = d(end);
expected.push(Expected {
end: end.to_string(),
cash_flow: ledger_package::cash_flow_for(
l,
&settings,
Some(START),
Some(end),
&[],
&[],
&[],
e,
)
.unwrap(),
members_equity: ledger_core::members_equity(l, d(START), e, &capitals).unwrap(),
register: ledger_core::fixed_asset_register(l, e).unwrap(),
vendor_1099: ledger_package::vendor_1099_report(l, &settings, 2026, None, Some(e))
.unwrap(),
depreciation: ledger_core::profit_and_loss_for_period(l, d(START), e, None)
.unwrap()
.expenses
.iter()
.filter(|x| x.code == "6500")
.map(|x| x.amount)
.sum(),
iif: ledger_package::export_iif(l, &b.cash, e).unwrap().text,
});
}
let money = ledger_core::parties::money_accounts(
l,
&[l.chart().find_by_code("1000").unwrap().id],
&b.financing
.iter()
.map(|c| l.chart().find_by_code(c).unwrap().id)
.collect::<Vec<_>>(),
);
let members = b.company.party_members(l);
let year = (d(START), d("2026-12-31"));
let parties: Vec<(String, ledger_core::parties::Activity)> = l
.parties()
.map(|p| {
(
p.name.clone(),
ledger_core::parties::activity_with(l, p.id, year, &money, &members),
)
})
.collect();
drop(b);
let mut mcp = Mcp::start(dir.path());
for x in &expected {
let end = x.end.as_str();
// The cash flow statement: every line, kind and reconciling item.
let cf = mcp.ok("books.cash_flow", json!({"start": START, "end": end}));
for (key, total) in [
("cash_from_operations", x.cash_flow.cash_from_operations),
("cash_from_investing", x.cash_flow.cash_from_investing),
("cash_from_financing", x.cash_flow.cash_from_financing),
("net_change_in_cash", x.cash_flow.net_change_in_cash),
] {
assert_eq!(m(&cf[key]), total, "{end} {key}");
}
for (key, section) in [
("operating", ledger_core::CashFlowSection::Operating),
("investing", ledger_core::CashFlowSection::Investing),
("financing", ledger_core::CashFlowSection::Financing),
] {
let lines: Vec<(String, rust_decimal::Decimal)> = cf[key]
.as_array()
.unwrap()
.iter()
.map(|l| (l["kind"].as_str().unwrap().to_string(), m(&l["amount"])))
.collect();
let engine: Vec<(String, rust_decimal::Decimal)> = x
.cash_flow
.lines(section)
.iter()
.map(|l| (l.kind.clone(), l.amount))
.collect();
assert_eq!(lines, engine, "{end} {key}");
}
let recon: Vec<(String, rust_decimal::Decimal)> = cf["operating_reconciliation"]["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| (l["label"].as_str().unwrap().to_string(), m(&l["amount"])))
.collect();
let engine: Vec<(String, rust_decimal::Decimal)> = x
.cash_flow
.operating_reconciliation
.lines
.iter()
.map(|l| (l.label.clone(), l.amount))
.collect();
assert_eq!(recon, engine, "{end}");
let depreciation: rust_decimal::Decimal = recon
.iter()
.filter(|(label, _)| label == "Depreciation")
.map(|(_, a)| *a)
.sum();
assert_eq!(
depreciation, x.depreciation,
"{end}: the P&L's depreciation"
);
// The P&L's depreciation.
let pnl = mcp.ok("books.profit_and_loss", json!({"start": START, "end": end}));
let dep: rust_decimal::Decimal = pnl["expense_accounts"]
.as_array()
.unwrap()
.iter()
.filter(|a| a["code"] == "6500")
.map(|a| m(&a["amount"]))
.sum();
assert_eq!(dep, x.depreciation, "{end}");
// The members' equity statement.
let me = mcp.ok(
"books.export",
json!({"report": "members_equity", "format": "json", "start": START, "end": end}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
for (col, engine) in me["members"]
.as_array()
.unwrap()
.iter()
.zip(&x.members_equity.members)
{
for (key, v) in [
("beginning", engine.beginning),
("contributions", engine.contributions),
("net_income", engine.net_income),
("distributions", engine.distributions),
("other", engine.other),
("ending", engine.ending),
] {
assert_eq!(m(&col[key]), v, "{end} {} {key}", engine.name);
}
}
// The fixed assets, as of the day: list_assets is the register.
let assets = mcp.ok("books.list_assets", json!({"as_of": end}));
for (a, row) in assets["assets"]
.as_array()
.unwrap()
.iter()
.zip(&x.register.rows)
{
assert_eq!(m(&a["accumulated_posted"]), row.accumulated_posted, "{end}");
assert_eq!(
m(&a["accumulated_scheduled_through"]),
row.accumulated_scheduled,
"{end}"
);
assert_eq!(m(&a["book_value"]), row.book_value, "{end}");
let disposed = row.disposed.is_some();
assert_eq!(a["held"], json!(!disposed), "{end}");
assert_eq!(
a["status"],
json!(if disposed { "disposed" } else { "active" }),
"{end}: a disposal after as_of doesn't show"
);
}
// The 1099 worksheet as of the day.
let w = mcp.ok("books.vendor_1099", json!({"year": 2026, "through": end}));
let vendors = w["vendors"].as_array().unwrap();
assert_eq!(vendors.len(), x.vendor_1099.vendors.len(), "{end}");
for (v, engine) in vendors.iter().zip(&x.vendor_1099.vendors) {
assert_eq!(v["vendor_id"], json!(engine.vendor_id));
for (key, amount) in [
("paid", engine.paid),
("excluded_card", engine.excluded_card),
("undetermined", engine.undetermined),
("not_reportable", engine.not_reportable),
] {
assert_eq!(m(&v[key]), amount, "{end} {} {key}", engine.name);
}
}
// The IIF as of the day.
let iif = mcp.ok(
"books.export",
json!({"report": "general_journal", "format": "iif", "as_of": end}),
);
assert_eq!(iif["content"].as_str().unwrap(), x.iif, "{end}");
}
// Party totals for the year, on both bases.
for (name, a) in &parties {
for (basis, key) in [
(ledger_core::parties::Basis::Cash, "cash"),
(ledger_core::parties::Basis::All, "all"),
] {
let p = mcp.ok(
"books.party",
json!({"party": name, "year": 2026, "basis": key}),
);
let (paid, received) = a.totals(basis);
assert_eq!(m(&p["totals"]["paid"]), paid, "{name} {key}");
assert_eq!(m(&p["totals"]["received"]), received, "{name} {key}");
}
}
}
-975
View File
@@ -1,975 +0,0 @@
//! The 1099 decision and payment methods through the server (plan P3,
//! defects 12, 18 and 23; D-40), driven over stdio against the real
//! binary: every success validates against the tool's outputSchema, and
//! every refusal leaves `journal.jsonl` byte-identical (the harness is
//! `tests/spec_parties.rs`'s). Every name is fictional; the reasons are
//! the ones the first company's cutover records.
use serde_json::{Value, json};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(180);
// ---------------------------------------------------------------- harness
#[derive(Debug, Clone)]
enum Out {
Ok(Value),
ToolErr(String),
RpcErr(Value),
}
impl Out {
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 {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The books dir when the server owns it: removed after the server
/// stops (fields drop after `Drop::drop`).
books_dir: Option<TestDir>,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&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 n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ledger-mcp-spec-1099-{}-{name}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
/// receipt threshold is high so ordinary test entries need no receipt.
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
),
)
.unwrap();
path
}
impl Mcp {
fn start(dir: &Path, toml: &Path) -> Self {
let run = dir.join("run");
std::fs::create_dir_all(&run).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(toml)
.env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut mcp = Self {
books_dir: None,
stdin,
rx,
child,
next_id: 1,
data: dir.join("books-data"),
validators: HashMap::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-1099", "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 validator = jsonschema::validator_for(&tool["outputSchema"])
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
mcp.validators.insert(name, validator);
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// The journal's line types, in order.
fn journal_types(&self) -> Vec<String> {
String::from_utf8(self.journal())
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let v: Value = serde_json::from_str(l).unwrap();
v["type"]
.as_str()
.or_else(|| v["line_type"].as_str())
.unwrap_or_default()
.to_string()
})
.collect()
}
fn send_line(&mut self, line: &str) {
writeln!(self.stdin, "{line}").unwrap();
self.stdin.flush().unwrap();
}
fn recv(&mut self) -> Option<Value> {
loop {
match self.rx.recv_timeout(TIMEOUT) {
Ok(line) => {
let v: Value = serde_json::from_str(&line)
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
if v.get("id").is_none() && v.get("method").is_some() {
continue;
}
return Some(v);
}
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
}
}
}
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()
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool; a refusal must leave the journal unchanged, a success
/// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
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)) {
Out::ToolErr(
result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string(),
)
} else {
let sc = result["structuredContent"].clone();
let v = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
assert!(
errors.is_empty(),
"{name} violates its outputSchema: {errors:?}\n{sc}"
);
Out::Ok(sc)
}
};
if out.refused() {
assert!(
self.journal() == before,
"refused {name} {args} changed the journal ({out:?})"
);
}
out
}
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
match self.tool(name, args) {
Out::Ok(v) => v,
other => panic!("{name} {a}: expected success, got {other:?}"),
}
}
/// `tool`, expecting a refusal that says why.
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:?}");
let why = out.err_text();
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
why
}
/// `ok`, and the journal must not change (read-only tools).
fn read_only(&mut self, name: &str, args: Value) -> Value {
let before = self.journal();
let v = self.ok(name, args);
assert!(
self.journal() == before,
"read-only {name} changed the journal"
);
v
}
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
for (code, name, ty) in accts {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
}
}
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
let mut args = json!({"date": date, "description": format!("entry {date}")});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
let id = self.ok("books.draft_entry", args)["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
}
/// `draft`, then post it (high receipt threshold: no receipt needed).
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
let id = self.draft(date, extra, lines);
self.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
id
}
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
.as_u64()
.unwrap()
}
fn party(&mut self, party: &str, year: i32) -> Value {
self.read_only("books.party", json!({"party": party, "year": year}))
}
/// How many journal lines have this type.
fn count(&self, line_type: &str) -> usize {
self.journal_types()
.iter()
.filter(|t| t.as_str() == line_type)
.count()
}
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"),
("2050", "Card", "liability"),
("2100", "Due to Members", "liability"),
("3000", "Capital", "equity"),
("4000", "Revenue", "revenue"),
("6000", "Contract Labor", "expense"),
("6999", "General Expenses", "expense"),
];
/// Books with the chart; cash is 1000, financing 2100.
fn books(name: &str) -> Mcp {
books_in(name, false)
}
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
/// P3's features, books an older version wrote, waiting for their format
/// upgrade (rules.md B-27).
fn books_in(name: &str, p2: bool) -> Mcp {
let dir = test_dir(name);
if p2 {
ledger_store::Store::create_in_format(
&dir.join("books-data"),
ledger_core::Currency::USD,
&ledger_store::JournalFormat {
format: 2,
features: Vec::new(),
},
)
.unwrap();
}
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
impl Mcp {
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
/// after the passphrase and a verified backup; no tool can): the
/// server stopped, the books moved to this version's format, and
/// served again.
fn upgraded(mut self) -> Mcp {
let dir = self.books_dir.take().expect("books_dir");
drop(self);
ledger_store::Store::open(&dir.join("books-data"))
.unwrap()
.upgrade_format()
.unwrap()
.expect("P2's books wait for the upgrade");
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
m.books_dir = Some(dir);
m
}
/// The same books, served again (a restart).
fn restart(mut self) -> Mcp {
let dir = self.books_dir.take().expect("books_dir");
drop(self);
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
m.books_dir = Some(dir);
m
}
}
/// A vendor's decision as books.list_vendors shows it today.
fn decision(m: &mut Mcp, vendor: &str) -> Value {
let all = m.read_only("books.list_vendors", json!({}));
all["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == vendor)
.unwrap_or_else(|| panic!("{vendor} not listed: {all}"))
.clone()
}
/// A vendor's line on the 2026 worksheet.
fn line_2026(m: &mut Mcp, vendor: &str) -> Value {
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
w["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == vendor)
.unwrap_or_else(|| panic!("{vendor} not on the worksheet: {w}"))
.clone()
}
/// Defect 23, from its reproduction (P3 brief 1.1): "none" stored the same
/// null as a vendor nobody decided on. Now "none" is refused with what to
/// say instead, not_reportable is kept with its reason and who decided
/// when, a fresh vendor reads undetermined with no one named, and both
/// survive a restart.
#[test]
fn a_vendor_marked_no_1099_is_not_undecided() {
let mut m = books("decided");
m.ok("books.add_vendor", json!({"name": "Bigco"}));
m.ok("books.add_vendor", json!({"name": "Newco"}));
for none in ["none", "", "None", "no_1099"] {
let why = m.refuse(
"books.set_vendor_1099",
json!({"vendor": "Bigco", "form_1099": none, "decided_by": "Riley Chen"}),
);
assert!(
why.contains("not_reportable") && why.contains("undetermined"),
"{why}"
);
assert!(why.contains("Paid by card is not a reason"), "{why}");
}
let v = m.ok(
"books.set_vendor_1099",
json!({"vendor": "Bigco", "form_1099": "not_reportable", "reason_kind": "corporation",
"reason": "a corporation by name", "decided_by": "Riley Chen",
"decided_on": "2026-10-16", "citation": "Treas. Reg. §1.6041-3(p)(1)"}),
);
assert_eq!(v["changed"], "set");
assert!(v["warnings"].as_array().unwrap().is_empty());
let check = |m: &mut Mcp| {
let b = decision(m, "Bigco");
assert!(b["form_1099"].is_null(), "{b}");
let d = &b["decision_1099"];
assert_eq!(d["value"], "not_reportable");
assert_eq!(d["reason_kind"], "corporation");
assert_eq!(d["reason"], "a corporation by name");
assert_eq!(d["authority"], "Treas. Reg. §1.6041-3(p)(1)");
assert_eq!(d["decided_by"], "Riley Chen");
assert_eq!(d["decided_on"], "2026-10-16");
assert_eq!(d["citation"], "Treas. Reg. §1.6041-3(p)(1)");
assert!(d["effective"].is_null(), "from the beginning");
assert_eq!(b["decisions_1099"].as_array().unwrap().len(), 1);
let n = decision(m, "Newco");
assert_eq!(n["decision_1099"]["value"], "undetermined");
assert!(n["decision_1099"]["decided_by"].is_null(), "nobody decided");
assert!(n["decisions_1099"].as_array().unwrap().is_empty());
};
check(&mut m);
assert_eq!(m.count("vendor.1099_set"), 1);
let mut m = m.restart();
check(&mut m);
}
/// D-40: the first company's cutover records exactly these decisions (a
/// reason kind and its words), and each comes back as given.
#[test]
fn the_cutovers_reasons_are_recorded_as_given() {
let mut m = books("cutover");
for (vendor, kind, reason, citation) in [
(
"Northwind Research, PBC",
"corporation",
"a corporation (Delaware public benefit corporation, from the public record)",
None,
),
(
"Tidewater Hosting, Inc.",
"corporation",
"a corporation by name",
Some("Treas. Reg. §1.6041-3(p)(1)"),
),
(
"Federal Parcel Service",
"united_states",
"the United States",
Some("Treas. Reg. §1.6041-3(p)(3)"),
),
] {
m.ok("books.add_vendor", json!({"name": vendor}));
let mut args = json!({"vendor": vendor, "form_1099": "not_reportable", "reason_kind": kind,
"reason": reason, "decided_by": "Riley Chen", "decided_on": "2026-10-16"});
if let Some(c) = citation {
args["citation"] = json!(c);
}
m.ok("books.set_vendor_1099", args);
let d = decision(&mut m, vendor)["decision_1099"].clone();
assert_eq!(
(
d["value"].as_str(),
d["reason_kind"].as_str(),
d["reason"].as_str()
),
(Some("not_reportable"), Some(kind), Some(reason))
);
assert_eq!(d["citation"].as_str(), citation);
}
}
/// INVARIANTS 1-3 of tools/instruments.rs: what a decision needs, each
/// refusal saying why and changing nothing.
#[test]
fn set_vendor_1099_says_what_a_decision_needs() {
let mut m = books("refusals");
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let v = "Harbor Design LLC";
let by = "Riley Chen";
for (args, needle) in [
(
json!({"vendor": v, "form_1099": "not_reportable", "reason": "x", "decided_by": by}),
"reason_kind",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "decided_by": by}),
"needs reason",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "paid_by_card", "reason": "x", "decided_by": by}),
"paid by card is not a reason",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "other", "reason": "x", "decided_by": by}),
"citation",
),
(
json!({"vendor": v, "form_1099": "nec", "reason": "x", "decided_by": by}),
"go with not_reportable",
),
(json!({"vendor": v, "form_1099": "nec"}), "decided_by"),
(
json!({"vendor": v, "form_1099": "nec", "effective": "2026-07-01", "decided_by": by}),
"from the beginning",
),
(
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation", "reason": "EIN 12-3456789", "decided_by": by}),
"EIN",
),
(json!({"vendor": v}), "nothing to change"),
(
json!({"vendor": v, "reason": "x", "tin_on_file": true}),
"go with form_1099",
),
(
json!({"vendor": v, "form_1099": "1099-k", "decided_by": by}),
"expected nec",
),
(
json!({"vendor": "Nobody", "form_1099": "nec", "decided_by": by}),
"vendor",
),
] {
let why = m.refuse("books.set_vendor_1099", args.clone());
assert!(why.contains(needle), "{args}: {why}");
}
assert_eq!(m.count("vendor.1099_set"), 0);
assert_eq!(
m.count("journal.format_set"),
1,
"nothing declared either: only the books' own format, from their creation"
);
}
/// P3 brief 1.5 and review R8: tin_on_file omitted keeps the W-9 flag;
/// tin_on_file alone keeps the decision and who made it.
#[test]
fn set_vendor_1099_keeps_the_w9_flag_when_omitted() {
let mut m = books("w9-flag");
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let v = "Harbor Design LLC";
m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "nec", "tin_on_file": true, "decided_by": "Riley Chen", "decided_on": "2026-03-02"}),
);
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "misc", "decided_by": "Riley Chen", "decided_on": "2026-04-01"}),
);
assert_eq!(out["tin_on_file"], true, "omitted: kept");
assert_eq!(out["form_1099"], "1099-MISC");
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "tin_on_file": false}),
);
assert_eq!(out["changed"], "unchanged");
assert_eq!(out["tin_on_file"], false);
assert_eq!(out["decision_1099"]["value"], "misc");
assert_eq!(out["decision_1099"]["decided_on"], "2026-04-01");
assert_eq!(m.count("vendor.1099_set"), 2);
assert_eq!(m.count("vendor.w9_flag_set"), 1);
// Already so: nothing journaled.
m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "tin_on_file": false}),
);
assert_eq!(m.count("vendor.w9_flag_set"), 1);
}
/// P3 brief 4.4 and review R8: recording a W-9 (books.set_w9) marks the
/// vendor's W-9 on file and keeps its 1099 decision with who made it and
/// when, verbatim.
#[test]
fn set_w9_keeps_the_1099_decision_and_its_source() {
let mut m = books("set-w9");
m.add_party("Harbor Design LLC", &["contractor"]);
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Harbor Design LLC", "form_1099": "nec", "decided_by": "Riley Chen",
"decided_on": "2026-01-05", "citation": "W-9 requested"}),
);
let before = decision(&mut m, "Harbor Design LLC")["decision_1099"].clone();
m.ok(
"books.set_w9",
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
);
let after = decision(&mut m, "Harbor Design LLC");
assert_eq!(after["tin_on_file"], true);
assert_eq!(after["decision_1099"], before);
assert_eq!(m.count("vendor.1099_set"), 1, "no decision re-journaled");
assert_eq!(m.count("vendor.w9_flag_set"), 1);
}
/// A member's method on an account the books don't name as financing
/// warns: the worksheet holds its untagged payments undetermined (never a
/// card's), and the settings should say what the account is.
#[test]
fn a_members_method_off_the_financing_accounts_warns() {
let mut m = books("member-method");
m.ok(
"books.add_account",
json!({"code": "2110", "name": "Due to Riley", "account_type": "liability"}),
);
let out = m.ok(
"books.add_instrument",
json!({"kind": "member_card", "label": "Riley's Visa", "account": "2110"}),
);
let w = out["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{out}");
assert!(
w[0].as_str()
.unwrap()
.contains("not one of the books' financing accounts"),
"{out}"
);
let out = m.ok(
"books.add_instrument",
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
);
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
}
/// B-27 and the format-upgrade gate (D-39): books.set_w9 journals the W-9
/// and then the vendor's W-9 flag, a line of the vendor_1099_decisions
/// feature, so it asks for the feature before the command's first line,
/// never between its lines. In books waiting for their format upgrade
/// (P2's) the whole command is refused, journaling nothing (never the W-9
/// recorded and the flag not); once the owner upgrades them, as in books
/// this version made, it journals the W-9, then the flag, and declares
/// nothing.
#[test]
fn set_w9_asks_for_the_w9_flags_feature_before_its_first_line() {
let w9 = json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"});
let mut m = books_in("set-w9-feature", true);
m.add_party("Harbor Design LLC", &["contractor"]);
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let why = m.refuse("books.set_w9", w9.clone());
assert!(
why.starts_with("format_upgrade_pending:") && why.contains("vendor_1099_decisions"),
"{why}"
);
assert_eq!(m.count("party.w9_set"), 0, "nothing half done");
let mut fresh = books("set-w9-feature-new");
fresh.add_party("Harbor Design LLC", &["contractor"]);
fresh.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
for mut m in [m.upgraded(), fresh] {
let before = m.journal_types().len();
m.ok("books.set_w9", w9.clone());
let added = m.journal_types()[before..].to_vec();
assert_eq!(added, ["party.w9_set", "vendor.w9_flag_set"]);
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
}
}
/// Defect 12 end to end, restarting between steps (P3 brief 8.2): payment
/// methods added (and refused when unsound), a draft taking one, a posted
/// entry and its reversal tagged together, and the worksheet showing what
/// counts, what a card paid, and what is undetermined.
#[test]
fn payment_methods_round_trip_and_classify_payments() {
let mut m = books("methods");
m.add_party("Riley Chen", &["member"]);
let added = m.ok(
"books.add_instrument",
json!({"kind": "bank", "label": "Harbor Bank checking", "last4": "1234", "account": "1000"}),
);
assert_eq!(added["card_or_network"], false);
assert!(added["warnings"].as_array().unwrap().is_empty());
m.ok(
"books.add_instrument",
json!({"kind": "business_card", "label": "Harbor Visa", "last4": "0042", "account": "2050"}),
);
let visa = m.ok(
"books.add_instrument",
json!({"kind": "member_card", "label": "Riley's Visa", "owner_party": "Riley Chen", "account": "2100"}),
);
assert_eq!(visa["card_or_network"], true);
assert_eq!(visa["account"], "2100");
m.ok(
"books.add_instrument",
json!({"kind": "member_cash_or_check", "label": "Riley's checks", "account": "2100"}),
);
for (args, needle) in [
(
json!({"kind": "card", "label": "x", "account": "2050"}),
"kind",
),
(
json!({"kind": "bank", "label": "Savings", "last4": "12345", "account": "1000"}),
"four digits",
),
(
json!({"kind": "bank", "label": "harbor visa", "account": "1000"}),
"already named",
),
(
json!({"kind": "bank", "label": "Capital", "account": "3000"}),
"asset",
),
(
json!({"kind": "bank", "label": "Nowhere", "account": "9999"}),
"unknown account",
),
] {
let why = m.refuse("books.add_instrument", args.clone());
assert!(why.contains(needle), "{args}: {why}");
}
m.ok("books.add_vendor", json!({"name": "Dana Studio"}));
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Dana Studio", "form_1099": "nec", "decided_by": "Riley Chen"}),
);
let dana = json!({"vendor": "Dana Studio", "instrument": "Riley's Visa"});
let e1 = m.post(
"2026-05-01",
dana,
&[("6000", "debit", "2500.00"), ("2100", "credit", "2500.00")],
);
assert_eq!(
m.read_only("books.get_entry", json!({"entry_id": e1}))["instrument"],
"Riley's Visa"
);
let e2 = m.post(
"2026-05-02",
json!({"vendor": "Dana Studio"}),
&[("6000", "debit", "3000.00"), ("2100", "credit", "3000.00")],
);
let e3 = m.post(
"2026-05-03",
json!({"vendor": "Dana Studio"}),
&[("6000", "debit", "400.00"), ("2050", "credit", "400.00")],
);
let mut m = m.restart();
let d = line_2026(&mut m, "Dana Studio");
assert_eq!(
(
&d["paid"],
&d["excluded_card"],
&d["undetermined"],
&d["status"]
),
(
&json!("0.00"),
&json!("2900.00"),
&json!("3000.00"),
&json!("undetermined")
)
);
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
let listed = |k: &str| -> Vec<(u64, String, Value)> {
w[k].as_array()
.unwrap()
.iter()
.map(|p| {
(
p["entry_id"].as_u64().unwrap(),
p["amount"].as_str().unwrap().to_string(),
p["instrument"].clone(),
)
})
.collect()
};
assert_eq!(
listed("excluded_card_payments"),
[
(e1, "2500.00".into(), json!("Riley's Visa")),
(e3, "400.00".into(), Value::Null)
]
);
assert_eq!(
listed("undetermined_payments"),
[(e2, "3000.00".into(), Value::Null)]
);
assert!(
w["notes"]
.as_array()
.unwrap()
.iter()
.any(|n| n.as_str().unwrap().contains("books.set_entry_instrument")),
"{w}"
);
// The member paid e2 by check, and it was reversed: tagging either
// tags both.
let rev = m.ok(
"books.reverse_entry",
json!({"entry_id": e2, "date": "2026-05-04", "description": "paid twice", "confirmed": true}),
)["reversal_id"]
.as_u64()
.unwrap();
let out = m.ok(
"books.set_entry_instrument",
json!({"entry_id": e2, "instrument": "Riley's checks"}),
);
assert_eq!(out["changed"], true);
assert_eq!(out["instrument"], "Riley's checks");
assert!(out["warnings"].as_array().unwrap().is_empty());
let mut m = m.restart();
assert_eq!(
m.read_only("books.get_entry", json!({"entry_id": rev}))["instrument"],
"Riley's checks"
);
let d = line_2026(&mut m, "Dana Studio");
assert_eq!(
(&d["paid"], &d["undetermined"], &d["status"]),
(&json!("0.00"), &json!("0.00"), &json!("under_threshold"))
);
let all = m.read_only("books.instruments", json!({}));
let labels: Vec<(&str, bool)> = all["instruments"]
.as_array()
.unwrap()
.iter()
.map(|i| {
(
i["label"].as_str().unwrap(),
i["card_or_network"].as_bool().unwrap(),
)
})
.collect();
assert_eq!(
labels,
[
("Harbor Bank checking", false),
("Harbor Visa", true),
("Riley's Visa", true),
("Riley's checks", false)
]
);
// Cleared again: back to undetermined, both of the chain.
m.ok("books.set_entry_instrument", json!({"entry_id": rev}));
assert!(m.read_only("books.get_entry", json!({"entry_id": e2}))["instrument"].is_null());
let p = m.party("Dana Studio", 2026);
assert_eq!(p["totals"]["decision_1099"], "nec");
assert_eq!(p["totals"]["form_1099"], "1099-NEC");
}
/// D-40 and P3 review R15: a decision changed for payments in a closed
/// period, or in a year whose 1099s were due (January 31 of the next
/// year), warns, naming the year and the entries; one reaching only this
/// year's open payments doesn't.
#[test]
fn a_change_reaching_a_filed_year_warns() {
let mut m = books("warns");
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
let v = "Harbor Design LLC";
m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "nec", "decided_by": "Riley Chen"}),
);
let harbor = json!({"vendor": v});
let pay = |m: &mut Mcp, date: &str| {
m.post(
date,
harbor.clone(),
&[("6000", "debit", "700.00"), ("1000", "credit", "700.00")],
)
};
let e2024 = pay(&mut m, "2024-03-01");
let e2025 = pay(&mut m, "2025-03-01");
pay(&mut m, "2026-03-01");
let period = m.ok(
"books.create_period",
json!({"name": "FY2025", "start": "2025-01-01", "end": "2025-12-31"}),
)["period_id"]
.as_u64()
.unwrap();
m.ok(
"books.close_period",
json!({"period_id": period, "confirmed": true}),
);
// From July 2026 on: only an open, not-yet-due year's payments: quiet.
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
"reason": "elected to be taxed as a corporation", "effective": "2026-07-01",
"decided_by": "Riley Chen"}),
);
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
assert_eq!(out["decisions_1099"].as_array().unwrap().len(), 2);
// From the beginning: reaches 2024 (1099s due 2025-01-31) and the
// closed 2025.
let out = m.ok(
"books.set_vendor_1099",
json!({"vendor": v, "form_1099": "not_reportable", "reason_kind": "corporation",
"reason": "a corporation by name", "decided_by": "Riley Chen"}),
);
let warnings: Vec<String> = out["warnings"]
.as_array()
.unwrap()
.iter()
.map(|w| w.as_str().unwrap().to_string())
.collect();
assert_eq!(warnings.len(), 2, "{warnings:?}");
assert!(warnings[0].contains("for 2024") && warnings[0].contains(&format!("entries {e2024}")));
assert!(warnings[0].contains("due January 31"), "{}", warnings[0]);
assert!(warnings[1].contains("for 2025") && warnings[1].contains("closed period"));
assert!(warnings.iter().all(|w| w.contains("corrected")));
// A payment method on the closed year's payment warns too.
m.ok(
"books.add_instrument",
json!({"kind": "business_card", "label": "Harbor debit card", "account": "1000"}),
);
let out = m.ok(
"books.set_entry_instrument",
json!({"entry_id": e2025, "instrument": "Harbor debit card"}),
);
let w = out["warnings"].as_array().unwrap();
assert_eq!(w.len(), 1, "{out}");
assert!(w[0].as_str().unwrap().contains("closed period"));
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-701
View File
@@ -1,701 +0,0 @@
//! P1 through the real server: settings moving into the books, the books'
//! own settings, conventions, approvals, books.start (plan/served.md P1).
//! Drives the real `ledger-mcp` binary over stdio with raw JSON-RPC.
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
struct Mcp {
stdin: ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
child: Child,
}
/// Closing stdin ends the server; wait for it to exit, so the next server
/// on the same books never races it for the writer lock.
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
fn spawn(data_dir: &std::path::Path) -> Self {
Self::spawn_with(data_dir, &[])
}
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
Self::spawn_env(data_dir, extra, &[])
}
fn spawn_env(
data_dir: &std::path::Path,
extra: &[&std::ffi::OsStr],
env: &[(&str, &std::path::Path)],
) -> Self {
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
let mut child = Command::new(bin)
.arg("--data-dir")
.arg(data_dir)
.args(extra)
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self {
stdin,
stdout,
next_id: 1,
child,
}
}
fn request(&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});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read response line");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["id"], id, "id mismatch in {resp}");
assert!(
resp.get("error").is_none(),
"JSON-RPC error for {method}: {}",
resp["error"]
);
resp
}
fn notify(&mut self, method: &str) {
let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
}
/// Call a tool; panic on tool error. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"tool {tool} returned an error: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
let text = result["content"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
})
}
/// Call a tool expecting an error; returns the error text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
let result = &resp["result"];
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"expected {tool} to fail, got {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&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!("ledger-mcp-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn init(m: &mut Mcp) -> String {
let r = m.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-kept", "version": "0"}}),
);
m.notify("notifications/initialized");
r["result"]["instructions"].as_str().unwrap().to_string()
}
const TOML: &str = r#"[company]
name = "Kept Co LLC"
tax_classification = "partnership" # Form 1065; a Schedule K-1 per member
fiscal_year_start = "01-01"
ein = "28-8525893"
[[company.members]]
name = "A"
share = "50%"
capital_account = "3000"
[[company.members]]
name = "B"
share = "50%"
capital_account = "3001"
[books]
data_dir = "books-data"
receipt_threshold = "0.00"
cash_accounts = ["1000"]
parties = "require"
[viewer]
open_at_start = "ask"
"#;
fn chart(m: &mut Mcp) {
for (code, name, t) in [
("1000", "Bank", "asset"),
("3000", "Capital A", "equity"),
("3001", "Capital B", "equity"),
("6000", "Software", "expense"),
] {
m.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": t}),
);
}
}
/// A settings file moves into the books, once, with the human's go: set
/// is refused before (it would drop the rest of the file); the preview
/// changes nothing; after, the file is never read, even to start, and the
/// company's EIN only ever leaves the server masked.
#[test]
fn a_settings_file_moves_into_the_books() {
let dir = test_dir("kept-move");
let data = dir.join("books-data");
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
let mut m = Mcp::spawn(&data);
let told = init(&mut m);
assert!(told.contains("books.start"), "{told}");
assert!(
told.contains("the settings file books.settings names"),
"{told}"
);
chart(&mut m);
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
let start = m.call("books.start", json!({}));
assert_eq!(start["settings_source"], "file");
assert!(
start["next_steps"]
.to_string()
.contains("books.import_settings")
);
let refused = m.call_err(
"books.set_setting",
json!({"changes": [{"key": "company.cpa", "value": "Casey"}], "decided_by": "A"}),
);
assert!(refused.contains("books.import_settings"), "{refused}");
// Nor is there an export yet: the file is the copy.
let refused = m.call_err("books.export_settings", json!({}));
assert!(
refused.contains("still read their settings file")
&& refused.contains("books.import_settings"),
"{refused}"
);
// The preview records nothing.
let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone();
let before = head(&mut m);
let preview = m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "note": "Moving in."}),
);
assert_eq!(preview["status"], "preview");
assert_eq!(head(&mut m), before);
assert!(
preview["notes"].to_string().contains("data_dir"),
"{preview}"
);
assert!(
preview["unknown"].as_array().unwrap().is_empty(),
"{preview}"
);
// The file's comments are listed, never dropped unseen.
assert_eq!(
preview["comments"],
json!(["Form 1065; a Schedule K-1 per member"]),
"{preview}"
);
let shown = preview.to_string();
assert!(!shown.contains("2215893"), "the EIN is masked: {shown}");
assert!(shown.contains("**-***5893"), "{shown}");
let done = m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
assert_eq!(done["status"], "imported");
let settings = m.call("books.settings", json!({}));
assert_eq!(settings["source"], "books");
assert_eq!(settings["books_toml"], Value::Null);
assert_eq!(settings["parties"], "require");
let ein = settings["keys"]
.as_array()
.unwrap()
.iter()
.find(|k| k["key"] == "company.ein")
.unwrap()
.clone();
assert_eq!(ein["value"], "**-***5893");
assert_eq!(ein["set_by"]["decided_by"], "A");
// The file is no longer read: an edit changes nothing.
std::fs::write(dir.join("wanderbooks.toml"), TOML.replace("0.00", "99.00")).unwrap();
let rule = m.call("books.settings", json!({}))["receipt_rule"].clone();
assert!(rule.as_str().unwrap().contains("every entry"), "{rule}");
// Settings change through the tools now.
let set = m.call(
"books.set_setting",
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"},
{"key": "company.ein", "value": "12-3456789"}],
"decided_by": "B", "decided_on": "2026-10-17"}),
);
assert!(!set.to_string().contains("3456789"), "{set}");
let export = m.call("books.export_settings", json!({}));
let text = export["toml_text"].as_str().unwrap();
assert!(text.contains("timezone = \"America/Denver\""), "{text}");
assert!(!text.contains("3456789"), "{text}");
let start = m.call("books.start", json!({}));
assert_eq!(start["settings_source"], "books");
assert_eq!(start["book"]["timezone"], "America/Denver");
assert!(!start.to_string().contains("3456789"));
drop(m);
// A broken settings file beside books that keep their own can't stop
// the server: it is never read.
std::fs::write(dir.join("wanderbooks.toml"), "[books\nthis is not toml").unwrap();
let mut m = Mcp::spawn(&data);
let told = init(&mut m);
assert!(
told.contains("Settings and conventions live in the books"),
"{told}"
);
assert!(!told.contains("local file you may edit"), "{told}");
assert!(told.contains("Kept Co LLC"), "{told}");
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
assert_eq!(m.call("books.verify", json!({}))["status"], "ok");
}
/// INVARIANT 2 (tools/kept.rs): an import that would read any setting
/// differently than the server does now is refused. Here the server reads
/// its receipt policy from config.json, which the import must carry.
#[test]
fn an_import_that_reads_differently_is_refused() {
let dir = test_dir("kept-differs");
let data = dir.join("books-data");
std::fs::create_dir_all(&data).unwrap();
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
let policy = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
std::fs::write(data.join("config.json"), policy).unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
let e = m.call_err(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("policy.evidence.threshold"), "{e}");
assert!(e.contains("config_json_text"), "{e}");
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true,
"config_json_text": policy}),
);
let s = m.call("books.settings", json!({}));
assert_eq!(s["source"], "books");
assert_eq!(s["receipt_exempt_accounts"], json!(["6999"]));
}
/// The vault key a settings file names must come from the command line
/// before the settings move in, or the vault would stop opening.
#[test]
fn an_import_needs_the_vault_key_on_the_command_line() {
let dir = test_dir("kept-vault");
let data = dir.join("books-data");
let key = dir.join("elsewhere").join("vault.key");
let toml = format!("{TOML}\n[vault]\nkey_file = \"{}\"\n", key.display());
std::fs::write(dir.join("wanderbooks.toml"), &toml).unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
let e = m.call_err(
"books.import_settings",
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("--vault-key-file"), "{e}");
drop(m);
let mut m = Mcp::spawn_with(&data, &["--vault-key-file".as_ref(), key.as_os_str()]);
init(&mut m);
m.call(
"books.import_settings",
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
);
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
}
/// Approval rules through the server: enforced, a covered post is refused
/// until its approver approves, then posts; off, they block nothing.
#[test]
fn approval_rules_through_the_server() {
let dir = test_dir("kept-approvals");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_setting",
json!({"changes": [{"key": "policy.evidence.threshold", "value": "1000000"},
{"key": "policy.approval_rules", "value": "enforce"}],
"decided_by": "A"}),
);
m.call(
"books.set_convention",
json!({"id": "big-software", "kind": "approval_rule", "title": "Big software",
"body_md": "Software over 500 needs B.",
"structured": {"approver": "B", "accounts": ["6000"], "amount_over": "500"},
"decided_by": "A"}),
);
let e = m.call(
"books.draft_entry",
json!({"date": "2026-10-01", "description": "Laptop", "no_party_reason": "test"}),
)["entry_id"]
.clone();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
m.call(
"books.add_line",
json!({"entry_id": e, "account": account, "side": side, "amount": "900.00"}),
);
}
let preview = m.call("books.post_entry", json!({"entry_id": e}));
assert_eq!(
preview["approvals_missing"],
json!([{"rule": "big-software", "approver": "B"}])
);
let refused = m.call_err(
"books.post_entry",
json!({"entry_id": e, "confirmed": true}),
);
assert!(refused.contains("approval_required"), "{refused}");
assert!(refused.contains("books.approve_entry"), "{refused}");
m.call(
"books.approve_entry",
json!({"entry_id": e, "approver": "B"}),
);
let preview = m.call("books.post_entry", json!({"entry_id": e}));
assert!(preview.get("approvals_missing").is_none(), "{preview}");
m.call(
"books.post_entry",
json!({"entry_id": e, "confirmed": true}),
);
m.call(
"books.set_setting",
json!({"changes": [{"key": "policy.approval_rules", "value": null}], "decided_by": "A"}),
);
assert_eq!(
m.call("books.start", json!({}))["rules"]["approval_rules"],
"off"
);
}
/// A posting template drafts through the server, to the cent.
#[test]
fn templates_draft_through_the_server() {
let dir = test_dir("kept-template");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_convention",
json!({"id": "contribution", "kind": "posting_template",
"title": "Joint contribution", "body_md": "Half each.",
"structured": {"description": "Contribution", "lines": [
{"account": "1000", "side": "debit", "share": "1"},
{"account": "3000", "side": "credit", "share": "0.5"},
{"account": "3001", "side": "credit", "share": "0.5"}]},
"decided_by": "A"}),
);
let d = m.call(
"books.draft_from_template",
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.03",
"no_party_reason": "between the members"}),
);
assert_eq!(d["description"], "Contribution");
let amounts: Vec<&str> = d["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["amount"].as_str().unwrap())
.collect();
assert_eq!(amounts, ["0.03", "0.02", "0.01"]);
let e = m.call_err(
"books.draft_from_template",
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.01"}),
);
assert!(e.contains("too small"), "{e}");
// A slot takes the account each draft names.
m.call(
"books.set_convention",
json!({"id": "bank-expense", "kind": "posting_template",
"title": "Expense paid from the bank", "body_md": "Dr the expense / Cr 1000.",
"structured": {"lines": [
{"account": "{expense}", "side": "debit", "share": "1"},
{"account": "1000", "side": "credit", "share": "1"}]},
"decided_by": "A"}),
);
let e = m.call_err(
"books.draft_from_template",
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00"}),
);
assert!(e.contains("expense"), "{e}");
let d = m.call(
"books.draft_from_template",
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00",
"accounts": {"expense": "6000"}, "no_party_reason": "test"}),
);
assert_eq!(d["lines"][0]["account"], "6000");
assert_eq!(d["lines"][1]["account"], "1000");
}
/// books.start stays at most 8 KB whatever the books hold, and says what
/// it left out.
#[test]
fn start_fits_in_8_kb() {
let dir = test_dir("kept-start");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
let body =
"A first line long enough to fill its summary to the brim, and then some more words. "
.repeat(3);
for i in 0..60 {
m.call(
"books.set_convention",
json!({"id": format!("note-{i}"), "kind": "note",
"title": format!("A rather long convention title number {i}"),
"body_md": body, "decided_by": "A"}),
);
}
for i in 0..40 {
m.call(
"books.raise_item",
json!({"title": format!("Open item number {i}, with a title of some length"),
"due": "2026-12-31", "decided_by": "A"}),
);
}
let resp = m.request(
"tools/call",
json!({"name": "books.start", "arguments": {}}),
);
let start = &resp["result"]["structuredContent"];
let bytes = serde_json::to_string(start).unwrap().len();
assert!(bytes <= 8 * 1024, "{bytes} bytes");
let cut = start["truncated"].to_string();
assert!(cut.contains("books.conventions"), "{cut}");
assert!(start["conventions"].as_array().unwrap().len() >= 10);
// isolation_label_is_advisory (plan P2, D-32).
assert_eq!(start["isolation"], "same_user (advisory)");
}
/// Review of P1 (2026-10-17): the text imported is the file the server
/// reads, whole; books that keep their own settings take no import that
/// changes them; a settings file never turns approvals on; after the
/// move, a broken config.json can't stop the server; and books.settings
/// shows a file's values before the move.
#[test]
fn an_import_is_the_file_the_server_reads() {
let dir = test_dir("kept-whole");
let data = dir.join("books-data");
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
let keys = m.call("books.settings", json!({}))["keys"].clone();
let ein = keys
.as_array()
.unwrap()
.iter()
.find(|k| k["key"] == "company.ein")
.unwrap()
.clone();
assert_eq!(ein["value"], "**-***5893", "the file's value, masked");
// A text that leaves the EIN out is not the file.
let short = TOML.replace("ein = \"28-8525893\"\n", "");
let e = m.call_err(
"books.import_settings",
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("company.ein"), "{e}");
// Approval rules never come in from a file.
std::fs::write(
dir.join("wanderbooks.toml"),
format!("{TOML}approval_rules = \"enforce\"\n")
.replace("[viewer]\nopen_at_start = \"ask\"\n", ""),
)
.unwrap();
let with_rules = std::fs::read_to_string(dir.join("wanderbooks.toml")).unwrap();
let e = m.call_err(
"books.import_settings",
json!({"toml_text": with_rules, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("approval"), "{e}");
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
// Imported books: the same text again is no change; another is refused.
let again = m.call(
"books.import_settings",
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
);
assert!(again["changes"].as_array().unwrap().is_empty(), "{again}");
let e = m.call_err(
"books.import_settings",
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
);
assert!(e.contains("books.set_setting"), "{e}");
drop(m);
std::fs::write(data.join("config.json"), "{ not json").unwrap();
let mut m = Mcp::spawn(&data);
init(&mut m);
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
}
/// A convention that names an account is listed when that account is
/// renumbered, and as a problem while it names a code no longer in the
/// chart.
#[test]
fn renumbering_names_the_conventions_to_revise() {
let dir = test_dir("kept-renumber");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_convention",
json!({"id": "software", "kind": "posting_template", "title": "Software",
"body_md": "Dr 6000 / Cr 1000.",
"structured": {"lines": [
{"account": "6000", "side": "debit", "share": "1"},
{"account": "1000", "side": "credit", "share": "1"}]},
"decided_by": "A"}),
);
let out = m.call(
"books.renumber_account",
json!({"account": "6000", "code": "6010"}),
);
assert!(
out["config_mentions"]
.to_string()
.contains("convention software"),
"{out}"
);
let problems = m.call("books.settings", json!({}))["problems"].to_string();
assert!(
problems.contains("convention software names account 6000"),
"{problems}"
);
}
/// books://start, books://settings and books://conventions read as their
/// tools do.
#[test]
fn the_kept_resources_read() {
let dir = test_dir("kept-resources");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
m.call(
"books.set_convention",
json!({"id": "software", "kind": "note", "title": "Software",
"body_md": "Software is 6000.", "decided_by": "A"}),
);
let read = |m: &mut Mcp, uri: &str| -> Value {
let r = m.request("resources/read", json!({"uri": uri}));
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
serde_json::from_str(text).unwrap()
};
let start = read(&mut m, "books://start");
assert_eq!(start["state"], "active", "{start}");
assert_eq!(start["conventions"][0]["id"], "software", "{start}");
let settings = read(&mut m, "books://settings");
assert_eq!(settings["source"], "file", "{settings}");
let conventions = read(&mut m, "books://conventions");
assert_eq!(
conventions["conventions"][0]["id"], "software",
"{conventions}"
);
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
/// A renumber lists exactly the conventions naming the old code.
#[test]
fn renumbering_lists_only_the_conventions_naming_it() {
let dir = test_dir("kept-renumber-only");
let data = dir.join("books-data");
let mut m = Mcp::spawn(&data);
init(&mut m);
chart(&mut m);
for (id, dr, cr) in [("software", "6000", "1000"), ("deposit", "1000", "3000")] {
m.call(
"books.set_convention",
json!({"id": id, "kind": "posting_template", "title": id,
"body_md": format!("Dr {dr} / Cr {cr}."),
"structured": {"lines": [
{"account": dr, "side": "debit", "share": "1"},
{"account": cr, "side": "credit", "share": "1"}]},
"decided_by": "A"}),
);
}
let out = m.call(
"books.renumber_account",
json!({"account": "6000", "code": "6010"}),
);
let mentions: Vec<String> = out["config_mentions"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.filter(|s| s.starts_with("convention "))
.collect();
assert_eq!(
mentions,
["convention software (revise it with books.set_convention)"],
"{out}"
);
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,781 +0,0 @@
//! P3 defects 14 and 24 through the MCP, over stdio against the real
//! binary: each party's part of an entry (`books.get_entry` `resolved`,
//! `direction`, `cash`, `in_kind`), party totals on a basis
//! (`books.party`, `books.parties` `basis`), and a party named the
//! creditor of a liability (`books.update_party` `creditor_of`).
//!
//! Expected behavior comes only from the tool descriptions and parameter /
//! result doc comments in `src/server.rs` and `src/types.rs` (as
//! `tools/list` serves them) and rules.md B-35. The harness is the minimal
//! subset of `tests/spec_mcp.rs` (copied from `tests/spec_parties.rs`):
//! every success validates against the tool's `outputSchema`, and every
//! refusal leaves `journal.jsonl` byte-identical.
use serde_json::{Value, json};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(180);
// ---------------------------------------------------------------- harness
#[derive(Debug, Clone)]
enum Out {
Ok(Value),
ToolErr(String),
RpcErr(Value),
}
impl Out {
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 {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The books dir when the server owns it: removed after the server
/// stops (fields drop after `Drop::drop`).
books_dir: Option<TestDir>,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&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 n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"ledger-mcp-spec-party-totals-{}-{name}-{n}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
/// receipt threshold is high so ordinary test entries need no receipt.
fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}"
),
)
.unwrap();
path
}
impl Mcp {
fn start(dir: &Path, toml: &Path) -> Self {
let run = dir.join("run");
std::fs::create_dir_all(&run).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(toml)
.env("XDG_RUNTIME_DIR", &run)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut mcp = Self {
books_dir: None,
stdin,
rx,
child,
next_id: 1,
data: dir.join("books-data"),
validators: HashMap::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-party-totals", "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 validator = jsonschema::validator_for(&tool["outputSchema"])
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}"));
mcp.validators.insert(name, validator);
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// The journal's line types, in order.
fn journal_types(&self) -> Vec<String> {
String::from_utf8(self.journal())
.unwrap()
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
let v: Value = serde_json::from_str(l).unwrap();
v["type"]
.as_str()
.or_else(|| v["line_type"].as_str())
.unwrap_or_default()
.to_string()
})
.collect()
}
fn send_line(&mut self, line: &str) {
writeln!(self.stdin, "{line}").unwrap();
self.stdin.flush().unwrap();
}
fn recv(&mut self) -> Option<Value> {
loop {
match self.rx.recv_timeout(TIMEOUT) {
Ok(line) => {
let v: Value = serde_json::from_str(&line)
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
if v.get("id").is_none() && v.get("method").is_some() {
continue;
}
return Some(v);
}
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
}
}
}
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()
.unwrap_or_else(|| panic!("no response to {method} (id {id})"));
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool; a refusal must leave the journal unchanged, a success
/// must match the outputSchema.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
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)) {
Out::ToolErr(
result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string(),
)
} else {
let sc = result["structuredContent"].clone();
let v = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = v.iter_errors(&sc).map(|e| e.to_string()).collect();
assert!(
errors.is_empty(),
"{name} violates its outputSchema: {errors:?}\n{sc}"
);
Out::Ok(sc)
}
};
if out.refused() {
assert!(
self.journal() == before,
"refused {name} {args} changed the journal ({out:?})"
);
}
out
}
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
match self.tool(name, args) {
Out::Ok(v) => v,
other => panic!("{name} {a}: expected success, got {other:?}"),
}
}
/// `tool`, expecting a refusal that says why.
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:?}");
let why = out.err_text();
assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why");
why
}
/// `ok`, and the journal must not change (read-only tools).
fn read_only(&mut self, name: &str, args: Value) -> Value {
let before = self.journal();
let v = self.ok(name, args);
assert!(
self.journal() == before,
"read-only {name} changed the journal"
);
v
}
fn accounts(&mut self, accts: &[(&str, &str, &str)]) {
for (code, name, ty) in accts {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
}
}
/// Draft an entry with `args` extra (party, vendor) and lines; the id.
fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
let mut args = json!({"date": date, "description": format!("entry {date}")});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
let id = self.ok("books.draft_entry", args)["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
}
/// `draft`, then post it (high receipt threshold: no receipt needed).
fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 {
let id = self.draft(date, extra, lines);
self.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
id
}
fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 {
self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"]
.as_u64()
.unwrap()
}
/// How many journal lines have this type.
fn count(&self, line_type: &str) -> usize {
self.journal_types()
.iter()
.filter(|t| t.as_str() == line_type)
.count()
}
}
const CHART: &[(&str, &str, &str)] = &[
("1000", "Checking", "asset"),
("1300", "Prepaid Expenses", "asset"),
("1500", "Equipment", "asset"),
("2050", "Business Card", "liability"),
("2100", "Due to Members", "liability"),
("3000", "Capital — Tobin Harlow", "equity"),
("3001", "Capital — Morgan Ellis", "equity"),
("6050", "Domain & Hosting", "expense"),
("6400", "Bank Fees", "expense"),
];
/// Fernhill Labs LLC: two members 50/50, cash 1000, the members'
/// financing 2100; the chart; both members and the card's issuer in the
/// directory.
fn books(name: &str) -> Mcp {
books_in(name, false)
}
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
/// P3's features, books an older version wrote, waiting for their format
/// upgrade (rules.md B-27).
fn books_in(name: &str, p2: bool) -> Mcp {
let dir = test_dir(name);
if p2 {
ledger_store::Store::create_in_format(
&dir.join("books-data"),
ledger_core::Currency::USD,
&ledger_store::JournalFormat {
format: 2,
features: Vec::new(),
},
)
.unwrap();
}
let toml = books_toml(
&dir,
"Fernhill Labs LLC",
"financing_accounts = [\"2100\"]\n\
[[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
);
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.add_party("Tobin Harlow", &["member"]);
m.add_party("Morgan Ellis", &["member"]);
m.add_party("Harbor Bank", &["financial_institution"]);
m.books_dir = Some(dir);
m
}
impl Mcp {
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
/// after the passphrase and a verified backup; no tool can): the
/// server stopped, the books moved to this version's format, and
/// served again.
fn upgraded(mut self) -> Mcp {
let dir = self.books_dir.take().expect("books_dir");
drop(self);
ledger_store::Store::open(&dir.join("books-data"))
.unwrap()
.upgrade_format()
.unwrap()
.expect("P2's books wait for the upgrade");
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
m.books_dir = Some(dir);
m
}
}
/// A party's part of an entry as books.get_entry gives it: (resolved,
/// direction, cash, in_kind).
fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) {
let e = m.read_only("books.get_entry", json!({"entry_id": entry}));
let p = e["parties"]
.as_array()
.unwrap()
.iter()
.find(|p| p["party"] == party)
.unwrap_or_else(|| panic!("{party} on entry {entry}: {e}"))
.clone();
(
p["resolved"].clone(),
p["direction"].clone(),
p["cash"].clone(),
p["in_kind"].clone(),
)
}
/// (paid, received, basis) from books.party.
fn totals(m: &mut Mcp, party: &str, basis: Option<&str>) -> (Value, Value, Value) {
let mut args = json!({"party": party, "year": 2026});
if let Some(b) = basis {
args["basis"] = json!(b);
}
let p = m.read_only("books.party", args);
let t = &p["totals"];
(t["paid"].clone(), t["received"].clone(), t["basis"].clone())
}
fn reverse(m: &mut Mcp, entry: u64, date: &str) -> u64 {
m.ok(
"books.reverse_entry",
json!({"entry_id": entry, "date": date, "description": "reversed", "confirmed": true}),
)["reversal_id"]
.as_u64()
.unwrap_or_else(|| panic!("a reversal of {entry}"))
}
/// Defect 14 over the MCP (rules.md B-35): `resolved` is signed as a line
/// on the party's own account, with its direction and its money and
/// property; a reversal is the exact opposite; party totals take a basis,
/// cash by default and echoed, all adding property in kind, so that a
/// member's basis-all received is the statement of members' equity's
/// contributions; any other basis is refused.
#[test]
fn defect14_party_totals_take_a_basis() {
let mut m = books("defect14");
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
// Cash in, 400.00 each.
let cash_in = m.post(
"2026-10-05",
tobin.clone(),
&[("1000", "debit", "400.00"), ("3000", "credit", "400.00")],
);
m.post(
"2026-10-05",
json!({"parties": [{"party": "Morgan Ellis"}]}),
&[("1000", "debit", "400.00"), ("3001", "credit", "400.00")],
);
// A laptop Tobin contributes, then its reversal.
let laptop = m.post(
"2026-10-12",
tobin.clone(),
&[("1500", "debit", "1480.00"), ("3000", "credit", "1480.00")],
);
let undo = reverse(&mut m, laptop, "2026-10-13");
// Devices from both, derived from the capital lines; one with its
// service contract (prepaid: property).
m.post(
"2026-10-14",
both.clone(),
&[
("1500", "debit", "1480.00"),
("3000", "credit", "740.00"),
("3001", "credit", "740.00"),
],
);
m.post(
"2026-10-19",
both.clone(),
&[
("1500", "debit", "1010.00"),
("1300", "debit", "30.00"),
("3000", "credit", "520.00"),
("3001", "credit", "520.00"),
],
);
// A reimbursement to both (money, paid).
let reimburse = m.post(
"2026-10-20",
json!({"parties": [{"party": "Tobin Harlow", "amount": "93.25"}, {"party": "Morgan Ellis", "amount": "93.25"}]}),
&[("2100", "debit", "186.50"), ("1000", "credit", "186.50")],
);
assert_eq!(
part(&mut m, cash_in, "Tobin Harlow"),
(
json!("-400.00"),
json!("received"),
json!("-400.00"),
json!("0.00")
)
);
assert_eq!(
part(&mut m, laptop, "Tobin Harlow"),
(
json!("-1480.00"),
json!("received"),
json!("0.00"),
json!("-1480.00")
)
);
assert_eq!(
part(&mut m, undo, "Tobin Harlow"),
(
json!("1480.00"),
json!("paid"),
json!("0.00"),
json!("1480.00")
)
);
assert_eq!(
part(&mut m, reimburse, "Morgan Ellis"),
(json!("93.25"), json!("paid"), json!("93.25"), json!("0.00"))
);
// Totals: cash by default, labelled; all adds 740.00 + 520.00.
let cash = (json!("93.25"), json!("400.00"), json!("cash"));
assert_eq!(totals(&mut m, "Tobin Harlow", None), cash);
assert_eq!(totals(&mut m, "Tobin Harlow", Some("cash")), cash);
let all = totals(&mut m, "Tobin Harlow", Some("all"));
assert_eq!(all, (json!("93.25"), json!("1660.00"), json!("all")));
// The statement of members' equity, the same dates: contributions
// 1,660.00 each (the laptop and its reversal cancel), no
// distributions; basis all's received is exactly that.
let me = m.read_only(
"books.export",
json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}),
);
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
for who in ["Tobin Harlow", "Morgan Ellis"] {
let col = me["members"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == who)
.unwrap_or_else(|| panic!("{who} in {me}"))
.clone();
assert_eq!(col["contributions"], totals(&mut m, who, Some("all")).1);
assert_eq!(col["contributions"], "1660.00");
assert_eq!(col["distributions"], "0.00");
}
// books.parties echoes the basis.
let list = m.read_only("books.parties", json!({"year": 2026, "basis": "all"}));
assert_eq!(list["basis"], "all");
let row = list["parties"]
.as_array()
.unwrap()
.iter()
.find(|p| p["name"] == "Morgan Ellis")
.unwrap()
.clone();
assert_eq!(
(row["paid"].clone(), row["received"].clone()),
(json!("93.25"), json!("1660.00"))
);
assert_eq!(m.read_only("books.parties", json!({}))["basis"], "cash");
// Any other basis is refused, by both tools.
for (tool, args) in [
(
"books.party",
json!({"party": "Tobin Harlow", "basis": "accrual"}),
),
("books.parties", json!({"basis": "accrual"})),
] {
let why = m.refuse(tool, args);
assert!(why.contains("\"cash\" or \"all\""), "{tool}: {why}");
}
}
/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the
/// liabilities the company owes a party itself on (journaled as
/// party.creditor_set, under the journal feature the books declared when
/// they were created, so nothing is declared on the way), replaces
/// the list, refuses what isn't a liability with nothing journaled; and a
/// fee on the issuer's own card then reads neither, so the issuer is paid
/// what the card payment paid, once.
#[test]
fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
let mut m = books("defect24");
let issuer = json!({"parties": [{"party": "Harbor Bank"}]});
let fee = m.post(
"2026-05-01",
issuer.clone(),
&[("6400", "debit", "25.00"), ("2050", "credit", "25.00")],
);
m.post(
"2026-05-20",
issuer.clone(),
&[("2050", "debit", "125.00"), ("1000", "credit", "125.00")],
);
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("150.00"));
// Not a liability: refused, nothing journaled (the tool checks).
let why = m.refuse(
"books.update_party",
json!({"party": "Harbor Bank", "creditor_of": ["1000"]}),
);
assert!(why.contains("liability"), "{why}");
assert_eq!(m.count("party.creditor_set"), 0);
let before = m.count("journal.format_set");
let p = m.ok(
"books.update_party",
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
);
assert_eq!(p["creditor_of"], json!(["2050"]));
assert_eq!(m.count("party.creditor_set"), 1);
assert_eq!(
m.count("journal.format_set"),
before,
"nothing declared on the way"
);
// The same list again changes nothing.
let journal = m.journal();
m.ok(
"books.update_party",
json!({"party": "Harbor Bank", "creditor_of": ["2050"]}),
);
assert!(m.journal() == journal);
assert_eq!(
part(&mut m, fee, "Harbor Bank"),
(
json!("-25.00"),
json!("neither"),
json!("0.00"),
json!("0.00")
)
);
assert_eq!(totals(&mut m, "Harbor Bank", None).0, json!("125.00"));
assert_eq!(
totals(&mut m, "Harbor Bank", Some("all")).0,
json!("125.00")
);
// Both members are owed what the members advanced; then one isn't.
for who in ["Tobin Harlow", "Morgan Ellis"] {
let p = m.ok(
"books.update_party",
json!({"party": who, "creditor_of": ["2100"]}),
);
assert_eq!(p["creditor_of"], json!(["2100"]));
}
let p = m.ok(
"books.update_party",
json!({"party": "Morgan Ellis", "creditor_of": []}),
);
assert_eq!(p["creditor_of"], json!([]));
assert_eq!(m.count("party.creditor_set"), 4);
assert_eq!(m.count("journal.format_set"), before);
let p = m.read_only("books.party", json!({"party": "Tobin Harlow"}));
assert_eq!(p["party"]["creditor_of"], json!(["2100"]));
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
/// rules.md B-27: `books.update_party` asks for the `party_creditors`
/// feature before the first line it writes, never between them. In books
/// waiting for their format upgrade (P2's), a name change with the first
/// creditor is refused whole, journaling nothing (never the notes changed
/// and the creditor not); once the owner upgrades them, as in books this
/// version made, it journals the change, then the creditor, and declares
/// nothing.
#[test]
fn update_party_asks_for_the_creditor_feature_before_its_first_line() {
let change =
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]});
let mut m = books_in("feature-first", true);
let why = m.refuse("books.update_party", change.clone());
assert!(
why.starts_with("format_upgrade_pending:") && why.contains("party_creditors"),
"{why}"
);
assert_eq!(m.count("party.updated"), 0, "nothing half done");
for mut m in [m.upgraded(), books("feature-first-new")] {
let start = m.journal_types().len();
let p = m.ok("books.update_party", change.clone());
assert_eq!(p["creditor_of"], json!(["2050"]));
assert_eq!(
m.journal_types()[start..].to_vec(),
["party.updated", "party.creditor_set"],
);
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
}
}
/// What `books.post_entry` previews is what it posts: each party's
/// `resolved`, `direction`, `cash` and `in_kind` in the preview are
/// `books.get_entry`'s once confirmed (a derived split is then journaled as
/// the amounts it resolved to, the same parts), for a joint contribution
/// of money and property and for a distribution partly still owed.
#[test]
fn the_post_preview_shows_the_parts_it_posts() {
let mut m = books("preview");
m.accounts(&[("2150", "Distributions Payable", "liability")]);
let both = json!({"parties": [{"party": "Tobin Harlow"}, {"party": "Morgan Ellis"}]});
let tobin = json!({"parties": [{"party": "Tobin Harlow"}]});
let parts = |v: &Value| -> Vec<Value> {
v["parties"]
.as_array()
.unwrap()
.iter()
.map(|p| {
json!([
p["party"],
p["resolved"],
p["direction"],
p["cash"],
p["in_kind"]
])
})
.collect()
};
let joint = m.draft(
"2026-03-02",
both,
&[
("1000", "debit", "610.00"),
("1500", "debit", "390.00"),
("3000", "credit", "500.00"),
("3001", "credit", "500.00"),
],
);
let owed = m.draft(
"2026-03-03",
tobin,
&[
("3000", "debit", "300.00"),
("1000", "credit", "120.00"),
("2150", "credit", "180.00"),
],
);
for (id, want) in [
(
joint,
vec![
json!(["Tobin Harlow", "-500.00", "received", "-305.00", "-195.00"]),
json!(["Morgan Ellis", "-500.00", "received", "-305.00", "-195.00"]),
],
),
(
owed,
vec![json!(["Tobin Harlow", "300.00", "paid", "120.00", "0.00"])],
),
] {
let preview = m.read_only("books.post_entry", json!({"entry_id": id}));
assert_eq!(preview["status"], "preview");
assert_eq!(parts(&preview), want, "entry {id}'s preview");
m.ok(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let posted = m.read_only("books.get_entry", json!({"entry_id": id}));
assert_eq!(parts(&posted), want, "entry {id} posted");
}
let (paid, received, _) = totals(&mut m, "Tobin Harlow", None);
assert_eq!((paid, received), (json!("120.00"), json!("305.00")));
}
File diff suppressed because it is too large. Load diff
-451
View File
@@ -1,451 +0,0 @@
//! Tax categories at account creation, and `policy.require_tax_category`
//! (P3, defect 8; rules.md B-32), through the real server over stdio.
//!
//! ```text
//! GOAL: every revenue and expense account maps to a line of the return,
//! or the human hears that it doesn't; a company that refuses
//! uncategorized accounts gets exactly that, never a refused
//! invoice.
//! SOURCE: rules.md B-32; plan/served.md P3 defect 8 and "Settled in
//! design" (warn by default, headers exempt).
//! ```
//!
//! Fixtures are fictional (Fernhill Labs LLC).
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
struct Mcp {
stdin: ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: u64,
child: Child,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
impl Mcp {
/// A server on books that keep their own settings, initialized.
fn spawn(data_dir: &std::path::Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(data_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut m = Self {
stdin,
stdout,
next_id: 1,
child,
};
m.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-tax-policy", "version": "0"}}),
);
let msg = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}});
writeln!(m.stdin, "{msg}").unwrap();
m.stdin.flush().unwrap();
m
}
fn request(&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});
writeln!(self.stdin, "{msg}").unwrap();
self.stdin.flush().unwrap();
let mut line = String::new();
self.stdout
.read_line(&mut line)
.expect("read response line");
let resp: Value = serde_json::from_str(&line).expect("parse response");
assert_eq!(resp["id"], id, "id mismatch in {resp}");
assert!(resp.get("error").is_none(), "{method}: {}", resp["error"]);
resp
}
fn result(&mut self, tool: &str, args: Value) -> Value {
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
resp["result"].clone()
}
/// Call a tool; panic on a refusal. Returns structuredContent.
fn call(&mut self, tool: &str, args: Value) -> Value {
let result = self.result(tool, args);
assert_ne!(
result.get("isError"),
Some(&json!(true)),
"{tool}: {}",
result["content"]
);
result.get("structuredContent").cloned().unwrap_or_else(|| {
serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap()
})
}
/// Call a tool expecting a refusal; returns its text.
fn call_err(&mut self, tool: &str, args: Value) -> String {
let result = self.result(tool, args);
assert_eq!(
result.get("isError"),
Some(&json!(true)),
"{tool}: {result}"
);
result["content"][0]["text"].as_str().unwrap().to_string()
}
fn events(&mut self) -> u64 {
self.call("books.verify", json!({"blobs": false}))["events"]
.as_u64()
.unwrap()
}
fn set_policy(&mut self, value: Value) {
self.call(
"books.set_setting",
json!({"changes": [{"key": "policy.require_tax_category", "value": value}],
"decided_by": "Tobin Harlow", "decided_on": "2026-10-20"}),
);
}
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
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!("ledger-mcp-tax-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn add(m: &mut Mcp, args: Value) -> Value {
m.call("books.add_account", args)
}
fn chart_codes(m: &mut Mcp) -> Vec<String> {
m.call("books.chart", json!({}))["accounts"]
.as_array()
.unwrap()
.iter()
.map(|a| a["code"].as_str().unwrap().to_string())
.collect()
}
/// Defect 8, repro 1.4.1: books.add_account of a revenue or expense
/// account without a tax category creates it and warns, naming it and
/// the tools to call; an asset, liability, equity or header account, or
/// one given its category, has no warning. A category that doesn't fit
/// the account or the return is refused, and a deductible percent needs
/// a category.
#[test]
fn add_account_warns_without_tax_category_defect_8() {
let dir = test_dir("warn");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
let out = add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense"}),
);
assert!(out["account_id"].as_u64().is_some());
assert_eq!(
out["warnings"],
json!([
"6150 Software has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."
])
);
let out = add(
&mut m,
json!({"code": "4000", "name": "Sales", "account_type": "revenue"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
for args in [
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
json!({"code": "2000", "name": "Card", "account_type": "liability"}),
json!({"code": "3000", "name": "Capital", "account_type": "equity"}),
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}),
json!({"code": "6170", "name": "Meals", "account_type": "expense", "tax_category": "meals", "deductible_percent": "50"}),
] {
let out = add(&mut m, args.clone());
assert!(out.get("warnings").is_none(), "{args}: {out}");
}
let chart = m.call("books.chart", json!({}));
let meals = chart["accounts"]
.as_array()
.unwrap()
.iter()
.find(|a| a["code"] == "6170")
.unwrap()
.clone();
assert_eq!(meals["tax_category"], "meals");
assert_eq!(meals["deductible_percent"], "50");
let before = m.events();
for (args, says) in [
(
json!({"code": "4100", "name": "Fees", "account_type": "revenue", "tax_category": "meals"}),
"expense",
),
(
json!({"code": "6180", "name": "X", "account_type": "expense", "tax_category": "no_such"}),
"unknown tax category",
),
(
json!({"code": "6190", "name": "Y", "account_type": "expense", "deductible_percent": "50"}),
"needs a tax_category",
),
(
json!({"code": "6195", "name": "Z", "account_type": "expense", "tax_category": "other_deductions", "deductible_percent": "150"}),
"between 0 and 100",
),
] {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
assert_eq!(m.events(), before, "refusals write nothing");
// Needs attention's list, for the agent too.
let digest = m.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(
digest["uncategorized_accounts"],
json!(["4000 Sales", "6150 Software"])
);
}
/// Defect 8 under `policy.require_tax_category = refuse`: books.add_account
/// without a category is refused and the chart is unchanged; with one it
/// is created (account.added and account.tax_set); import_chart_csv with
/// one uncategorized expense row creates nothing and says why; clearing a
/// needed category is refused; a bill on books without 6999 still posts,
/// its result naming the account it created, which Needs attention (the
/// digest) lists. Back to warn, the same account is created with a
/// warning.
#[test]
fn require_tax_category_refuses_defect_8() {
let dir = test_dir("refuse");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
add(
&mut m,
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
);
add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}),
);
m.set_policy(json!("refuse"));
let settings = m.call("books.settings", json!({}));
assert!(
settings.to_string().contains("policy.require_tax_category"),
"{settings}"
);
let before = m.events();
let codes = chart_codes(&mut m);
let err = m.call_err(
"books.add_account",
json!({"code": "6160", "name": "Hosting", "account_type": "expense"}),
);
assert!(
err.contains("policy.require_tax_category is refuse")
&& err.contains("6160 Hosting")
&& err.contains("books.tax_categories"),
"{err}"
);
assert_eq!(chart_codes(&mut m), codes);
assert_eq!(m.events(), before);
let out = add(
&mut m,
json!({"code": "6160", "name": "Hosting", "account_type": "expense", "tax_category": "other_deductions"}),
);
assert!(out.get("warnings").is_none(), "{out}");
assert_eq!(m.events(), before + 2, "account.added and account.tax_set");
// A header is exempt.
add(
&mut m,
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
);
// The import: refused whole, saying why.
let before = m.events();
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}),
);
assert!(
err.contains("6210") && err.contains("fifth column") && err.contains("never a header"),
"{err}"
);
assert!(!chart_codes(&mut m).contains(&"6200".to_string()));
assert_eq!(m.events(), before);
// Clearing a needed category.
let err = m.call_err(
"books.set_tax_category",
json!({"account": "6150", "category": null}),
);
assert!(err.contains("keeps a tax category"), "{err}");
m.call(
"books.set_tax_category",
json!({"account": "6150", "category": "supplies"}),
);
// A bill creates General Expenses 6999 without a category: never
// refused, named in the result's warnings and the digest.
let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"}));
let bill = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "hosting", "amount": "42.00"}]}),
);
assert_eq!(
bill["warnings"],
json!([
"6999 General Expenses has no tax category: the tax summary will list it as uncategorized. Propose one from books.tax_categories and set it with books.set_tax_category once the human agrees."
])
);
let digest = m.call("books.digest", json!({"since": "2026-01-01"}));
assert_eq!(
digest["uncategorized_accounts"],
json!(["6999 General Expenses"])
);
// A second bill creates nothing, and warns of nothing.
let again = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "hosting", "amount": "1.00"}]}),
);
assert!(again.get("warnings").is_none(), "{again}");
// Back to warn (cleared): created, with the warning.
m.set_policy(Value::Null);
let out = add(
&mut m,
json!({"code": "6210", "name": "Misc", "account_type": "expense"}),
);
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
let out = m.call(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}),
);
assert_eq!(out["count"], 3);
assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}");
}
/// rules.md B-31 for new accounts: a name another account under the same
/// parent has, ignoring capitals (top-level accounts are siblings too),
/// or one holding nine digits in a row, is refused, for books.add_account
/// and books.import_chart_csv alike; under another parent the name is
/// free.
#[test]
fn a_new_account_named_like_a_sibling_is_refused() {
let dir = test_dir("names");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
add(
&mut m,
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
);
let ops = m.call("books.chart", json!({}))["accounts"][0]["id"].clone();
add(
&mut m,
json!({"code": "6150", "name": "Software", "account_type": "expense", "parent": ops, "tax_category": "other_deductions"}),
);
let before = m.events();
for (args, says) in [
(
json!({"code": "6151", "name": "SOFTWARE", "account_type": "expense", "parent": ops}),
"6150 Software already has the name",
),
(
json!({"code": "6001", "name": " operating ", "account_type": "expense"}),
"6000 Operating already has the name",
),
(
json!({"code": "1000", "name": "Checking 123456789", "account_type": "asset"}),
"nine digits",
),
] {
let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}");
}
let err = m.call_err(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}),
);
assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}");
assert_eq!(m.events(), before);
// Top level: no sibling of the child.
add(
&mut m,
json!({"code": "6200", "name": "Software", "account_type": "expense", "tax_category": "other_deductions"}),
);
}
/// rules.md B-32: a gain on business property (`form_4797_gain_loss`, the
/// disposal account a refusing company must categorize) is exported apart
/// in the tax summary, JSON and CSV alike: at its book amount, with no tax
/// amount, and outside income and both nets (Form 4797 figures it from
/// the tax basis; never Schedule C income, never ordinary income the
/// summary could place).
#[test]
fn a_gain_on_business_property_is_exported_apart() {
let dir = test_dir("form4797");
let mut m = Mcp::spawn(&dir.0.join("books-data"));
for args in [
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
json!({"code": "4000", "name": "Sales", "account_type": "revenue", "tax_category": "gross_receipts"}),
json!({"code": "4700", "name": "Gain on disposal", "account_type": "revenue", "tax_category": "form_4797_gain_loss"}),
] {
add(&mut m, args);
}
for (cr, amount) in [("4000", "10.00"), ("4700", "7.50")] {
let e = m.call(
"books.draft_entry",
json!({"date": "2026-03-02", "description": "x", "no_party_reason": "test"}),
);
let id = e["entry_id"].clone();
for (account, side) in [("1000", "debit"), (cr, "credit")] {
m.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
m.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
}
let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"});
let out = m.call("books.export", range("json"));
let j: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap();
assert_eq!(
j["form_4797"],
json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)",
"accounts": ["4700 Gain on disposal"], "book": "7.50"}])
);
assert_eq!(j["income"].as_array().unwrap().len(), 1, "{j}");
assert_eq!(j["income"][0]["category"], "gross_receipts");
assert_eq!(j["book_net"], "10.00");
assert_eq!(j["tax_net"], "10.00");
let out = m.call("books.export", range("csv"));
let csv = out["content"].as_str().unwrap();
assert!(
csv.lines().any(|l| l
== "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"),
"{csv}"
);
assert!(
!csv.lines().any(|l| l.starts_with("income,form_4797")),
"{csv}"
);
}
File diff suppressed because it is too large. Load diff
-264
View File
@@ -1,264 +0,0 @@
//! Starting the server: arguments, where the books and settings are found,
//! and what happens when another writer has the books.
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp");
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::ffi::OsStr> for TestDir {
fn as_ref(&self) -> &std::ffi::OsStr {
self.0.as_os_str()
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn temp(tag: &str) -> TestDir {
let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TestDir(d)
}
/// Start the server, initialize, call one tool, and return its text
/// result (or the error text), then end the session.
fn one_call(cmd: &mut Command, tool: &str, args: serde_json::Value) -> String {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut out = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: serde_json::Value| writeln!(stdin, "{v}").unwrap();
send(
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}),
);
let mut line = String::new();
out.read_line(&mut line).unwrap();
send(serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}));
send(
serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":args}}),
);
line.clear();
out.read_line(&mut line).unwrap();
drop(stdin);
let _ = child.wait();
line
}
#[test]
fn help_names_the_options() {
let out = Command::new(BIN).arg("--help").output().unwrap();
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stderr);
for opt in ["--data-dir", "--books-toml", "--config", "--http"] {
assert!(text.contains(opt), "--help lacks {opt}: {text}");
}
}
/// With no --data-dir the books live in ~/.local/share/wanderbooks.
#[test]
fn the_default_data_dir_is_under_home() {
let home = temp("home");
one_call(
Command::new(BIN).env("HOME", &home),
"books.chart",
serde_json::json!({}),
);
assert!(
home.join(".local/share/wanderbooks/journal.jsonl").exists(),
"no books under {}",
home.display()
);
}
/// wanderbooks.toml beside the data folder is read without --books-toml:
/// its receipt threshold (0.00) refuses a 1.00 entry with no receipt,
/// which the default ($25.00) would post.
#[test]
fn the_settings_beside_the_books_are_read() {
let dir = temp("beside");
std::fs::write(
dir.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"0.00\"\n",
)
.unwrap();
let data = dir.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account(
"6000",
"Rent",
ledger_core::AccountType::Expense,
None,
false,
)
.unwrap();
let e = store
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "x")
.unwrap();
let m = ledger_core::Money::new(rust_decimal::Decimal::ONE, ledger_core::Currency::USD);
store
.add_line(e, rent, ledger_core::Side::Debit, m)
.unwrap();
store
.add_line(e, cash, ledger_core::Side::Credit, m)
.unwrap();
drop(store);
let reply = one_call(
Command::new(BIN).arg("--data-dir").arg(&data),
"books.post_entry",
serde_json::json!({"entry_id": e, "confirmed": true}),
);
assert!(
reply.contains("receipt_required"),
"posted without a receipt: {reply}"
);
}
/// The cash flow roles in wanderbooks.toml beside the data folder are
/// read without --books-toml: its cash account lets cash_flow run with no
/// cash_accounts of its own.
#[test]
fn the_cash_roles_beside_the_books_are_read() {
let dir = temp("roles");
std::fs::write(
dir.join("wanderbooks.toml"),
"[books]\ncash_accounts = [\"1000\"]\n",
)
.unwrap();
let data = dir.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
drop(store);
let reply = one_call(
Command::new(BIN).arg("--data-dir").arg(&data),
"books.cash_flow",
serde_json::json!({"start": "2026-01-01", "end": "2026-03-31"}),
);
assert!(reply.contains("\"isError\":false"), "{reply}");
}
/// A writer that keeps the books is a conflict: the server gives up with
/// an error (after its ten-second wait), never hangs.
#[test]
fn a_held_lock_is_refused_not_waited_on_forever() {
let dir = temp("locked");
let _holder = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
let start = Instant::now();
let mut child = Command::new(BIN)
.arg("--data-dir")
.arg(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let status = loop {
if let Some(s) = child.try_wait().unwrap() {
break s;
}
if start.elapsed() > Duration::from_secs(60) {
let _ = child.kill();
panic!("the server waited on a held lock for a minute");
}
std::thread::sleep(Duration::from_millis(100));
};
assert!(!status.success());
assert!(
start.elapsed() >= Duration::from_secs(9),
"gave up before waiting"
);
}
/// Books written by a newer wanderbooks: the server won't start on them,
/// and says to update it, not that the books are corrupt (rules.md B-27).
#[test]
fn newer_books_are_refused_as_newer_not_corrupt() {
let dir = temp("newer");
drop(ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap());
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
serde_json::json!({"format": 3, "features": []}),
)
.unwrap();
// The next line chains onto it, so its bytes are checked (B-27).
w.append(
"fx.rate_set",
serde_json::json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
)
.unwrap();
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let out = Command::new(BIN)
.arg("--data-dir")
.arg(&dir)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("written by a newer version of wanderbooks"),
"{err}"
);
assert!(err.contains("Update wanderbooks"), "{err}");
assert!(!err.contains("corrupt"), "{err}");
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
journal
);
}
/// `--import-root` is gone (files come in as tool input, P2 slice 5): a
/// server started with it stops at once, as for any unknown argument,
/// and never serves as if it had read from that folder.
#[test]
fn the_import_root_flag_is_refused_as_unknown() {
let dir = temp("import-root");
let out = Command::new(BIN)
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--import-root")
.arg(dir.join("Downloads"))
.env("XDG_CONFIG_HOME", dir.join("config"))
.env("XDG_RUNTIME_DIR", dir.join("config"))
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("unknown argument: --import-root"), "{err}");
assert!(!err.contains("serving"), "{err}");
assert!(!dir.join("books-data").exists(), "opened the books");
}
-733
View File
@@ -1,733 +0,0 @@
//! Files that carry full TINs never enter the books (rules.md B-26;
//! tin_files.rs).
//!
//! ```text
//! GOAL: no file holding a full TIN can be filed as a receipt or document
//! (books.get_receipt would hand its bytes to any agent), however it
//! comes back: the W-9 books.set_w9 read, books.get_w9's copy, a CPA
//! package or a file from one, or text with a TIN in it.
//! SOURCE: rules.md B-26; DECISION (P0 review round 2, 2026-10-16).
//! INVARIANTS (oracle):
//! 1. Every file the server read or wrote with full TINs is refused by
//! every way a file enters the books (books.attach_receipt and
//! books.file_document, its bytes given inline), from any session and
//! after the server restarts.
//! 2. Text holding a full TIN is refused, whoever made it: one the vault
//! holds, in every common rendering (nine digits; 3-2-4 joined by
//! hyphens, spaces or dots; 2-7 joined by a hyphen or a space), and,
//! with or without the vault, a number printed as an SSN or EIN is
//! (123-45-6789, 12-3456789) standing alone.
//! 3. Text probing for a TIN (more than two TIN-shaped numbers ending
//! in a last four the books know) is refused alike, byte for byte,
//! whether or not one of them is a TIN the vault holds; one or two
//! such numbers are checked against the vault.
//! 4. A refusal stores nothing.
//! 5. Ordinary text goes in: a bank export with routing numbers, long
//! references, and dates next to amounts (DECISION, owner,
//! 2026-10-16: bank exports are filed for reconciliation).
//! ```
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, ClientConfig};
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::StreamableHttpClientTransport;
use serde_json::{Value, json};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
type Client = RunningService<RoleClient, ClientConfig>;
const SSN: &str = "291-12-2846";
const MEMBER_SSN: &str = "830-83-5531";
/// The refusal for a file the server read or wrote with TINs.
const TIN_FILE: &str = "read or wrote with full TINs";
/// The refusal for text holding a TIN the vault holds.
const TIN_HELD: &str = "a full TIN the books keep in the TIN vault";
/// The refusal for text probing for a TIN.
const TIN_PROBE: &str = "more than two numbers shaped like a TIN";
/// The refusal for text holding a number printed as an SSN or EIN is.
const TIN_PRINTED: &str = "printed the way an SSN or EIN is";
struct Server(Child);
impl Drop for Server {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// The books dir, a folder for files on their way in, a folder outside
/// both, and the settings (a member, so the CPA package has a K-1
/// worksheet).
struct Books {
dir: PathBuf,
inbox: PathBuf,
out: PathBuf,
toml: PathBuf,
}
/// The books dir and the folders beside it go when the test ends, pass or
/// panic.
impl Drop for Books {
fn drop(&mut self) {
if let Some(base) = self.dir.parent() {
let _ = std::fs::remove_dir_all(base);
}
}
}
fn books(tag: &str) -> Books {
let base =
std::env::temp_dir().join(format!("ledger-mcp-tin-files-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let dir = base.join("books");
let inbox = base.join("inbox");
let out = base.join("out");
for d in [&dir, &inbox, &out, &base.join("config")] {
std::fs::create_dir_all(d).unwrap();
}
let toml = dir.join("wanderbooks.toml");
std::fs::write(
&toml,
format!(
"[company]\nname = \"Tin Co\"\n[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n\
[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n\
[vault]\nkey_file = \"{}\"\n",
base.join("config/vault.key").display()
),
)
.unwrap();
Books {
dir,
inbox,
out,
toml,
}
}
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
/// Start the server over HTTP on these books; every client is a session
/// of its own.
fn serve(b: &Books) -> (Server, String) {
let addr = format!("127.0.0.1:{}", free_port());
let base = b.dir.parent().unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(b.dir.join("books-data"))
.arg("--books-toml")
.arg(&b.toml)
.arg("--http")
.arg(&addr)
.env("XDG_CONFIG_HOME", base.join("config"))
.env("XDG_RUNTIME_DIR", base.join("config"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
(Server(child), format!("http://{addr}/mcp"))
}
async fn connect(url: &str) -> Client {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let transport = StreamableHttpClientTransport::from_uri(url.to_string());
match ClientConfig::default().serve(transport).await {
Ok(c) => return c,
Err(e) if Instant::now() > deadline => panic!("connect: {e}"),
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
}
}
}
/// Call a tool: Ok(structured result) or Err(the tool's error text).
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
let args = args.as_object().cloned().unwrap_or_default();
let r = c
.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args))
.await
.map_err(|e| format!("protocol: {e}"))?;
let text = r
.content
.iter()
.find_map(|c| c.as_text().map(|t| t.text.clone()))
.unwrap_or_default();
if r.is_error == Some(true) {
Err(text)
} else {
Ok(r.structured_content
.unwrap_or_else(|| serde_json::from_str(&text).unwrap()))
}
}
async fn ok(c: &Client, tool: &str, args: Value) -> Value {
call(c, tool, args)
.await
.unwrap_or_else(|e| panic!("{tool}: {e}"))
}
/// `bytes` as a tool takes them inline: base64, and their SHA-256 (hex).
fn inline(bytes: &[u8]) -> (String, String) {
use base64::Engine;
(
base64::engine::general_purpose::STANDARD.encode(bytes),
ledger_store::journal::sha256_hex(bytes),
)
}
/// The bytes of a file a tool handed over (a `HandedFile`): inline, as on
/// books served from a --data-dir, matching the size and SHA-256 it
/// states.
fn handed(file: &Value) -> Vec<u8> {
use base64::Engine;
assert!(file.get("download").is_none(), "{file}");
let bytes = base64::engine::general_purpose::STANDARD
.decode(file["content_base64"].as_str().expect("content_base64"))
.unwrap();
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes)),
"{file}"
);
bytes
}
/// Every way the file at `path` (named `name`) could enter the books is
/// refused with `why`, and nothing is stored: the refusals, tool by tool.
async fn refused_everywhere(
c: &Client,
b: &Books,
path: &Path,
name: &str,
why: &str,
) -> Vec<String> {
let bytes = std::fs::read(path).unwrap();
let (content, sha) = inline(&bytes);
let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE);
let before = std::fs::read(&journal).unwrap();
let mut refusals = Vec::new();
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": name, "mime_type": "application/octet-stream",
"content_base64": content, "sha256": sha}),
),
(
"books.file_document",
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
),
] {
let err = call(c, tool, args).await.expect_err(tool);
assert!(err.contains(why), "{tool} {name}: {err}");
refusals.push(err);
}
assert_eq!(
std::fs::read(&journal).unwrap(),
before,
"a refusal stored something"
);
refusals
}
/// INVARIANT 1: the W-9 books.set_w9 read (binary, no TIN in its text), a
/// books.get_w9 copy of it, a 1099 worksheet and a CPA package with TINs,
/// and the K-1 worksheet extracted from that package are each refused,
/// from another session and after a restart; INVARIANT 2: a CSV holding a
/// TIN that the server never saw is refused, sent as base64.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() {
let b = books("sessions");
// A W-9 scan: binary bytes, no TIN a text scan would see.
let w9 = b.inbox.join("w9.pdf");
std::fs::write(
&w9,
b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n",
)
.unwrap();
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
let (server, url) = serve(&b);
let one = connect(&url).await;
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("6000", "Design", "expense"),
] {
ok(
&one,
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)
.await;
}
ok(
&one,
"books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}),
)
.await;
ok(
&one,
"books.add_party",
json!({"name": "Mo", "roles": ["member"]}),
)
.await;
ok(
&one,
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_content_base64": w9_content, "w9_sha256": w9_sha}),
)
.await;
ok(
&one,
"books.set_tin",
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
)
.await;
// A bank statement filed in the books: the package carries it, and it
// is no TIN file (its routing numbers are not TINs).
accepted_everywhere(&one, "statement.csv", BANK_CSV).await;
// What the server hands over with TINs, from the first session, saved
// outside the books as the human would save it.
let copy = b.out.join("w9-copy.pdf");
let got = ok(&one, "books.get_w9", json!({"party": "Acme"})).await;
std::fs::write(&copy, handed(&got["file"])).unwrap();
let csv = b.out.join("1099.csv");
let got = ok(
&one,
"books.vendor_1099",
json!({"year": 2026, "include_tins": true, "csv": true}),
)
.await;
std::fs::write(&csv, handed(&got["csv"])).unwrap();
let zip = b.out.join("cpa.zip");
let got = ok(
&one,
"books.export_cpa_package",
json!({"cash_accounts": ["1000"], "as_of": "2026-12-31"}),
)
.await;
assert_eq!(got["tins_included"], true, "{got}");
std::fs::write(&zip, handed(&got["file"])).unwrap();
let k1 = b.out.join("k1-members.csv");
{
let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap();
let mut member = archive.by_name("k1-members.csv").unwrap();
let mut bytes = Vec::new();
member.read_to_end(&mut bytes).unwrap();
assert!(
String::from_utf8_lossy(&bytes).contains(MEMBER_SSN),
"the K-1 worksheet holds the TIN"
);
std::fs::write(&k1, bytes).unwrap();
}
let files = [
(w9.clone(), "receipt.pdf"),
(copy.clone(), "invoice.pdf"),
(csv.clone(), "statement.csv"),
(zip.clone(), "bundle.zip"),
(k1.clone(), "k1-members.csv"),
];
// A second session refuses each.
let two = connect(&url).await;
for (path, name) in &files {
refused_everywhere(&two, &b, path, name, TIN_FILE).await;
}
// Text with a TIN the server never wrote: refused by its text.
let fresh = b.out.join("payees.csv");
std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap();
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
// An ordinary file still goes in, every way.
let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n");
ok(
&two,
"books.attach_receipt",
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
"content_base64": fine, "sha256": fine_sha}),
)
.await;
// The books' own files the package carried are not marked: the
// statement still goes in.
{
let archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap();
assert!(
archive
.file_names()
.filter_map(Result::ok)
.any(|n| n.starts_with("documents/") && n.ends_with("statement.csv")),
"the package carries the statement"
);
}
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
drop((one, two));
drop(server);
// A server started afresh on the same books refuses them all too.
let (_server, url) = serve(&b);
let three = connect(&url).await;
for (path, name) in &files {
refused_everywhere(&three, &b, path, name, TIN_FILE).await;
}
// The registry holds hashes only.
let registry = std::fs::read_to_string(b.dir.join("vault/tin-files.sha256")).unwrap();
assert!(registry.lines().count() >= files.len(), "{registry}");
assert!(
registry
.lines()
.all(|l| l.len() == 64 && l.chars().all(|c| c.is_ascii_hexdigit())),
"{registry}"
);
}
/// INVARIANT 1, the other way: a books.set_w9 that is refused (an unknown
/// party, a party without a 1099 role, a file that is no PDF, PNG or
/// JPEG) never marks the file it names: the same bytes are then filed as
/// an ordinary document. Once a set_w9 with the file succeeds, they are
/// refused everywhere.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_refused_set_w9_never_marks_its_file() {
let b = books("w9-refused");
let w9 = b.inbox.join("w9.pdf");
let w9_bytes = b"%PDF-1.4\n\xff\xfe\x00 a scanned W-9 \x04\x05\n%%EOF\n".to_vec();
std::fs::write(&w9, &w9_bytes).unwrap();
let notes = b.inbox.join("notes.txt");
std::fs::write(&notes, b"Acme sent their W-9 on the 3rd.\n").unwrap();
let (_server, url) = serve(&b);
let c = connect(&url).await;
for (code, name, ty) in [("1000", "Cash", "asset"), ("3000", "Capital", "equity")] {
ok(
&c,
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)
.await;
}
for (name, role) in [("Acme", "contractor"), ("Mo", "member")] {
ok(
&c,
"books.add_party",
json!({"name": name, "roles": [role]}),
)
.await;
}
let w9_args = |party: &str, path: &Path| {
let (content, sha) = inline(&std::fs::read(path).unwrap());
json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_content_base64": content, "w9_sha256": sha})
};
for (party, path, why) in [
("Nobody", &w9, "unknown party"),
("Mo", &w9, "1099"),
("Acme", &notes, "PDF, PNG, or JPEG"),
] {
let err = call(&c, "books.set_w9", w9_args(party, path))
.await
.expect_err("refused");
assert!(err.contains(why), "{party}: {err}");
}
// Neither file was marked: each is filed as any document is.
for (name, bytes) in [
("w9-scan.pdf", w9_bytes.clone()),
("notes.txt", std::fs::read(&notes).unwrap()),
] {
let (content, sha) = inline(&bytes);
let out = ok(
&c,
"books.file_document",
json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
)
.await;
assert!(out["attachment_id"].is_u64(), "{name}: {out}");
}
// A set_w9 that succeeds marks it.
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
let (content, sha) = inline(&w9_bytes);
for (tool, args) in [
(
"books.file_document",
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
),
(
"books.attach_receipt",
json!({"file_name": "again.pdf", "mime_type": "application/pdf",
"content_base64": content, "sha256": sha}),
),
] {
let err = call(&c, tool, args).await.expect_err("marked");
assert!(err.contains(TIN_FILE), "{tool}: {err}");
}
}
/// Every way a file enters the books takes the text `name` holding
/// `text`, and the journal shows it.
async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
let (content, sha) = inline(text.as_bytes());
for (tool, args) in [
(
"books.attach_receipt",
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}),
),
(
"books.file_document",
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv",
"content_base64": content, "sha256": sha}),
),
] {
let out = ok(c, tool, args).await;
assert!(out["attachment_id"].is_u64(), "{tool}: {out}");
}
}
/// A real bank export: ABA routing numbers (nine digits), account and
/// trace numbers, long references, dates run into amounts, a ZIP+4, a
/// phone number, and the number 12345 in a ten-digit run.
const BANK_CSV: &str = "\
Date,Description,Amount,Balance,Reference
2026-10-01,ACH CREDIT APPLE INC 021000021 ID 9878143210123456,1499.00,\"12,345.67\",20261001000123456789
2026-10-02,WIRE IN 026009593 ACCT 4417123456789 TRACE 0210000210012345,250000.00,262345.67,FT26275987814321
2026-10-03 12.50,POS 2026-10-03 801-555-1234 SALT LAKE UT 84101-1234,-12.50,262333.17,1002345678
2026-10-04,ZELLE 5551234567 CONF 1123-45-6789 REF 123-45-67890,-40.00,262293.17,000123456789012
2026-10-05,CHECK 1042 SEQ 026 00 9593 ITEM 112-3456789,-100.00,262193.17,ROUTING 228620358
";
/// INVARIANTS 2-4 through the tools: a member's TIN the vault holds is
/// refused in each rendering by every filing tool; a printed SSN or EIN
/// shape is refused with the vault locked as with it open, while the
/// vault's TIN written bare then goes in (only the shapes are left); a
/// bank export goes in either way.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not() {
let b = books("text");
let (server, url) = serve(&b);
let c = connect(&url).await;
ok(
&c,
"books.add_party",
json!({"name": "Mo", "roles": ["member"]}),
)
.await;
ok(
&c,
"books.set_tin",
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
)
.await;
let renderings = [
"830835531",
"830-83-5531",
"830 83 5531",
"830.83.5531",
"83-0835531",
"83 0835531",
];
for (n, tin) in renderings.iter().enumerate() {
let path = b.out.join(format!("members-{n}.csv"));
std::fs::write(&path, format!("member,tin\nKat,{tin}\n")).unwrap();
refused_everywhere(&c, &b, &path, &format!("members-{n}.csv"), TIN_HELD).await;
}
for (n, printed) in ["Dana,187-05-1907", "Acme LLC,20-5200615"]
.iter()
.enumerate()
{
let path = b.out.join(format!("payees-{n}.csv"));
std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap();
refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await;
}
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
drop(c);
drop(server);
// The vault locked (its key gone): the printed shapes are still
// refused, and the bank export still goes in; the vault's TIN written
// bare is no longer recognized.
std::fs::remove_file(b.dir.parent().unwrap().join("config/vault.key")).unwrap();
let (_server, url) = serve(&b);
let c = connect(&url).await;
let path = b.out.join("payees-0.csv");
refused_everywhere(&c, &b, &path, "payees-0.csv", TIN_PRINTED).await;
let path = b.out.join("members-1.csv");
refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await;
accepted_everywhere(
&c,
"bank-2.csv",
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
)
.await;
accepted_everywhere(&c, "members-0.csv", "member,tin\nKat,830835531\n").await;
}
/// INVARIANT 3b through the tools: the company's own EIN, as its
/// settings hold it, goes in printed 2-7 (formation papers, a business
/// plan) by every filing tool; another EIN, or the same nine digits
/// printed as an SSN, is still refused.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_company_s_own_ein_goes_in_and_no_other() {
let b = books("own-ein");
let toml = std::fs::read_to_string(&b.toml).unwrap();
std::fs::write(
&b.toml,
toml.replacen(
"name = \"Tin Co\"\n",
"name = \"Tin Co\"\nein = \"12-3456789\"\n",
1,
),
)
.unwrap();
let (_server, url) = serve(&b);
let c = connect(&url).await;
accepted_everywhere(
&c,
"plan.md",
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n",
)
.await;
for (n, text) in [
"EIN 12-3456789; our vendor's is 20-5200615",
"as an SSN: 123-45-6789",
]
.iter()
.enumerate()
{
let path = b.out.join(format!("other-{n}.md"));
std::fs::write(&path, text).unwrap();
refused_everywhere(&c, &b, &path, &format!("other-{n}.md"), TIN_PRINTED).await;
}
}
/// INVARIANT 3 through the tools: UTF-16 text written without a
/// byte-order mark (ASCII so written is valid UTF-8, each digit followed
/// or led by a NUL) is read as the text it is, either way round: a vault
/// TIN and a printed SSN in it are refused by every filing tool,
/// `books.file_document` included, and nothing is stored.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn utf16_text_without_a_byte_order_mark_is_scanned_either_way_round() {
let b = books("utf16");
let (_server, url) = serve(&b);
let c = connect(&url).await;
ok(
&c,
"books.add_party",
json!({"name": "Mo", "roles": ["member"]}),
)
.await;
ok(
&c,
"books.set_tin",
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
)
.await;
let bare = |text: &str, le: bool| -> Vec<u8> {
text.encode_utf16()
.flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() })
.collect()
};
for (le, side) in [(true, "le"), (false, "be")] {
for (text, why, tag) in [
("member,tin\nKat,830835531\n", TIN_HELD, "held"),
("payee,tin\nDana,187-05-1907\n", TIN_PRINTED, "printed"),
] {
let bytes = bare(text, le);
assert!(
le && bytes[1] == 0 || !le && bytes[0] == 0,
"NUL-interleaved, no byte-order mark"
);
let name = format!("utf16-{side}-{tag}.csv");
let path = b.out.join(&name);
std::fs::write(&path, &bytes).unwrap();
refused_everywhere(&c, &b, &path, &name, why).await;
}
}
}
/// INVARIANT 3 through the tools: text with three numbers ending in the
/// last four of a TIN the books know (a member's TIN in the vault, a
/// payee's W-9 kept elsewhere) is refused by every filing tool, byte for
/// byte alike whether one of them is the vault's TIN or none is, with the
/// vault open or locked; one such number goes in unless it is the TIN; a
/// bank export goes in.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
let b = books("probe");
let (server, url) = serve(&b);
let c = connect(&url).await;
ok(
&c,
"books.add_party",
json!({"name": "Mo", "roles": ["member"]}),
)
.await;
ok(
&c,
"books.set_tin",
json!({"party": "Mo", "tin": MEMBER_SSN, "tin_type": "ssn"}),
)
.await;
// A W-9 kept at the CPA's: the books know its last four, the vault
// holds nothing for it.
ok(
&c,
"books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}),
)
.await;
ok(
&c,
"books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "C corporation",
"tin_last4": "7788", "kept_at": "CPA portal"}),
)
.await;
let write = |name: &str, text: &str| {
let path = b.out.join(name);
std::fs::write(&path, text).unwrap();
path
};
let with_tin = write(
"with.csv",
"member,ref\nKat,830835531\nA,864065531\nB,846825531\n",
);
let without = write(
"without.csv",
"member,ref\nKat,873685531\nA,864065531\nB,846825531\n",
);
let payee = write("payee.csv", "ref\n100007788\n100017788\n100027788\n");
let one = write("one.csv", "member,ref\nKat,873685531\n");
let tin = write("tin.csv", "member,ref\nKat,830835531\n");
let probes = async |c: &Client| {
let a = refused_everywhere(c, &b, &with_tin, "with.csv", TIN_PROBE).await;
let z = refused_everywhere(c, &b, &without, "without.csv", TIN_PROBE).await;
assert_eq!(a, z, "the refusal tells whether the vault's TIN is there");
refused_everywhere(c, &b, &payee, "payee.csv", TIN_PROBE).await;
a
};
let open = probes(&c).await;
refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).await;
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
drop(c);
drop(server);
// The vault locked: the same refusals, word for word.
std::fs::remove_file(b.dir.parent().unwrap().join("config/vault.key")).unwrap();
let (_server, url) = serve(&b);
let c = connect(&url).await;
assert_eq!(
probes(&c).await,
open,
"the refusal tells whether the vault is open"
);
}