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

+9
View File
@@ -25,16 +25,25 @@ ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies]
base64 = "0.22"
chrono = "0.4"
# Every test that drives the books server's tools: the CPA package they
# open, the outputSchemas they check against, the random cases, and the
# database read_only_tools_change_nothing compares by its rows.
jsonschema = { version = "0.58", default-features = false }
libc = "0.2"
proptest = "1"
reqwest = "0.13"
rusqlite = { version = "0.32", features = ["bundled"] }
rust_decimal = "1"
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" }
ledger-package = { path = "../ledger-package" }
ledger-vault = { path = "../ledger-vault" }
serde_json = "1"
sha2 = "0.10"
tempfile = "3"
zip = "9.0.1"
[lints]
workspace = true
+12 -28
View File
@@ -1,9 +1,10 @@
//! `wanderbooks`: the one executable (plan D-22). Its modes are the
//! viewer (`gui`), the books server (`server`, and `dev-stdio` over stdio),
//! the host and the agents' bridge (`host`, `mcp`), and the owner's
//! commands (`setup`, `books`, `connect`, `token`, `passphrase`, `serve`,
//! `close`, `doctor`, `upload`, `download`). A build without the `gui`
//! feature (the server-only build for another machine) has no viewer.
//! viewer (`gui`), the books server (`server`: the one way books are
//! served, D-44), the host and the agents' bridge (`host`, `mcp`), and the
//! owner's commands (`setup`, `books`, `connect`, `token`, `passphrase`,
//! `serve`, `close`, `doctor`, `upload`, `download`). A build without the
//! `gui` feature (the server-only build for another machine) has no
//! viewer.
//!
//! From nothing to the first post: `wanderbooks setup`, `wanderbooks books
//! create ID`, `wanderbooks connect claude --book ID` (then talk to the
@@ -14,7 +15,7 @@ use std::path::PathBuf;
fn print_usage() {
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID | --data-dir PATH ...]");
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID]");
eprintln!(
" wanderbooks books list | create ID | adopt DATA-DIR --id ID | restore BACKUP | back-up ID | upgrade ID | put-aside ID DATA-DIR [PATH...]"
);
@@ -27,7 +28,6 @@ fn print_usage() {
eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" wanderbooks serve | close | doctor");
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
eprintln!();
eprintln!(" setup make the books' private folder (only your account can open it)");
eprintln!(" gui open the live, read-only view of the books (a book of the data root)");
@@ -43,7 +43,6 @@ fn print_usage() {
eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
}
/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone
@@ -135,8 +134,8 @@ fn run_gui(args: Vec<String>) -> i32 {
.cloned()
};
// Only the viewer's own flags for books of the data root, each with
// its value: anything else (help, a mistake, an older books dir,
// the demo) holds nothing.
// its value: anything else (help, a mistake, the demo) holds
// nothing.
let mut known = true;
let mut rest = args.iter();
while let Some(a) = rest.next() {
@@ -300,20 +299,6 @@ fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 {
0
}
/// `wanderbooks dev-stdio`: the books server over stdio, in this process.
fn run_dev_stdio(args: Vec<String>) -> i32 {
match ledger_mcp::run("wanderbooks dev-stdio", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// `wanderbooks server`: the books server for the data root, on its
/// local sockets (plan 3.9). The host starts it.
#[cfg(unix)]
@@ -420,7 +405,6 @@ fn main() {
let mut all = words.clone().into_iter();
match all.next().as_deref() {
Some("gui") => std::process::exit(run_gui(all.collect())),
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
Some("setup") => std::process::exit(run_setup(all.collect())),
Some("server") => std::process::exit(run_server(all.collect())),
Some(
@@ -450,8 +434,8 @@ fn main() {
.find(|w| !w.starts_with('-'))
{
Some(
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "mcp" | "serve" | "connect"
| "close" | "books" | "doctor" | "token" | "passphrase" | "upload" | "download"),
mode @ ("gui" | "setup" | "server" | "host" | "mcp" | "serve" | "connect" | "close"
| "books" | "doctor" | "token" | "passphrase" | "upload" | "download"),
) => {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
std::process::exit(2);
@@ -482,7 +466,7 @@ mod gui_args_tests {
fn flags_and_anything_else_pass_as_they_are() {
for args in [
v(&["--demo"]),
v(&["--data-dir", "x"]),
v(&["--root", "x"]),
v(&["a", "b"]),
v(&["--help"]),
] {
+949
View File
@@ -0,0 +1,949 @@
//! How every test here reaches books: the one way production serves them
//! (plan D-44). Each test file says `mod common;` and uses what it needs.
//!
//! ```text
//! GOAL: a test drives books exactly as an agent reaches them: a
//! `wanderbooks server` on a data root of the test's own, spoken to
//! over the root's MCP socket with a bearer token, with the headers
//! the agents' bridge sends; a file the books make comes back by
//! its download, never inline.
//! SOURCE: plan/served.md D-44 (one way to serve books), 3.1 (the
//! server's sockets, the admin socket), 3.3 (tokens), 4.7
//! (uploads and downloads); ledger-mcp local.rs INVARIANTS 8 and
//! 9; ledger-host bridge.rs (what it sends upstream).
//! INVARIANTS:
//! 1. Every home is a fresh temp dir (`wb-` prefixed, so a sweep of
//! the test temp dir finds it), made by `wanderbooks setup --home`;
//! its server runs with an empty environment but PATH, and HOME and
//! XDG_CONFIG_HOME inside the home, on that home's root alone: never
//! this account's root, keyring, or sockets.
//! 2. A server is ended by its own child's PID, never by a search.
//! 3. Settings a test gives as a settings file's text go into the books
//! as an agent puts them there: the changes ledger-config's import
//! reads from the text, made with books.set_setting.
//! 4. The harness answers nothing itself: every message a test is
//! given is one the server sent.
//! ```
#![allow(dead_code)]
use ledger_mcp::admin::{self, Request, Response};
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// The id of the books [`Books`] makes.
pub const BOOK: &str = "acme";
/// How long one request may take before the server counts as hung: long
/// enough for a first answer while other test binaries contend for the
/// disk (every journal append is synced).
pub const TIMEOUT: Duration = Duration::from_secs(180);
/// The protocol version the tests speak.
pub const PROTOCOL: &str = "2025-06-18";
/// A home with a data root made by `wanderbooks setup` (INVARIANT 1).
pub struct Home {
pub dir: tempfile::TempDir,
/// A token for each book, made on first use (all scopes).
tokens: Mutex<HashMap<String, String>>,
}
impl Home {
pub fn new(tag: &str) -> Self {
let dir = tempfile::Builder::new()
.prefix(&format!("wb-{tag}-"))
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", dir.path())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self {
dir,
tokens: Default::default(),
}
}
pub fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks")
}
pub fn run(&self) -> PathBuf {
self.root().join("run")
}
/// The folder of the books `id` in the root.
pub fn book_dir(&self, id: &str) -> PathBuf {
self.root().join("books").join(id)
}
pub fn admin(&self, request: Request) -> Response {
admin::call(&self.run().join("admin.sock"), request).unwrap()
}
/// The token tests use for `book`, made through the admin socket the
/// first time (every scope).
pub fn token(&self, book: &str) -> String {
let mut tokens = self.tokens.lock().unwrap();
if let Some(t) = tokens.get(book) {
return t.clone();
}
let text = self.token_with(book, "test", &["read", "write", "post", "admin"]);
tokens.insert(book.to_string(), text.clone());
text
}
/// A new token named `name` for `book` with these scopes (replacing
/// one of that name).
pub fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String {
match self.admin(Request::TokenCreate {
book: book.into(),
name: name.into(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
replace: true,
}) {
Response::TokenCreated { text, .. } => text.0,
other => panic!("{other:?}"),
}
}
/// The text of every answer must not name the root (local.rs
/// INVARIANT 7).
pub fn assert_hidden(&self, text: &str) {
let root = self.root().display().to_string();
assert!(!text.contains(&root), "names the data root: {text}");
assert!(
!text.contains(&self.dir.path().display().to_string()),
"names the home: {text}"
);
}
pub fn create(&self, id: &str, currency: &str) {
match self.admin(Request::Create {
id: id.into(),
currency: currency.into(),
}) {
Response::Created { id: made, problem } => {
assert_eq!(made, id);
assert_eq!(problem, None);
}
other => panic!("{other:?}"),
}
}
/// Adopt the old data dir `source` as the books `id` (plan 5.5), with
/// its settings file's text: previewed, then confirmed at the head the
/// preview read, as the owner's `wanderbooks books adopt` does.
pub fn adopt(&self, id: &str, source: &Path, settings_toml: Option<&str>) {
let request = |confirmed: bool, expect_head: Option<(u64, String)>| Request::Adopt {
id: id.into(),
source: source.to_path_buf(),
settings_toml: settings_toml.map(str::to_string),
config_json: None,
vault_dir: None,
vault_key: None,
confirmed,
expect_head,
skip_unknown: true,
};
let head = match self.admin(request(false, None)) {
Response::Preview(p) => p.source_head,
other => panic!("adopt preview: {other:?}"),
};
match self.admin(request(true, Some(head))) {
Response::Adopted(_) => {}
other => panic!("adopt: {other:?}"),
}
}
}
/// A running `wanderbooks server`, ended (by its own PID) when dropped.
pub struct Server {
pub child: Child,
}
impl Server {
/// Start one on `home`'s root and wait for its ready line.
pub fn start(home: &Home) -> Self {
Self::start_in(home, home.dir.path(), &[])
}
/// [`Server::start`], in the folder `cwd`, with `env` beside the
/// home's own (INVARIANT 1).
pub fn start_in(home: &Home, cwd: &Path, env: &[(&str, &Path)]) -> Self {
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(home.dir.path().join("server.log"))
.unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("server")
.arg("--root")
.arg(home.root())
.current_dir(cwd)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(log)
.spawn()
.unwrap();
let mut line = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
assert_eq!(line, "ready\n", "the server didn't say ready");
Self { child }
}
/// Close it as the host does (stdin ends) and wait for it to exit.
pub fn close(mut self) -> std::process::ExitStatus {
drop(self.child.stdin.take());
let deadline = Instant::now() + Duration::from_secs(60);
loop {
if let Some(status) = self.child.try_wait().unwrap() {
return status;
}
assert!(Instant::now() < deadline, "the server didn't close");
std::thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// An HTTP client for `home`'s MCP socket, with the socket's short path
/// (good while it lives).
pub fn mcp_http(home: &Home) -> reqwest::Client {
mcp_http_builder(home).build().unwrap()
}
/// [`mcp_http`]'s builder.
pub fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
mcp_http_builder_at(&home.run())
}
/// [`mcp_http_builder`], for the root's `run/` folder `run`.
pub fn mcp_http_builder_at(run: &Path) -> reqwest::ClientBuilder {
// The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(run, "mcp.sock").unwrap();
let path = short.path().to_path_buf();
std::mem::forget(short);
reqwest::Client::builder().unix_socket(path)
}
/// The settings a settings file's text gives, as books.set_setting changes
/// (INVARIANT 3): what ledger-config's import reads from it for books
/// that set nothing yet.
pub fn settings_changes(toml: &str, config_json: Option<&str>, currency: &str) -> Value {
let import = ledger_config::codec::import(
toml,
config_json,
&ledger_core::settings::Settings::default(),
currency,
)
.unwrap_or_else(|e| panic!("the test's settings don't read: {e}\n{toml}"));
assert!(
import.unknown.is_empty(),
"the test's settings hold entries the books don't know: {:?}",
import.unknown
);
Value::Array(
import
.changes
.iter()
.map(|c| json!({"key": c.key, "value": c.value}))
.collect(),
)
}
/// What one HTTP exchange came back as, when it wasn't a JSON-RPC
/// message: refused at the door (a status and its text).
#[derive(Debug, Clone)]
pub struct Refusal {
pub status: u16,
pub body: String,
/// It carried local.rs's REFUSED_HEADER (not run).
pub not_run: bool,
}
/// One MCP session with one book, synchronous: what a test sends is
/// POSTed to `/b/{book}/mcp` as the bridge does, and every message the
/// server answers with is kept for [`Wire::recv`] (INVARIANT 4).
pub struct Wire {
http: reqwest::Client,
book: String,
token: Option<String>,
session: Option<String>,
version: Option<String>,
told: VecDeque<Value>,
/// The last exchange that came back as no message at all.
pub refused: Option<Refusal>,
/// The HTTP status of the last exchange.
pub status: u16,
next_id: u64,
/// Last: the client's connections are dropped inside it.
rt: tokio::runtime::Runtime,
}
impl Wire {
/// A session with `book` (not yet initialized), with its token.
pub fn new(home: &Home, book: &str) -> Self {
let token = home.token(book);
Self::with_token(home, book, Some(token))
}
/// [`Wire::new`], with this token (or none).
pub fn with_token(home: &Home, book: &str, token: Option<String>) -> Self {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let http = mcp_http_builder(home).timeout(TIMEOUT).build().unwrap();
Self {
rt,
http,
book: book.to_string(),
token,
session: None,
version: None,
told: VecDeque::new(),
refused: None,
status: 0,
next_id: 1_000_000,
}
}
/// The token this session sends.
pub fn token(&self) -> Option<&str> {
self.token.as_deref()
}
/// Initialize and send `notifications/initialized`: the initialize
/// answer.
pub fn initialize(&mut self, client: &str) -> Value {
let init = self.rpc(
"initialize",
json!({"protocolVersion": PROTOCOL, "capabilities": {},
"clientInfo": {"name": client, "version": "0.1.0"}}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
self.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
init
}
/// Send a request with an id of the wire's own and take its answer.
pub fn rpc(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let resp = self
.recv(TIMEOUT)
.unwrap_or_else(|| panic!("no answer to {method} (id {id}): {:?}", self.refused));
assert_eq!(resp["id"], json!(id), "answer id mismatch: {resp}");
resp
}
/// Call a tool: the `tools/call` answer.
pub fn call(&mut self, tool: &str, args: Value) -> Value {
self.rpc("tools/call", json!({"name": tool, "arguments": args}))
}
/// Send one message.
pub fn send(&mut self, message: &Value) {
self.send_line(&message.to_string());
}
/// Send `text` as one message's body, as it is (a test's own bytes,
/// malformed ones included).
pub fn send_line(&mut self, text: &str) {
let parsed: Option<Value> = serde_json::from_str(text).ok();
let method = parsed
.as_ref()
.and_then(|m| m.get("method"))
.and_then(Value::as_str)
.map(str::to_string);
let mut req = self
.http
.post(format!("http://localhost/b/{}/mcp", self.book))
.header("content-type", "application/json")
.header("accept", "application/json, text/event-stream")
.body(text.to_string());
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
if let Some(s) = &self.session {
req = req.header("mcp-session-id", s);
}
if let Some(v) = &self.version {
req = req.header("mcp-protocol-version", v);
}
let (status, headers, body) = self.rt.block_on(async {
let r = req.send().await.expect("the server's socket answers");
let status = r.status().as_u16();
let headers = r.headers().clone();
let body = r.text().await.unwrap_or_default();
(status, headers, body)
});
if method.as_deref() == Some("initialize")
&& let Some(s) = headers.get("mcp-session-id").and_then(|v| v.to_str().ok())
{
self.session = Some(s.to_string());
}
let is_stream = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("text/event-stream"));
// A message the server sends is JSON, or the test fails here: a
// stream's data line (an empty one is the stream's priming), or a
// body it says is JSON. Anything else is a refusal, kept below.
let is_json = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|t| t.starts_with("application/json"));
let messages: Vec<Value> = if is_stream {
body.lines()
.filter_map(|l| l.strip_prefix("data:"))
.map(str::trim)
.filter(|d| !d.is_empty())
.map(|d| {
serde_json::from_str(d)
.unwrap_or_else(|e| panic!("the server streamed no JSON ({e}): {d}"))
})
.collect()
} else if is_json && !body.trim().is_empty() {
let v: Value = serde_json::from_str(body.trim())
.unwrap_or_else(|e| panic!("the server's JSON isn't ({e}): {body}"));
[v].into_iter()
.filter(|v| v.get("jsonrpc").is_some() || v.is_array())
.collect()
} else {
Vec::new()
};
self.status = status;
if messages.is_empty() && status != 202 {
self.refused = Some(Refusal {
status,
body: body.clone(),
not_run: headers.contains_key(ledger_mcp::local::REFUSED_HEADER),
});
} else {
self.refused = None;
}
for m in messages {
if method.as_deref() == Some("initialize")
&& let Some(v) = m.pointer("/result/protocolVersion").and_then(Value::as_str)
{
self.version = Some(v.to_string());
}
// Notifications the server sends on its own are not answers.
if m.get("id").is_none() && m.get("method").is_some() {
continue;
}
self.told.push_back(m);
}
}
/// The next message the server answered with, if any. An answer
/// comes back with its request, so nothing is waited for.
pub fn recv(&mut self, _wait: Duration) -> Option<Value> {
self.told.pop_front()
}
/// GET `path` (under `/b/{book}/`) with this session's token: the
/// status and the bytes.
pub fn get(&mut self, path: &str) -> (u16, Vec<u8>) {
let mut req = self
.http
.get(format!("http://localhost/b/{}/{path}", self.book));
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
self.rt.block_on(async {
let r = req.send().await.expect("the server's socket answers");
let status = r.status().as_u16();
(status, r.bytes().await.unwrap_or_default().to_vec())
})
}
/// The bytes of a file the books handed over (a tool's HandedFile):
/// fetched by its download with this session's token, and checked
/// against its size and SHA-256.
pub fn fetch(&mut self, file: &Value) -> Vec<u8> {
let command = file["download"]
.as_str()
.unwrap_or_else(|| panic!("no download in {file}"));
let id = command
.split(' ')
.find(|w| w.starts_with("ex_"))
.unwrap_or_else(|| panic!("no export id in {command}"));
let (status, bytes) = self.get(&format!("x/{id}"));
assert_eq!(
status,
200,
"fetching {id}: {}",
String::from_utf8_lossy(&bytes)
);
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes)),
"{file}"
);
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
bytes
}
/// End the session (DELETE, as the bridge does when its agent goes).
pub fn end(&mut self) {
let Some(s) = self.session.take() else { return };
let mut req = self
.http
.delete(format!("http://localhost/b/{}/mcp", self.book))
.header("mcp-session-id", s);
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
let _ = self.rt.block_on(async { req.send().await });
}
}
/// A test's own books: a home, its server, and the books [`BOOK`] made in
/// it, with the settings the test gives.
pub struct Books {
pub home: Home,
server: std::cell::RefCell<Option<Server>>,
pub id: String,
pub currency: String,
}
impl Books {
/// New books in a new home, kept in USD, with no settings set.
pub fn new(tag: &str) -> Self {
Self::with(tag, "USD", None)
}
/// New books in `currency`, with the settings `settings_toml` (a
/// settings file's text) gives, set as an agent sets them.
pub fn with(tag: &str, currency: &str, settings_toml: Option<&str>) -> Self {
let home = Home::new(tag);
let server = Server::start(&home);
home.create(BOOK, currency);
let books = Self {
home,
server: std::cell::RefCell::new(Some(server)),
id: BOOK.to_string(),
currency: currency.to_string(),
};
if let Some(toml) = settings_toml {
books.settle(toml);
}
books
}
/// Books an earlier wanderbooks left in the data root: the files of
/// `fixture` (a book's folder: `journal.jsonl` and what else it holds)
/// put in the root before the server opens them, private as the server
/// keeps a book.
pub fn placed(tag: &str, fixture: &Path) -> Self {
let home = Home::new(tag);
copy_private(fixture, &home.book_dir(BOOK));
let server = Server::start(&home);
Self {
home,
server: std::cell::RefCell::new(Some(server)),
id: BOOK.to_string(),
currency: "USD".to_string(),
}
}
/// Books adopted from the old data dir `source`, with its settings
/// file's text (plan 5.5).
pub fn adopted(tag: &str, source: &Path, settings_toml: Option<&str>) -> Self {
let home = Home::new(tag);
let server = Server::start(&home);
home.adopt(BOOK, source, settings_toml);
Self {
home,
server: std::cell::RefCell::new(Some(server)),
id: BOOK.to_string(),
currency: "USD".to_string(),
}
}
/// The books' folder in the root.
pub fn dir(&self) -> PathBuf {
self.home.book_dir(&self.id)
}
/// The books' journal, as it is on disk.
pub fn journal(&self) -> Vec<u8> {
std::fs::read(self.dir().join("journal.jsonl")).unwrap_or_default()
}
/// A new session with the books (not yet initialized).
pub fn wire(&self) -> Wire {
Wire::new(&self.home, &self.id)
}
/// A new, initialized session with the books.
pub fn session(&self, client: &str) -> Wire {
let mut w = self.wire();
w.initialize(client);
w
}
/// Set the settings a settings file's text gives (INVARIANT 3).
pub fn settle(&self, toml: &str) {
self.settle_with(toml, None);
}
/// [`Books::settle`], with a receipt policy file's text too (its
/// `receipt_policy` wins over the settings file's receipt settings).
pub fn settle_with(&self, toml: &str, config_json: Option<&str>) {
if let Err(e) = self.try_settle(toml, config_json) {
panic!("the test's settings were refused: {e}\n{toml}");
}
}
/// [`Books::settle_with`]: Err with the refusal's text.
pub fn try_settle(&self, toml: &str, config_json: Option<&str>) -> Result<(), String> {
settle_through(&self.home, &self.id, &self.currency, toml, config_json)
}
/// Close the server (as the host does) and wait for it to exit.
pub fn stop(&self) {
if let Some(s) = self.server.borrow_mut().take() {
assert!(s.close().success(), "the server didn't close cleanly");
}
}
/// Start the server again (after [`Books::stop`]).
pub fn start(&self) {
let mut server = self.server.borrow_mut();
assert!(server.is_none(), "the server is running");
*server = Some(Server::start(&self.home));
}
/// Close the server and start it again on the same root.
pub fn restart(&self) {
self.stop();
self.start();
}
/// The running server's process id.
pub fn server_pid(&self) -> Option<u32> {
self.server.borrow().as_ref().map(|s| s.child.id())
}
/// The server log (its stderr).
pub fn server_log(&self) -> String {
std::fs::read_to_string(self.home.dir.path().join("server.log")).unwrap_or_default()
}
}
/// Every file under `dir`, by path relative to it, with the SHA-256 of its
/// contents, leaving out what a call writes by design whatever the call:
/// the access log (every call is recorded), the viewer's side channel
/// (`ops/`), the books' own backup (`archive.git/`), the files handed over
/// and on their way in (`exports/`, `uploads/`), and SQLite's -wal and
/// -shm files (it moves pages between them on its own).
pub fn book_files(dir: &Path) -> std::collections::BTreeMap<PathBuf, String> {
fn walk(base: &Path, dir: &Path, out: &mut std::collections::BTreeMap<PathBuf, String>) {
let Ok(rd) = std::fs::read_dir(dir) else {
return;
};
for e in rd.flatten() {
let p = e.path();
let rel = p.strip_prefix(base).unwrap().to_path_buf();
let name = rel.to_string_lossy().into_owned();
let first = rel
.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.unwrap_or_default();
if ["ops", "archive.git", "exports", "uploads"].contains(&first.as_str())
|| name == "access.jsonl"
|| name.ends_with("-wal")
|| name.ends_with("-shm")
{
continue;
}
if p.is_dir() {
walk(base, &p, out);
} else if let Ok(bytes) = std::fs::read(&p) {
out.insert(rel, ledger_store::journal::sha256_hex(&bytes));
}
}
}
let mut out = std::collections::BTreeMap::new();
walk(dir, dir, &mut out);
out
}
/// Copy the tree `from` into `to`, private as the server keeps a book
/// (folders 0700, files 0600).
pub fn copy_private(from: &Path, to: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::create_dir_all(to).unwrap();
std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o700)).unwrap();
for entry in std::fs::read_dir(from).unwrap().flatten() {
let (src, dst) = (entry.path(), to.join(entry.file_name()));
if entry.file_type().unwrap().is_dir() {
copy_private(&src, &dst);
} else {
std::fs::copy(&src, &dst).unwrap();
std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600)).unwrap();
}
}
}
/// A test's place: a home (whose folder holds the test's own files, its
/// settings file among them) and the books [`BOOK`] in its root, served
/// by a server [`Place::start`] starts, and starts again on each next
/// call, as a new server process reads the same books afresh. A test may
/// write books into [`Place::books_data`] before the first start (books
/// an earlier version left in the root, a journal written by hand); the
/// server opens them as it opens any books in its root.
pub struct Place {
pub home: Home,
server: std::cell::RefCell<Option<Server>>,
/// The settings file's text set in the books, once set.
settled: std::cell::RefCell<Option<String>>,
/// The currency books made by the first start are kept in.
pub currency: String,
/// The folder the server runs in, when not the home.
cwd: std::cell::RefCell<Option<PathBuf>>,
}
impl std::ops::Deref for Place {
type Target = Path;
fn deref(&self) -> &Path {
self.home.dir.path()
}
}
impl AsRef<Path> for Place {
fn as_ref(&self) -> &Path {
self.home.dir.path()
}
}
impl Place {
pub fn new(tag: &str) -> Self {
Self {
home: Home::new(tag),
server: Default::default(),
settled: Default::default(),
currency: "USD".to_string(),
cwd: Default::default(),
}
}
/// Run the server in `cwd` from its next start (where a relative path
/// would land).
pub fn run_in(&self, cwd: &Path) {
*self.cwd.borrow_mut() = Some(cwd.to_path_buf());
}
/// The books' folder in the root (where a test may write books before
/// the first start).
pub fn books_data(&self) -> PathBuf {
self.home.book_dir(BOOK)
}
/// Start the server (closing the one running first): books made if
/// the root holds none yet, the settings `settings_toml` gives set the
/// first time; and a new session with them (not yet initialized).
pub fn start(&self, settings_toml: Option<&str>) -> Wire {
self.stop();
let dir = self.books_data();
let fresh = !dir.join("journal.jsonl").exists();
if dir.exists() {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
}
let cwd = self.cwd.borrow().clone();
let cwd = cwd.as_deref().unwrap_or(self.home.dir.path());
*self.server.borrow_mut() = Some(Server::start_in(&self.home, cwd, &[]));
if fresh {
self.home.create(BOOK, &self.currency);
}
if let Some(toml) = settings_toml {
let mut settled = self.settled.borrow_mut();
match settled.as_deref() {
Some(was) => assert_eq!(
was, toml,
"the test changed its settings file between starts: books keep their settings, change them with books.set_setting"
),
None => {
settle_through(&self.home, BOOK, &self.currency, toml, None).unwrap_or_else(
|e| panic!("the test's settings were refused: {e}\n{toml}"),
);
*settled = Some(toml.to_string());
}
}
}
Wire::new(&self.home, BOOK)
}
/// Close the server, if one runs, and wait for it to exit.
pub fn stop(&self) {
if let Some(s) = self.server.borrow_mut().take() {
assert!(s.close().success(), "the server didn't close cleanly");
}
}
/// The running server's process id.
pub fn server_pid(&self) -> Option<u32> {
self.server.borrow().as_ref().map(|s| s.child.id())
}
}
/// Set the settings `toml` (and `config_json`) give in `book`, through a
/// session of its own (INVARIANT 3): Err with the refusal.
pub fn settle_through(
home: &Home,
book: &str,
currency: &str,
toml: &str,
config_json: Option<&str>,
) -> Result<(), String> {
let changes = settings_changes(toml, config_json, currency);
if changes.as_array().is_some_and(Vec::is_empty) {
return Ok(());
}
let mut w = Wire::new(home, book);
w.initialize("settings");
let r = w.call(
"books.set_setting",
json!({"changes": changes, "decided_by": "Test Owner"}),
);
w.end();
if r["result"]["isError"] == json!(true) || r.get("error").is_some() {
return Err(r.to_string());
}
Ok(())
}
/// An rmcp client session with a book, for async tests.
pub type Client =
rmcp::service::RunningService<rmcp::service::RoleClient, rmcp::model::ClientConfig>;
/// An HTTP client for `home`'s MCP socket that sends `book`'s token on
/// every request.
pub fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", home.token(book)).parse().unwrap(),
);
mcp_http_builder(home)
.default_headers(headers)
.build()
.unwrap()
}
/// An initialized rmcp client session with `book`, with its token.
pub async fn connect(home: &Home, book: &str) -> Result<Client, String> {
let token = home.token(book);
connect_with(home, book, Some(token)).await
}
/// [`connect`], with this token (or none).
pub async fn connect_with(
home: &Home,
book: &str,
token: Option<String>,
) -> Result<Client, String> {
connect_at(&home.run(), book, token).await
}
/// [`connect_with`], to the server whose `run/` folder is `run` (for a
/// task that holds no [`Home`]).
pub async fn connect_at(run: &Path, book: &str, token: Option<String>) -> Result<Client, String> {
use rmcp::ServiceExt;
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
let http = mcp_http_builder_at(run).build().unwrap();
let mut config =
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"));
if let Some(token) = token {
config = config.auth_header(token);
}
let transport = StreamableHttpClientTransport::with_client(http, config);
rmcp::model::ClientConfig::default()
.serve(transport)
.await
.map_err(|e| e.to_string())
}
/// Call a tool: Ok(structured result) or Err(the error text).
pub async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
use rmcp::model::CallToolRequestParams;
let args = args.as_object().cloned().unwrap_or_default();
// A client whose server went away may wait forever: a call that
// takes this long counts as refused.
let r = tokio::time::timeout(
Duration::from_secs(20),
c.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)),
)
.await
.map_err(|_| "no answer".to_string())?
.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_or(Value::String(text))))
}
}
/// Fetch the export a tool handed over in `file` (its `download`
/// command's id) from `book` with `token`: its bytes, checked against its
/// size and SHA-256.
pub async fn fetch_export(home: &Home, book: &str, token: &str, file: &Value) -> Vec<u8> {
let command = file["download"]
.as_str()
.unwrap_or_else(|| panic!("{file}"));
let id = command
.split(' ')
.find(|w| w.starts_with("ex_"))
.unwrap_or_else(|| panic!("{command}"));
let r = mcp_http(home)
.get(format!("http://localhost/b/{book}/x/{id}"))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200, "{:?}", r.headers());
let bytes = r.bytes().await.unwrap().to_vec();
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes))
);
assert_eq!(file["size_bytes"], json!(bytes.len()));
bytes
}
File diff suppressed because it is too large. Load diff
+199
View File
@@ -0,0 +1,199 @@
//! Concurrency: several agents at once against one book's store.
//!
//! The books server gives every session its own service over the book's
//! one shared, locked store (ledger-mcp local.rs INVARIANT 4). 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 common::{Client, call};
use serde_json::json;
mod common;
const CLIENTS: usize = 6;
const ENTRIES: usize = 12;
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 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() {
// Made on a thread of its own: the settings go in through a session
// with a runtime of its own (common.rs), never inside the test's.
let books = std::thread::spawn(|| {
common::Books::with(
"http-concurrency",
"USD",
Some("[books]\nreceipt_threshold = \"0.00\"\n"),
)
})
.join()
.unwrap();
let (run, token) = (books.home.run(), books.home.token(common::BOOK));
let connect = |run: std::path::PathBuf, token: String| async move {
common::connect_at(&run, common::BOOK, Some(token))
.await
.unwrap()
};
let setup = connect(run.clone(), token.clone()).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 (run, token) = (run.clone(), token.clone());
let contested = contested.clone();
tasks.push(tokio::spawn(async move {
let c = connect(run, token).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;
// A restart replays the same books.
books.restart();
let c = connect(run.clone(), token.clone()).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;
}
+9 -263
View File
@@ -4,12 +4,8 @@
//! finishes, every writer lock is released").
#![cfg(unix)]
use common::{Client, Home, Server, call, connect, connect_with, mcp_http, mcp_http_for};
use ledger_mcp::admin::{self, Request, Response};
use rmcp::ServiceExt;
use rmcp::model::{CallToolRequestParams, ClientConfig};
use rmcp::service::{RoleClient, RunningService};
use rmcp::transport::StreamableHttpClientTransport;
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use serde_json::{Value, json};
use std::io::{BufRead, BufReader};
use std::os::unix::fs::PermissionsExt;
@@ -17,190 +13,7 @@ use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
type Client = RunningService<RoleClient, ClientConfig>;
/// A home with a data root made by `wanderbooks setup`.
struct Home {
dir: tempfile::TempDir,
/// A token for each book, made on first use (all scopes).
tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
}
impl Home {
fn new(tag: &str) -> Self {
let dir = tempfile::Builder::new()
.prefix(&format!("wb-srv-{tag}-"))
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self {
dir,
tokens: Default::default(),
}
}
/// The token tests use for `book`, made through the admin socket the
/// first time.
fn token(&self, book: &str) -> String {
let mut tokens = self.tokens.lock().unwrap();
if let Some(t) = tokens.get(book) {
return t.clone();
}
let text = match self.admin(Request::TokenCreate {
book: book.into(),
name: "test".into(),
scopes: ["read", "write", "post", "admin"]
.map(String::from)
.to_vec(),
replace: true,
}) {
Response::TokenCreated { text, .. } => text.0,
other => panic!("{other:?}"),
};
tokens.insert(book.to_string(), text.clone());
text
}
fn root(&self) -> PathBuf {
self.dir.path().join(".local/share/wanderbooks")
}
fn run(&self) -> PathBuf {
self.root().join("run")
}
fn admin(&self, request: Request) -> Response {
admin::call(&self.run().join("admin.sock"), request).unwrap()
}
/// The text of every answer must not name the root (local.rs
/// INVARIANT 7).
fn assert_hidden(&self, text: &str) {
let root = self.root().display().to_string();
assert!(!text.contains(&root), "names the data root: {text}");
assert!(
!text.contains(&self.dir.path().display().to_string()),
"names the home: {text}"
);
}
fn create(&self, id: &str, currency: &str) {
match self.admin(Request::Create {
id: id.into(),
currency: currency.into(),
}) {
Response::Created { id: made, problem } => {
assert_eq!(made, id);
assert_eq!(problem, None);
}
other => panic!("{other:?}"),
}
}
}
/// A running `wanderbooks server`, ended (kill) when dropped.
struct Server {
child: Child,
}
impl Server {
/// Start one and wait for its ready line.
fn start(home: &Home) -> Self {
// Its environment is the test home's alone: the default import
// folder and vault key are there, never the real ones.
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("server")
.arg("--root")
.arg(home.root())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut line = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut line)
.unwrap();
assert_eq!(line, "ready\n", "the server didn't say ready");
Self { child }
}
/// Close it as the host does (stdin ends) and wait for it to exit.
fn close(mut self) -> std::process::ExitStatus {
drop(self.child.stdin.take());
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = self.child.try_wait().unwrap() {
return status;
}
assert!(Instant::now() < deadline, "the server didn't close");
std::thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
/// An HTTP client for the server's MCP socket.
fn mcp_http(home: &Home) -> reqwest::Client {
mcp_http_builder(home).build().unwrap()
}
fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
// The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
let path = short.path().to_path_buf();
std::mem::forget(short);
reqwest::Client::builder().unix_socket(path)
}
/// An HTTP client for the server's MCP socket that sends `book`'s token
/// on every request.
fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", home.token(book)).parse().unwrap(),
);
mcp_http_builder(home)
.default_headers(headers)
.build()
.unwrap()
}
async fn connect(home: &Home, book: &str) -> Result<Client, String> {
let token = home.token(book);
connect_with(home, book, Some(token)).await
}
/// [`connect`], with this token (or none).
async fn connect_with(home: &Home, book: &str, token: Option<String>) -> Result<Client, String> {
let http = mcp_http(home);
let mut config =
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"));
if let Some(token) = token {
config = config.auth_header(token);
}
let transport = StreamableHttpClientTransport::with_client(http, config);
ClientConfig::default()
.serve(transport)
.await
.map_err(|e| e.to_string())
}
mod common;
/// Name cash 1000 the books' cash account (adding it if the chart lacks
/// it): the 1099 worksheet counts payments out of the books' cash
@@ -221,31 +34,6 @@ async fn name_cash(c: &Client) {
.unwrap();
}
/// Call a tool: Ok(structured result) or Err(the error text).
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
let args = args.as_object().cloned().unwrap_or_default();
// A client whose server went away may wait forever: a call that
// takes this long counts as refused.
let r = tokio::time::timeout(
Duration::from_secs(20),
c.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)),
)
.await
.map_err(|_| "no answer".to_string())?
.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_or(Value::String(text))))
}
}
async fn chart(c: &Client) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
@@ -1166,25 +954,6 @@ fn adoption_checks_the_vault_key_and_the_settings_file() {
// P2 slice 4: tokens, scopes and the access log (local.rs INVARIANT 8,
// ledger-mcp access.rs, scopes.rs; ledger-store auth.rs, access.rs).
impl Home {
/// A token for `book` named `name` with `scopes`, made (or remade).
fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String {
match self.admin(Request::TokenCreate {
book: book.into(),
name: name.into(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
replace: true,
}) {
Response::TokenCreated { text, .. } => text.0,
other => panic!("{other:?}"),
}
}
fn book_dir(&self, book: &str) -> PathBuf {
self.root().join("books").join(book)
}
}
/// A raw initialize on `book`'s route with `bearer` (if any): its status,
/// its refusal header, its session id and its body.
async fn raw_initialize(
@@ -2523,7 +2292,10 @@ async fn a_w9_and_a_file_holding_a_tin_go_by_upload_only_where_they_belong() {
.await
.unwrap();
assert_eq!(got["file"]["file_name"], json!("payee-w9.pdf"));
assert_eq!(fetch_export(&home, &token, &got["file"]).await, w9);
assert_eq!(
common::fetch_export(&home, "acme", &token, &got["file"]).await,
w9
);
// The W-9's upload is gone; the refused file's waits to lapse.
let left: Vec<String> = std::fs::read_dir(home.book_dir("acme").join("uploads"))
.unwrap()
@@ -2609,32 +2381,6 @@ async fn an_mcp_request_is_at_most_a_mebibyte() {
assert!(server.close().success());
}
/// Fetch the export a tool handed over in `file` (its `download`
/// command's id) with `token`: its bytes, checked against its sha256.
async fn fetch_export(home: &Home, token: &str, file: &Value) -> Vec<u8> {
let command = file["download"]
.as_str()
.unwrap_or_else(|| panic!("{file}"));
let id = command
.split(' ')
.find(|w| w.starts_with("ex_"))
.unwrap_or_else(|| panic!("{command}"));
let r = mcp_http(home)
.get(format!("http://localhost/b/acme/x/{id}"))
.bearer_auth(token)
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200, "{:?}", r.headers());
let bytes = r.bytes().await.unwrap().to_vec();
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes))
);
assert_eq!(file["size_bytes"], json!(bytes.len()));
bytes
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_file_the_books_make_is_fetched_by_its_own_token() {
let home = Home::new("exports");
@@ -2689,9 +2435,9 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
);
assert!(file["expires_at"].as_str().unwrap().ends_with('Z'));
// Fetched whole, as often as needed while it's kept.
let zip = fetch_export(&home, &token, file).await;
let zip = common::fetch_export(&home, "acme", &token, file).await;
assert!(zip.starts_with(b"PK"), "a zip");
assert_eq!(fetch_export(&home, &token, file).await, zip);
assert_eq!(common::fetch_export(&home, "acme", &token, file).await, zip);
let id = command.split(' ').find(|w| w.starts_with("ex_")).unwrap();
let get = |bearer: Option<String>, what: String| {
let http = mcp_http(&home);
@@ -2730,7 +2476,7 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
let w = call(&ca, "books.vendor_1099", json!({"year": 2026, "csv": true}))
.await
.unwrap();
let csv = fetch_export(&home, &admin, &w["csv"]).await;
let csv = common::fetch_export(&home, "acme", &admin, &w["csv"]).await;
assert!(csv.starts_with(b"vendor_id,name,"));
drop(ca);
// Each fetch is in the access log as an export, under its id.
File diff suppressed because it is too large. Load diff
+30 -159
View File
@@ -1,102 +1,18 @@
//! One executable (plan D-22): `wanderbooks` runs the books server
//! (`dev-stdio`) and the viewer (`gui`) as modes of itself, each with its
//! own flags, and the server-only build has no viewer.
//! (`server`) and the viewer (`gui`) as modes of itself, each with its own
//! flags, and the server-only build has no viewer.
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
#[cfg(all(feature = "gui", target_os = "linux"))]
mod common;
fn wanderbooks() -> Command {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
}
/// One JSON-RPC request over the child's stdio, its response parsed.
fn ask(
stdin: &mut std::process::ChildStdin,
stdout: &mut BufReader<std::process::ChildStdout>,
id: u64,
method: &str,
params: Value,
) -> Value {
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
writeln!(stdin, "{msg}").unwrap();
stdin.flush().unwrap();
let mut line = String::new();
stdout.read_line(&mut line).expect("read a response");
let resp: Value = serde_json::from_str(&line).expect("parse the response");
assert_eq!(resp["id"], id, "{resp}");
resp
}
#[test]
fn dev_stdio_is_the_books_server() {
let dir = tempfile::Builder::new()
.prefix("wb-one-exe-")
.tempdir()
.unwrap();
let data = dir.path().join("books").join("books-data");
let mut child = wanderbooks()
.arg("dev-stdio")
.arg("--data-dir")
.arg(&data)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn wanderbooks dev-stdio");
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let init = ask(
&mut stdin,
&mut stdout,
1,
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "one-executable", "version": "0"}}),
);
assert!(init["result"]["serverInfo"].is_object(), "{init}");
writeln!(
stdin,
"{}",
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.unwrap();
let tools = ask(&mut stdin, &mut stdout, 2, "tools/list", json!({}));
let names: Vec<&str> = tools["result"]["tools"]
.as_array()
.expect("a tool list")
.iter()
.filter_map(|t| t["name"].as_str())
.collect();
assert!(names.contains(&"books.start"), "{names:?}");
// The books it serves are real books: a fresh store was created.
let tb = ask(
&mut stdin,
&mut stdout,
3,
"tools/call",
json!({"name": "books.trial_balance", "arguments": {}}),
);
assert!(tb.get("error").is_none(), "{tb}");
assert!(data.join("journal.jsonl").is_file());
drop(stdin);
let status = child.wait().unwrap();
assert!(status.success(), "{status}");
}
#[test]
fn each_mode_names_itself_in_its_usage() {
let out = wanderbooks()
.args(["dev-stdio", "--help"])
.output()
.unwrap();
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("usage: wanderbooks dev-stdio [--data-dir PATH]"),
"{err}"
);
let out = wanderbooks().args(["server", "--help"]).output().unwrap();
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
@@ -107,11 +23,7 @@ fn each_mode_names_itself_in_its_usage() {
let out = wanderbooks().arg("--help").output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
for line in [
"wanderbooks gui [BOOK",
"wanderbooks dev-stdio",
"dev-stdio run the books server",
] {
for line in ["wanderbooks gui [BOOK", "wanderbooks mcp --book ID"] {
assert!(err.contains(line), "{line:?} missing from {err}");
}
}
@@ -131,9 +43,9 @@ fn the_viewer_names_itself_in_its_usage() {
#[test]
fn a_mode_refuses_what_it_does_not_know() {
let modes: &[&str] = if cfg!(feature = "gui") {
&["dev-stdio", "gui", "server"]
&["gui", "server"]
} else {
&["dev-stdio", "server"]
&["server"]
};
for &mode in modes {
let out = wanderbooks()
@@ -143,10 +55,7 @@ fn a_mode_refuses_what_it_does_not_know() {
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
}
// A mode's flags are its own: the CLI's `--force` means nothing there.
let out = wanderbooks()
.args(["dev-stdio", "--force"])
.output()
.unwrap();
let out = wanderbooks().args(["server", "--force"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
}
@@ -166,7 +75,7 @@ fn the_books_folder_scaffold_is_gone_and_says_so() {
#[test]
fn a_mode_comes_first() {
for mode in ["gui", "dev-stdio", "server"] {
for mode in ["gui", "server"] {
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
@@ -177,13 +86,10 @@ fn a_mode_comes_first() {
#[test]
fn a_server_that_cannot_start_says_why_and_exits_1() {
// A flag missing its value is an error, not a usage slip (exit 2).
let out = wanderbooks()
.args(["dev-stdio", "--data-dir"])
.output()
.unwrap();
let out = wanderbooks().args(["server", "--root"]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("error: --data-dir needs a path"), "{err}");
assert!(err.contains("error: --root needs a folder"), "{err}");
}
/// With no display to open a window on, the viewer fails and says so
@@ -192,28 +98,33 @@ fn a_server_that_cannot_start_says_why_and_exits_1() {
#[cfg(all(feature = "gui", target_os = "linux"))]
#[test]
fn a_viewer_that_cannot_open_a_window_exits_1() {
let dir = tempfile::Builder::new()
.prefix("wb-one-exe-")
.tempdir()
.unwrap();
// Empty books in a data root of the test's own, and a home of its
// own: the viewer opens no loose books folder (D-44), and it reads its
// preferences from the home, never this account's.
let home = common::Home::new("one-exe");
let server = common::Server::start(&home);
home.create(common::BOOK, "USD");
assert!(server.close().success());
let mut child = wanderbooks()
.args(["gui", "--data-dir"])
.arg(dir.path().join("books-data"))
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.args(["gui", "--root"])
.arg(home.root())
.args(["--book", common::BOOK])
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
let deadline = std::time::Instant::now() + common::TIMEOUT;
let status = loop {
if let Some(status) = child.try_wait().unwrap() {
break status;
}
if std::time::Instant::now() > deadline {
let _ = child.kill();
panic!("the viewer neither opened nor failed within a minute");
let _ = child.wait();
panic!("the viewer neither opened nor failed in time");
}
std::thread::sleep(std::time::Duration::from_millis(50));
};
@@ -235,50 +146,10 @@ fn the_server_only_build_has_no_viewer() {
);
}
/// Books in the data root are the books server's alone (plan 3.1): the
/// test server refuses them, and the books server needs a root.
/// The books server needs a data root: with none, it says to run setup.
#[cfg(unix)]
#[test]
fn dev_stdio_refuses_books_in_the_private_folder() {
let home = tempfile::Builder::new()
.prefix("wb-one-root-")
.tempdir()
.unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let root = home.path().join(".local/share/wanderbooks");
for inside in [
root.join("books/acme"),
root.clone(),
root.join("books/a/b"),
] {
let out = wanderbooks()
.args(["dev-stdio", "--data-dir"])
.arg(&inside)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{inside:?}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("only the books server opens"), "{err}");
assert!(!inside.join("journal.jsonl").exists(), "{inside:?}");
}
assert!(!root.join("books/acme").exists());
// A relative path, from inside the root.
let out = wanderbooks()
.args(["dev-stdio", "--data-dir", "acme"])
.current_dir(root.join("books"))
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(!root.join("books/acme").exists());
fn the_books_server_needs_a_root() {
// No root yet: the books server says to run setup.
let empty = tempfile::Builder::new()
.prefix("wb-one-noroot-")
File diff suppressed because it is too large. Load diff
+283
View File
@@ -0,0 +1,283 @@
//! 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 adopted with its
//! settings file into a data root and served by a real `wanderbooks
//! server` (common.rs), 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 common::{Books, Wire};
use serde_json::{Value, json};
mod common;
const START: &str = "2026-01-01";
struct Mcp {
wire: Wire,
}
impl Mcp {
fn start(books: &Books) -> Self {
let mut wire = books.wire();
wire.initialize("p3-seams");
Mcp { wire }
}
/// A tool's structured result; a refusal fails the test.
fn ok(&mut self, name: &str, args: Value) -> Value {
let resp = self.wire.call(name, args.clone());
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 settings_toml = std::fs::read_to_string(dir.path().join("wanderbooks.toml")).unwrap();
let served = Books::adopted(
"p3-seams",
&dir.path().join("books-data"),
Some(&settings_toml),
);
let mut mcp = Mcp::start(&served);
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}");
}
}
}
+23 -82
View File
@@ -12,8 +12,8 @@ use ledger_gui::pages::projects::{Snap, page_figures};
use ledger_gui::{Source, data};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
mod common;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
@@ -28,89 +28,39 @@ fn s(v: Decimal) -> String {
format!("{v:.2}")
}
/// The books server over stdio (`wanderbooks dev-stdio`), one request at
/// a time.
/// The settings both tests' books keep.
const SETTINGS: &str = "[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n";
/// A session with the books server (common.rs), one request at a time.
struct Server {
child: std::process::Child,
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
id: u64,
wire: common::Wire,
}
impl Server {
fn start(data: &std::path::Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("dev-stdio")
.arg("--data-dir")
.arg(data)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn wanderbooks dev-stdio");
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut s = Server {
child,
stdin,
stdout,
id: 0,
};
s.ask(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "projects-parity", "version": "0"}}),
);
writeln!(
s.stdin,
"{}",
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
)
.unwrap();
s
}
fn ask(&mut self, method: &str, params: Value) -> Value {
self.id += 1;
let msg = json!({"jsonrpc": "2.0", "id": self.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("a response");
let resp: Value = serde_json::from_str(&line).expect("JSON");
assert_eq!(resp["id"], self.id, "{resp}");
resp
/// Start `place`'s server on the books the test wrote in its root,
/// with [`SETTINGS`], and a session with them.
fn start(place: &common::Place) -> Self {
let mut wire = place.start(Some(SETTINGS));
wire.initialize("projects-parity");
Server { wire }
}
/// A tool's structured result.
fn tool(&mut self, name: &str, args: Value) -> Value {
let r = self.ask("tools/call", json!({"name": name, "arguments": args}));
let r = self.wire.call(name, args);
let out = &r["result"];
assert_ne!(out["isError"], true, "{name}: {r}");
out["structuredContent"].clone()
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
let dir = tempfile::Builder::new()
.prefix("wb-projects-parity-")
.tempdir()
.unwrap();
let data = dir.path().join("books-data");
std::fs::write(
dir.path().join("wanderbooks.toml"),
"[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
)
.unwrap();
// The books written in a data root by the store itself, then served
// there.
let place = common::Place::new("projects-parity");
let data = place.books_data();
let today = d(2026, 10, 15);
let id = {
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
@@ -162,12 +112,12 @@ fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
.unwrap();
id
};
let mut server = Server::start(&place);
let b = data::load_once(&Source {
data_dir: data.clone(),
today: Some(today),
})
.unwrap();
let mut server = Server::start(&data);
// To date, and by month over the viewer's chart span.
let page = page_figures(&b, id, Some(d(2025, 11, 1)), false);
@@ -264,24 +214,15 @@ fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
/// project) and from the server (books.project, books.get_entry).
#[test]
fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server() {
let dir = tempfile::Builder::new()
.prefix("wb-projects-parity-docs-")
.tempdir()
.unwrap();
let data = dir.path().join("books-data");
std::fs::write(
dir.path().join("wanderbooks.toml"),
"[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
)
.unwrap();
let place = common::Place::new("projects-parity-docs");
let data = place.books_data();
let today = d(2026, 10, 15);
{
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
st.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
}
let mut server = Server::start(&data);
let mut server = Server::start(&place);
server.tool(
"books.add_project",
json!({"name": "Kestrel", "kind": "client", "started_on": "2026-01-05"}),
+900
View File
@@ -0,0 +1,900 @@
//! The 1099 decision and payment methods through the server (plan P3,
//! defects 12, 18 and 23; D-40), driven through a real books server as
//! an agent reaches it (common.rs): 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 common::{Place, Wire};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod common;
const TIMEOUT: Duration = common::TIMEOUT;
// ---------------------------------------------------------------- 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 {
wire: Wire,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The test's place, when the session owns it (its server is closed
/// and its home removed after the session).
books_dir: Option<TestDir>,
}
/// A test's place (common.rs): its home, its server, its books.
type TestDir = Place;
fn test_dir(name: &str) -> TestDir {
Place::new(name)
}
/// `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: &TestDir, toml: &Path) -> Self {
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
let mut mcp = Self {
books_dir: None,
wire,
next_id: 1,
data: dir.books_data(),
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) {
self.wire.send_line(line);
}
fn recv(&mut self) -> Option<Value> {
self.wire.recv(TIMEOUT)
}
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.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);
dir.stop();
ledger_store::Store::open(&dir.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
+488
View File
@@ -0,0 +1,488 @@
//! P1 through the real server: the books' own settings, conventions,
//! approvals, books.start (plan/served.md P1). Drives a real `wanderbooks
//! server` with raw JSON-RPC, as an agent reaches it (common.rs).
use common::{Books, Wire};
use serde_json::{Value, json};
mod common;
/// One agent's session with the test's books (common.rs).
struct Mcp {
wire: Wire,
next_id: u64,
}
impl Mcp {
/// A new session with `books` (initialized by the test).
fn spawn(books: &Books) -> Self {
Self {
wire: books.wire(),
next_id: 1,
}
}
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});
self.wire.send(&msg);
let resp = self
.wire
.recv(common::TIMEOUT)
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
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) {
self.wire
.send(&json!({"jsonrpc": "2.0", "method": method, "params": {}}));
}
/// 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()
}
}
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}),
);
}
}
/// Books keep their settings, changed through the tools with who decided
/// and when; the company's EIN only ever leaves the server masked (in
/// books.settings, books.set_setting, books.export_settings and
/// books.start); and a restarted server reads them from the books.
#[test]
fn settings_live_in_the_books_and_the_ein_leaves_masked() {
let books = Books::with("kept-ein", "USD", Some(TOML));
let mut m = Mcp::spawn(&books);
let told = init(&mut m);
assert!(told.contains("books.start"), "{told}");
chart(&mut m);
let settings = m.call("books.settings", json!({}));
assert_eq!(settings["parties"], "require");
let shown = settings.to_string();
assert!(!shown.contains("8525893"), "the EIN is masked: {shown}");
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"], "Test Owner");
let rule = settings["receipt_rule"].clone();
assert!(rule.as_str().unwrap().contains("every entry"), "{rule}");
// Settings change through the tools.
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["book"]["timezone"], "America/Denver");
assert!(!start.to_string().contains("3456789"));
drop(m);
books.restart();
let mut m = Mcp::spawn(&books);
let told = init(&mut m);
assert!(
told.contains("Settings and conventions live in the books"),
"{told}"
);
assert!(told.contains("Kept Co LLC"), "{told}");
// What was set before the restart, read back from the books: the
// timezone, and the new EIN, still masked.
let start = m.call("books.start", json!({}));
assert_eq!(start["book"]["timezone"], "America/Denver");
let settings = m.call("books.settings", json!({}));
let ein = settings["keys"]
.as_array()
.unwrap()
.iter()
.find(|k| k["key"] == "company.ein")
.unwrap()
.clone();
assert_eq!(ein["value"], "**-***6789");
assert_eq!(ein["set_by"]["decided_by"], "B");
assert!(!settings.to_string().contains("3456789"), "{settings}");
assert_eq!(m.call("books.verify", json!({}))["status"], "ok");
}
/// 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 books = Books::new("kept-approvals");
let mut m = Mcp::spawn(&books);
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 books = Books::new("kept-template");
let mut m = Mcp::spawn(&books);
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 books = Books::new("kept-start");
let mut m = Mcp::spawn(&books);
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) and D-44 (owner, 2026-10-23: the import
/// tool goes): books keep their own settings and take no settings file;
/// books.settings shows each value (an identity value masked), and
/// books.import_settings is no tool (an unknown tool: a protocol error).
#[test]
fn books_keeping_their_settings_take_no_settings_file() {
let books = Books::with("kept-whole", "USD", Some(TOML));
let mut m = Mcp::spawn(&books);
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 value, masked");
let tools = m.request("tools/list", json!({}))["result"]["tools"].clone();
assert!(
!tools.to_string().contains("books.import_settings"),
"{tools}"
);
let id = m.next_id;
m.next_id += 1;
m.wire
.send(&json!({"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": {"name": "books.import_settings",
"arguments": {"toml_text": TOML, "decided_by": "A", "confirmed": true}}}));
let resp = m.wire.recv(common::TIMEOUT).expect("an answer");
assert!(resp.get("error").is_some(), "{resp}");
}
/// 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 books = Books::new("kept-renumber");
let mut m = Mcp::spawn(&books);
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 books = Books::new("kept-resources");
let mut m = Mcp::spawn(&books);
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, m.call("books.settings", json!({})), "{settings}");
let conventions = read(&mut m, "books://conventions");
assert_eq!(
conventions["conventions"][0]["id"], "software",
"{conventions}"
);
}
/// A renumber lists exactly the conventions naming the old code.
#[test]
fn renumbering_lists_only_the_conventions_naming_it() {
let books = Books::new("kept-renumber-only");
let mut m = Mcp::spawn(&books);
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}"
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,707 @@
//! P3 defects 14 and 24 through the MCP, through a real books server as
//! an agent reaches it (common.rs): 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 common::{Place, Wire};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod common;
const TIMEOUT: Duration = common::TIMEOUT;
// ---------------------------------------------------------------- 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 {
wire: Wire,
next_id: u64,
data: PathBuf,
validators: HashMap<String, jsonschema::Validator>,
/// The test's place, when the session owns it (its server is closed
/// and its home removed after the session).
books_dir: Option<TestDir>,
}
/// A test's place (common.rs): its home, its server, its books.
type TestDir = Place;
fn test_dir(name: &str) -> TestDir {
Place::new(name)
}
/// `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: &TestDir, toml: &Path) -> Self {
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
let mut mcp = Self {
books_dir: None,
wire,
next_id: 1,
data: dir.books_data(),
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) {
self.wire.send_line(line);
}
fn recv(&mut self) -> Option<Value> {
self.wire.recv(TIMEOUT)
}
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.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);
dir.stop();
ledger_store::Store::open(&dir.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
+413
View File
@@ -0,0 +1,413 @@
//! Tax categories at account creation, and `policy.require_tax_category`
//! (P3, defect 8; rules.md B-32), through a real books server as an
//! agent reaches it (common.rs).
//!
//! ```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 common::{Books, Wire};
use serde_json::{Value, json};
mod common;
struct Mcp {
wire: Wire,
next_id: u64,
}
impl Mcp {
/// A session with books that keep their own settings, initialized.
fn spawn(books: &Books) -> Self {
let mut m = Self {
wire: books.wire(),
next_id: 1,
};
m.request(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-tax-policy", "version": "0"}}),
);
m.wire
.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}));
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});
self.wire.send(&msg);
let resp = self
.wire
.recv(common::TIMEOUT)
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
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"}),
);
}
}
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 books = Books::new("warn");
let mut m = Mcp::spawn(&books);
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 books = Books::new("refuse");
let mut m = Mcp::spawn(&books);
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 books = Books::new("names");
let mut m = Mcp::spawn(&books);
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 books = Books::new("form4797");
let mut m = Mcp::spawn(&books);
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
+118
View File
@@ -0,0 +1,118 @@
//! Starting the books server on books it can't serve: books another
//! program holds, and books a newer wanderbooks wrote. Oracle: ledger-mcp
//! local.rs INVARIANT 3 (every book is tried before the server says it is
//! ready; one that won't open is reported with why, on its route and in
//! the list, and the others are served) and rules.md B-27 (newer books
//! are refused as newer, never as corrupt, and left as they are).
use common::{Home, Server, Wire};
use ledger_mcp::admin::{Request, Response};
use serde_json::json;
use std::time::{Duration, Instant};
mod common;
/// New books `id` written in `home`'s root by the store itself, before a
/// server opens them; the store, still open.
fn written(home: &Home, id: &str) -> ledger_store::Store {
use std::os::unix::fs::PermissionsExt;
let dir = home.book_dir(id);
let store = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
store
}
/// What the server's list says of `id`: whether it is open, and why not.
fn listed(home: &Home, id: &str) -> (bool, Option<String>) {
match home.admin(Request::List) {
Response::Books(list) => {
let b = list
.into_iter()
.find(|b| b.id == id)
.unwrap_or_else(|| panic!("{id} isn't listed"));
(b.open, b.problem)
}
other => panic!("{other:?}"),
}
}
/// A request to `id`'s route is refused before it runs, with why.
fn refused_at_the_door(home: &Home, id: &str, why: &str) {
let mut wire = Wire::with_token(home, id, None);
wire.send(
&json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": common::PROTOCOL, "capabilities": {},
"clientInfo": {"name": "t", "version": "0"}}}),
);
let refusal = wire.refused.clone().expect("refused");
assert!(refusal.not_run, "{refusal:?}");
assert!(refusal.body.contains(why), "{refusal:?}");
home.assert_hidden(&refusal.body);
}
/// A writer that keeps the books is a conflict: the server reports those
/// books not open, with why, serves the others, and never hangs on them.
#[test]
fn a_held_lock_is_refused_not_waited_on_forever() {
let home = Home::new("start-locked");
let _holder = written(&home, "acme");
drop(written(&home, "beta"));
let start = Instant::now();
let server = Server::start(&home);
assert!(
start.elapsed() < Duration::from_secs(60),
"the server waited on a held lock"
);
let (open, problem) = listed(&home, "acme");
assert!(!open);
let problem = problem.expect("why");
assert!(
problem.contains("another program has these books open"),
"{problem}"
);
refused_at_the_door(&home, "acme", "another program has these books open");
// The others are served.
assert_eq!(listed(&home, "beta"), (true, None));
let mut beta = Wire::new(&home, "beta");
beta.initialize("t");
let chart = beta.call("books.chart", json!({}));
assert_eq!(chart["result"]["isError"], json!(false), "{chart}");
assert!(server.close().success());
}
/// Books written by a newer wanderbooks: the server won't open them, and
/// says to update it, not that the books are corrupt (rules.md B-27); it
/// leaves them as they are.
#[test]
fn newer_books_are_refused_as_newer_not_corrupt() {
let home = Home::new("start-newer");
drop(written(&home, "acme"));
let dir = home.book_dir("acme");
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append("journal.format_set", json!({"format": 3, "features": []}))
.unwrap();
// The next line chains onto it, so its bytes are checked (B-27).
w.append(
"fx.rate_set",
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
)
.unwrap();
drop(w);
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let server = Server::start(&home);
let (open, problem) = listed(&home, "acme");
assert!(!open);
let err = problem.expect("why");
assert!(
err.contains("written by a newer version of wanderbooks"),
"{err}"
);
assert!(err.contains("Update wanderbooks"), "{err}");
assert!(!err.contains("corrupt"), "{err}");
refused_at_the_door(&home, "acme", "Update wanderbooks");
assert!(server.close().success());
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
journal
);
}
+634
View File
@@ -0,0 +1,634 @@
//! 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 common::{Client, call};
use serde_json::{Value, json};
use std::io::Read;
use std::path::{Path, PathBuf};
mod common;
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";
/// The test's books (common.rs: a home, its server, the books in its
/// root), their folder, a folder for files on their way in, and one
/// outside the root for files saved; the settings: a member, so the CPA
/// package has a K-1 worksheet.
struct Books {
served: common::Books,
dir: PathBuf,
inbox: PathBuf,
out: PathBuf,
}
fn books(tag: &str) -> Books {
let tag = tag.to_string();
// Made on a thread of its own: the settings go in through a session
// with a runtime of its own (common.rs), never inside the test's.
let served = std::thread::spawn(move || {
common::Books::with(
&format!("tin-files-{tag}"),
"USD",
Some(
"[company]\nname = \"Tin Co\"\n[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n\
[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
),
)
})
.join()
.unwrap();
let base = served.home.dir.path().to_path_buf();
let (inbox, out) = (base.join("inbox"), base.join("out"));
for d in [&inbox, &out] {
std::fs::create_dir_all(d).unwrap();
}
Books {
dir: served.dir(),
served,
inbox,
out,
}
}
/// A new session with the books.
async fn connect(b: &Books) -> Client {
common::connect(&b.served.home, common::BOOK).await.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`): fetched by its
/// download, matching the size and SHA-256 it states.
async fn handed(b: &Books, file: &Value) -> Vec<u8> {
let token = b.served.home.token(common::BOOK);
common::fetch_export(&b.served.home, common::BOOK, &token, file).await
}
/// 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(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 one = connect(&b).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(&b, &got["file"]).await).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(&b, &got["csv"]).await).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(&b, &got["file"]).await).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(&b).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));
b.served.restart();
// A server started afresh on the same books refuses them all too.
let three = connect(&b).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 c = connect(&b).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 c = connect(&b).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);
// 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.
b.served.stop();
std::fs::remove_file(b.dir.join("keys/legacy.key")).unwrap();
b.served.start();
let c = connect(&b).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 c = connect(&b).await;
call(
&c,
"books.set_setting",
json!({"changes": [{"key": "company.ein", "value": "12-3456789"}],
"decided_by": "Test Owner"}),
)
.await
.unwrap();
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 c = connect(&b).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 c = connect(&b).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);
// The vault locked: the same refusals, word for word.
b.served.stop();
std::fs::remove_file(b.dir.join("keys/legacy.key")).unwrap();
b.served.start();
let c = connect(&b).await;
assert_eq!(
probes(&c).await,
open,
"the refusal tells whether the vault is open"
);
}