diff --git a/Cargo.lock b/Cargo.lock index 724664b..77f6d79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3855,20 +3855,26 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "jsonschema", + "ledger-config", "ledger-core", "ledger-gui", "ledger-host", "ledger-mcp", + "ledger-package", "ledger-store", "ledger-vault", "libc", + "proptest", "reqwest", "rmcp", + "rusqlite", "rust_decimal", "serde_json", "sha2 0.10.9", "tempfile", "tokio", + "zip 9.0.2", ] [[package]] @@ -3881,7 +3887,6 @@ dependencies = [ "rust_decimal", "serde", "serde_json", - "tempfile", "toml 1.1.8+spec-1.1.0", ] @@ -3987,7 +3992,6 @@ dependencies = [ "chrono", "futures-util", "http-body-util", - "jsonschema", "ledger-config", "ledger-core", "ledger-import", @@ -3995,10 +3999,7 @@ dependencies = [ "ledger-store", "ledger-vault", "libc", - "mime_guess", - "proptest", "rmcp", - "rusqlite", "rust_decimal", "schemars", "serde", @@ -4011,7 +4012,6 @@ dependencies = [ "toml 1.1.8+spec-1.1.0", "tower", "zeroize", - "zip 9.0.2", ] [[package]] @@ -4249,16 +4249,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "mime_guess2" version = "2.3.1" diff --git a/README.md b/README.md index bdbebfb..17ef5cf 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,10 @@ through MCP. Self-hosted, no rent. append-only, hash-chained JSONL journal; SQLite is a disposable projection rebuilt from it; receipts live in a content-addressed blob store. Drafts are durable, corrupt projections self-heal. -- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or - Streamable HTTP: draft → preview → post workflow, server-side receipt +- **`ledger-mcp`** — the AI-first interface. 118 MCP tools, served by the + books server (`wanderbooks server`) on the data root's local sockets and + reached through the agents' bridge (`wanderbooks mcp --book ID`): draft → + preview → post workflow, server-side receipt policy (>$25), anti-splitting detection, asset management, digest for human review, plus read-only resources for dashboards. - **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs @@ -38,7 +40,8 @@ This is the storytold way applied to accounting: agents verify against oracles, never ship what they haven't demonstrated. - 190+ workspace tests: invariant tests encoding accounting law, golden-file - report snapshots, property tests (proptest), end-to-end MCP stdio tests. + report snapshots, property tests (proptest), end-to-end MCP tests through + a real books server. - **Formal proofs of the accounting laws** (`just prove`, `docs/verification.md`). Kani model-checks the engine's arithmetic kernel exhaustively within bounds. Lean 4 proves that, after any @@ -68,9 +71,8 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and ## Quickstart ```bash -cargo test # whole suite -cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio -cargo run -p ledger-gui -- --demo # the viewer, on a demo company +cargo test # whole suite +cargo run -p ledger-gui -- --demo # the viewer, on a demo company ``` New books, one command (installs Rust and wanderbooks if needed, makes the books' private folder, starts books called `acme` and lets Claude Code reach @@ -100,7 +102,7 @@ just # lists the recipes: check, build, prove, demo, ... ``` crates/ledger-core/ # engine: money, accounts, journal, ledger, reports crates/ledger-store/ # journal + SQLite projection + blob store -crates/ledger-mcp/ # MCP server: 119 tools, resources, handbook, receipt policy +crates/ledger-mcp/ # MCP server: 118 tools, resources, handbook, receipt policy crates/ledger-import/ # QBO CSV import, IIF/XLSX export crates/ledger-config/ # wanderbooks.toml company settings crates/ledger-cli/ # `wanderbooks`: the one executable (every mode) diff --git a/bookspec/README.md b/bookspec/README.md index 3959446..b7ad560 100644 --- a/bookspec/README.md +++ b/bookspec/README.md @@ -352,7 +352,7 @@ answers is refused, never skipped. | `register_row` | `asset`, `as_of` | the asset's row of the fixed asset register (B-15 rules 7 and 8, B-24): `acquired`, `in_service` (`YYYY-MM-DD`), `cost`, `accumulated_scheduled` (the schedule's), `accumulated_posted` (what the books carry), `unposted` (the one less the other), `book_value` (cost less posted), each `"0.00"` once disposed; the example names the figures it shows; an asset acquired after the day is refused | `ledger-core` `tests/bookspec.rs` | | `register_totals` | `as_of` | the register's totals over the assets held (B-24, B-25): `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`, `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`, `missing_basis` (a count), and how it ties to the books: `unregistered_cost`, `unposted_depreciation`, `unattributed_accumulated`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `asset_basis` | `asset`, no dates | the asset's basis facts and the tax figures they give (B-25): `rule`, `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`, `depreciable_basis`, `built_in_gain_704c` (a loss negative), each `"none"` where it has none, and `sec704c_1c` (`"true"` for a built-in loss, which counts only for the contributor); the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `project` | `project`, `from`, `to` | the project's figures (B-37), every one given: `revenue`, `expenses` and `net` for the period (the revenue and expense lines tagged to it, closes left out), `to_date.revenue`, `to_date.expenses` and `to_date.net` (every tagged line through `to`), and `unrecovered_cost` (expenses less revenue to date, never below zero; `"none"` unless it is on spec) | `ledger-core` `tests/bookspec.rs`; `ledger-mcp` `project_figures_match_profit_and_loss_by_class` | +| `project` | `project`, `from`, `to` | the project's figures (B-37), every one given: `revenue`, `expenses` and `net` for the period (the revenue and expense lines tagged to it, closes left out), `to_date.revenue`, `to_date.expenses` and `to_date.net` (every tagged line through `to`), and `unrecovered_cost` (expenses less revenue to date, never below zero; `"none"` unless it is on spec) | `ledger-core` `tests/bookspec.rs`; `ledger-cli` `tests/spec_projects.rs` `project_figures_match_profit_and_loss_by_class` | | `schedule_plan` | `schedule`, no dates | the schedule's plan (B-16): what it recognizes at each month end named (`"YYYY-MM-DD"`), `"0.00"` at one it doesn't post; `total`, all it recognizes; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | | `iif_lines` | `as_of` | formats appendix: the file's lines in order, each its fields joined by tabs, without the CR LF that ends each | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | | `iif_changes` | `as_of` | formats appendix: each text the file writes differently from the books, in file order, as `[what, books' text, file's text]` (the example's header gives `what`) | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | diff --git a/bookspec/examples/default-period-fiscal-year-to-date.toml b/bookspec/examples/default-period-fiscal-year-to-date.toml index a2d8fbe..a681846 100644 --- a/bookspec/examples/default-period-fiscal-year-to-date.toml +++ b/bookspec/examples/default-period-fiscal-year-to-date.toml @@ -3,9 +3,9 @@ # wanderbooks: P3 defect 16 (the report tools, books.show and the viewer # each read omitted dates their own way); rules.md B-30; regression tests # `prop_period_defaults_resolve` (ledger-core tests/properties.rs), -# `one_default_period_everywhere_defect_16` (ledger-mcp -# tests/mcp_stdio.rs), `show_sends_the_dates_it_names_defect_16` and -# `show_refuses_start_after_end_defect_15` (ledger-mcp tests/oracle_mcp.rs). +# `one_default_period_everywhere_defect_16` (ledger-cli +# tests/mcp_journeys.rs), `show_sends_the_dates_it_names_defect_16` and +# `show_refuses_start_after_end_defect_15` (ledger-cli tests/oracle_mcp.rs). # # Expected values (bookspec/README.md): `period` gives the dates a period # report asked with these dates covers, `day` the day a report as of a day diff --git a/bookspec/examples/p3-party-tin-last4.toml b/bookspec/examples/p3-party-tin-last4.toml index db5f4fd..289dbef 100644 --- a/bookspec/examples/p3-party-tin-last4.toml +++ b/bookspec/examples/p3-party-tin-last4.toml @@ -1,7 +1,7 @@ # P3 defect 11 (audit finding F28): the directory showed a member's TIN as # missing because it read W-9 facts only. Regression tests: ledger-core # tests/spec_party_totals.rs -# defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s; ledger-mcp +# defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s; ledger-cli # tests/spec_vault.rs defect11_the_directory_shows_a_members_tin_last4. # The shape: bookspec/README.md. diff --git a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml index 2cc9b85..386d961 100644 --- a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml +++ b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml @@ -3,7 +3,7 @@ # wanderbooks: rules.md B-37 item 8 (journal feature # `document_line_projects`); regression tests in ledger-core # `tests/spec_document_projects.rs`, ledger-store -# `tests/document_projects.rs`, ledger-mcp `tests/spec_projects.rs` +# `tests/document_projects.rs`, ledger-cli `tests/spec_projects.rs` # (`invoice_and_bill_lines_count_toward_their_projects`). # # Expected values worked by hand. Invoice 1 bills 3,000.00 + 500.00 of diff --git a/bookspec/examples/projects-net-is-its-tagged-lines.toml b/bookspec/examples/projects-net-is-its-tagged-lines.toml index 1acb435..fb1c60b 100644 --- a/bookspec/examples/projects-net-is-its-tagged-lines.toml +++ b/bookspec/examples/projects-net-is-its-tagged-lines.toml @@ -2,7 +2,7 @@ # # wanderbooks: rules.md B-37 (projects); regression tests # `project_figures_are_its_tagged_lines` (ledger-core tests/spec_projects.rs), -# `project_figures_match_profit_and_loss_by_class` (ledger-mcp +# `project_figures_match_profit_and_loss_by_class` (ledger-cli # tests/spec_projects.rs). # # Expected values worked by hand: Alpha earns 5,000.00 and costs 1,800.00 diff --git a/bookspec/examples/rename-changes-no-amount.toml b/bookspec/examples/rename-changes-no-amount.toml index 33387cb..4176f49 100644 --- a/bookspec/examples/rename-changes-no-amount.toml +++ b/bookspec/examples/rename-changes-no-amount.toml @@ -3,7 +3,7 @@ # wanderbooks: P3 defect 15 (an account could not be renamed); rules.md # B-8, B-31; regression tests `rename_account_changes_only_the_name_defect_15` # and `prop_rename_changes_no_figure` (ledger-store tests/store.rs), -# `rename_account_end_to_end_defect_15` (ledger-mcp tests/mcp_stdio.rs). +# `rename_account_end_to_end_defect_15` (ledger-cli tests/mcp_journeys.rs). # The journal's record of the old name beside the new (`account.renamed`) # is the store's, checked there. # diff --git a/bookspec/examples/tax-category-required.toml b/bookspec/examples/tax-category-required.toml index d58ab0d..d17aca7 100644 --- a/bookspec/examples/tax-category-required.toml +++ b/bookspec/examples/tax-category-required.toml @@ -3,7 +3,7 @@ # wanderbooks: P3 defect 8 (an account made without a tax category went # unnoticed); rules.md B-32; regression tests # `add_account_warns_without_tax_category_defect_8` and -# `require_tax_category_refuses_defect_8` (ledger-mcp +# `require_tax_category_refuses_defect_8` (ledger-cli # tests/spec_tax_policy.rs), `prop_tax_policy_refuses_exactly` (ledger-store # tests/store.rs), `needs_attention_flags_every_uncategorized_pl_account_defect_8` # (ledger-gui tests/view_model.rs). diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index 8770d8d..aa2786d 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -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 diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 31f2485..57c7a73 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -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) -> 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) -> i32 { - match ledger_mcp::run("wanderbooks dev-stdio", args) { - Ok(()) => 0, - Err(e) => match e.downcast_ref::() { - 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"]), ] { diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs new file mode 100644 index 0000000..482efd5 --- /dev/null +++ b/crates/ledger-cli/tests/common/mod.rs @@ -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>, +} + +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, + session: Option, + version: Option, + told: VecDeque, + /// The last exchange that came back as no message at all. + pub refused: Option, + /// 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) -> 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 = 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 = 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 { + 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) { + 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 { + 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>, + 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 { + 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 { + 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 { + fn walk(base: &Path, dir: &Path, out: &mut std::collections::BTreeMap) { + 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>, + /// The settings file's text set in the books, once set. + settled: std::cell::RefCell>, + /// 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>, +} + +impl std::ops::Deref for Place { + type Target = Path; + fn deref(&self) -> &Path { + self.home.dir.path() + } +} + +impl AsRef 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 { + 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; + +/// 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 { + 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, +) -> Result { + 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) -> Result { + 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 { + 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 { + 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 +} diff --git a/crates/ledger-mcp/tests/hostile_args.rs b/crates/ledger-cli/tests/hostile_args.rs similarity index 91% rename from crates/ledger-mcp/tests/hostile_args.rs rename to crates/ledger-cli/tests/hostile_args.rs index 7b5c11f..612065d 100644 --- a/crates/ledger-mcp/tests/hostile_args.rs +++ b/crates/ledger-cli/tests/hostile_args.rs @@ -23,22 +23,24 @@ //! books.chart and books.verify (P0 review round 4: a call a command //! accepts but replay refuses would brick the books). //! 5. Books an earlier version wrote (ledger-store's old-books fixtures) -//! open, and every tool and resource answers over them. +//! open, and every tool and resource answers over them: with the +//! fixture company's settings where their format keeps settings, on +//! the defaults where they wait for their format upgrade. //! ``` //! //! Against a small fixture company with every kind of record, so ids and //! names the generator picks often exist. `MCP_CASES` sets the number of //! cases (default 256). +use common::{Place, Wire}; use proptest::prelude::*; use proptest::test_runner::{Config, TestCaseError, TestRunner}; use serde_json::{Map, Value, json}; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::mpsc::{Receiver, channel}; use std::time::Duration; +mod common; + /// The longest any one call may take: a call that hangs is a denial of /// service too. const TIMEOUT: Duration = Duration::from_secs(120); @@ -53,68 +55,42 @@ fn cases() -> u32 { .unwrap_or(256) } +/// One session with the books (common.rs). struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} +/// The settings the fixture company keeps. +const SETTINGS: &str = "[company]\nname = \"Hostile Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\ + [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ + [books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\nparties = \"suggest\"\n"; -/// The places one run uses: the books, the vault key, a folder of files on -/// their way in (as a downloads folder), the folder the server runs in -/// (where a relative path lands). +/// The places one run uses: the test's home and its books (common.rs), a +/// folder of files on their way in (as a downloads folder), and the +/// folder the server runs in (where a relative path lands), both in the +/// home and outside the data root. struct Places { - base: PathBuf, + place: Place, + /// The books' folder in the data root. books: PathBuf, inbox: PathBuf, sandbox: PathBuf, } -/// The places go when the test ends, pass or panic. -impl Drop for Places { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.base); - } -} - fn places(tag: &str) -> Places { - let base = - std::env::temp_dir().join(format!("ledger-mcp-hostile-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); + let place = Place::new(&format!("hostile-{tag}")); let p = Places { - books: base.join("books"), - inbox: base.join("inbox"), - sandbox: base.join("sandbox"), - base, + books: place.books_data(), + inbox: place.join("inbox"), + sandbox: place.join("sandbox"), + place, }; - for d in [ - &p.books, - &p.inbox, - &p.sandbox.join("out"), - &p.base.join("home"), - &p.base.join("config"), - ] { + for d in [&p.inbox, &p.sandbox.join("out")] { std::fs::create_dir_all(d).unwrap(); } - std::fs::write( - p.books.join("wanderbooks.toml"), - format!( - "[company]\nname = \"Hostile Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\ - [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ - [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ - [books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\nparties = \"suggest\"\n\ - [vault]\nkey_file = \"{}\"\n", - p.base.join("config/vault.key").display() - ), - ) - .unwrap(); + p.place.run_in(&p.sandbox); std::fs::write(p.inbox.join("receipt.pdf"), b"%PDF-1.4 a receipt\n%%EOF\n").unwrap(); std::fs::write( p.inbox.join("bank.csv"), @@ -125,53 +101,70 @@ fn places(tag: &str) -> Places { } impl Mcp { + /// Start (or restart) the books' server, and a session with it. fn start(p: &Places) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(p.books.join("books-data")) - .arg("--books-toml") - .arg(p.books.join("wanderbooks.toml")) - .current_dir(&p.sandbox) - .env("HOME", p.base.join("home")) - .env("XDG_CONFIG_HOME", p.base.join("config")) - .env("XDG_RUNTIME_DIR", p.base.join("config")) - .env_remove("XDG_DOWNLOAD_DIR") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::from( - std::fs::File::create(p.base.join("stderr.log")).unwrap(), - )) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; + Self::session(p.place.start(Some(SETTINGS))) + } + + /// Start (or restart) the server on books an earlier version wrote, + /// with the fixture company's settings set as an agent sets them, one + /// at a time. Books in a journal format before 2 keep no settings + /// until the owner's format upgrade (rules.md B-27, D-39): each change + /// is refused as `format_upgrade_pending`, and those books are served + /// on the defaults (no members, no 1065, no cash accounts), as they + /// would be in a data root. Books with periods closed under the + /// calendar year keep it (a changed tax year needs the IRS's consent, + /// rules.md B-29), whatever their format. Any other answer fails the + /// test when the books are first served (`first`); on a restart, + /// after hostile calls that may have changed a setting and closed + /// periods under it, the books may refuse a change for what they now + /// hold, as they would any agent. + fn start_old(p: &Places, first: bool) -> Self { + let mut m = Self::session(p.place.start(None)); + let changes = common::settings_changes(SETTINGS, None, "USD"); + let mut refused = Vec::new(); + let mut held = 0; + for change in changes.as_array().unwrap() { + let r = m.rpc( + "tools/call", + json!({"name": "books.set_setting", + "arguments": {"changes": [change], "decided_by": "Test Owner"}}), + ); + assert!(r.get("error").is_none(), "{change}: {r}"); + if first && r["result"]["isError"] == json!(true) { + let why = r["result"]["content"][0]["text"] + .as_str() + .unwrap_or_default(); + if change["key"] == "company.fiscal_year_start" + && why.contains("periods are closed under it") + { + held += 1; + continue; } + assert!( + why.starts_with("format_upgrade_pending:"), + "{change}: {why}" + ); + refused.push(change["key"].clone()); } - }); - let mut m = Self { - stdin, - rx, - child, - next_id: 1, - }; + } + assert!( + refused.is_empty() || refused.len() + held == changes.as_array().unwrap().len(), + "books that take some settings take them all: {refused:?} refused" + ); + m + } + + fn session(wire: Wire) -> Self { + let mut m = Self { wire, next_id: 1 }; let init = m.rpc( "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "hostile", "version": "0"}}), ); assert!(init.get("result").is_some(), "{init}"); - writeln!( - m.stdin, - "{}", - json!({"jsonrpc": "2.0", "method": "notifications/initialized"}) - ) - .unwrap(); + m.wire + .send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); m } @@ -185,18 +178,17 @@ impl Mcp { let id = self.next_id; self.next_id += 1; let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - writeln!(self.stdin, "{msg}").map_err(|e| format!("write: {e}"))?; - self.stdin.flush().map_err(|e| format!("flush: {e}"))?; - loop { - let line = self - .rx - .recv_timeout(TIMEOUT) - .map_err(|_| format!("no answer within {TIMEOUT:?} (or the server died)"))?; - let v: Value = - serde_json::from_str(&line).map_err(|e| format!("non-JSON {line:?}: {e}"))?; - if v.get("id") == Some(&json!(id)) { - return Ok(v); - } + self.wire.send(&msg); + let v = self.wire.recv(TIMEOUT).ok_or_else(|| { + format!( + "no answer within {TIMEOUT:?} (or the server died): {:?}", + self.wire.refused + ) + })?; + if v.get("id") == Some(&json!(id)) { + Ok(v) + } else { + Err(format!("an answer to another request: {v}")) } } @@ -211,6 +203,20 @@ impl Mcp { } } +/// Every file under `dir`, with its bytes. +fn files_in(dir: &Path) -> std::collections::BTreeMap> { + let mut out = std::collections::BTreeMap::new(); + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + out.extend(files_in(&p)); + } else { + out.insert(p.clone(), std::fs::read(&p).unwrap_or_default()); + } + } + out +} + /// A small company with one of everything, so generated ids and names /// often find a record. fn fixture(m: &mut Mcp, p: &Places) { @@ -518,10 +524,9 @@ fn hostile_paths(p: &Places) -> Vec { s(&p.inbox.join("x.zip")), format!("{}/../inbox/receipt.pdf", s(&p.inbox)), format!("{}/./receipt.pdf", s(&p.inbox)), - s(&p.books.join("wanderbooks.toml")), - s(&p.books.join("books-data/journal.jsonl")), + s(&p.books.join("journal.jsonl")), s(&p.books.join("out.zip")), - s(&p.base.join("config/vault.key")), + s(&p.books.join("keys/legacy.key")), "relative.pdf".into(), "out/rel.zip".into(), String::new(), @@ -813,6 +818,9 @@ fn hostile_tool_arguments_never_stop_the_server() { let p = places("tools"); let mut m = Mcp::start(&p); fixture(&mut m, &p); + // The files on their way in, and the folder the server runs in: no + // call writes or changes one. + let outside = (files_in(&p.inbox), files_in(&p.sandbox)); let list = m.rpc("tools/list", json!({})); let tools: Vec<(String, Value)> = list["result"]["tools"] .as_array() @@ -882,8 +890,11 @@ fn hostile_tool_arguments_never_stop_the_server() { let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args})); check_answer(&mut m, &what, r).unwrap(); } + assert!( + outside == (files_in(&p.inbox), files_in(&p.sandbox)), + "a call wrote outside the books" + ); reopens(m, &p, "every tool"); - let _ = std::fs::remove_dir_all(&p.base); } /// INVARIANT 4: whatever the calls journaled, the books open again. A @@ -1219,7 +1230,6 @@ fn inputs_that_once_panicked_are_refused_or_answered() { ] { pinned(&mut m, tool, args); } - let _ = std::fs::remove_dir_all(&p.base); } /// INVARIANT 3: resources/read with generated URIs. @@ -1280,7 +1290,6 @@ fn hostile_resource_uris_never_stop_the_server() { }) .unwrap(); reopens(m.into_inner(), &p, "the resources"); - let _ = std::fs::remove_dir_all(&p.base); } fn b64(bytes: &[u8]) -> String { @@ -1492,8 +1501,9 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec { let mut failures: Vec = Vec::new(); let name = dir.file_name().unwrap().to_string_lossy().to_string(); let p = places(&format!("old-{name}")); - copy_books(dir, &p.books.join("books-data")); - let mut m = Mcp::start(&p); + // As an earlier version left them in the data root. + copy_books(dir, &p.books); + let mut m = Mcp::start_old(&p, true); let list = m.rpc("tools/list", json!({})); let tools: Vec<(String, Value)> = list["result"]["tools"] .as_array() @@ -1526,7 +1536,7 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec { if let Err(e) = check_answer(&mut m, &what, r) { failures.push(e.to_string()); drop(m); - m = Mcp::start(&p); + m = Mcp::start_old(&p, false); } } for uri in [ @@ -1540,12 +1550,12 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec { if let Err(e) = check_answer(&mut m, &format!("{name}: {uri}"), r) { failures.push(e.to_string()); drop(m); - m = Mcp::start(&p); + m = Mcp::start_old(&p, false); } } // Whatever the calls wrote, the books reopen. drop(m); - let mut m = Mcp::start(&p); + let mut m = Mcp::start_old(&p, false); let r = m.try_rpc( "tools/call", json!({"name": "books.chart", "arguments": {}}), @@ -1554,7 +1564,6 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec { failures.push(e.to_string()); } drop(m); - let _ = std::fs::remove_dir_all(&p.base); failures } diff --git a/crates/ledger-mcp/tests/http_concurrency.rs b/crates/ledger-cli/tests/http_concurrency.rs similarity index 59% rename from crates/ledger-mcp/tests/http_concurrency.rs rename to crates/ledger-cli/tests/http_concurrency.rs index 6b607d6..7cdc74a 100644 --- a/crates/ledger-mcp/tests/http_concurrency.rs +++ b/crates/ledger-cli/tests/http_concurrency.rs @@ -1,50 +1,20 @@ -//! Concurrency over HTTP: several agents at once against one store. +//! Concurrency: several agents at once against one book's store. //! -//! In `--http` mode every connection gets its own server over one shared, -//! locked store (main.rs `serve_http`). Oracle (server.rs INVARIANTS 2 and +//! 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 rmcp::ServiceExt; -use rmcp::model::{CallToolRequestParams, ClientConfig}; -use rmcp::service::{RoleClient, RunningService}; -use rmcp::transport::StreamableHttpClientTransport; -use serde_json::{Value, json}; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; +use common::{Client, call}; +use serde_json::json; -type Client = RunningService; +mod common; const CLIENTS: usize = 6; const ENTRIES: usize = 12; -struct Server(Child); -impl Drop for Server { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn free_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() -} - fn b64(bytes: &[u8]) -> String { const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::new(); @@ -63,38 +33,6 @@ fn b64(bytes: &[u8]) -> String { out } -async fn connect(url: &str) -> Client { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - let transport = StreamableHttpClientTransport::from_uri(url.to_string()); - match ClientConfig::default().serve(transport).await { - Ok(c) => return c, - Err(e) if Instant::now() > deadline => panic!("connect: {e}"), - Err(_) => tokio::time::sleep(Duration::from_millis(100)).await, - } - } -} - -/// Call a tool: Ok(structured result) or Err(the tool's error text). -async fn call(c: &Client, tool: &str, args: Value) -> Result { - let args = args.as_object().cloned().unwrap_or_default(); - let r = c - .call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)) - .await - .map_err(|e| format!("protocol: {e}"))?; - let text = r - .content - .iter() - .find_map(|c| c.as_text().map(|t| t.text.clone())) - .unwrap_or_default(); - if r.is_error == Some(true) { - Err(text) - } else { - Ok(r.structured_content - .unwrap_or_else(|| serde_json::from_str(&text).unwrap())) - } -} - async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 { let e = call( c, @@ -130,33 +68,24 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn agents_posting_at_once_leave_whole_books() { - let dir = std::env::temp_dir().join(format!("ledger-mcp-http-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - // Declared before the server, so dropped after it: pass or panic. - let _cleanup = TestDir(dir.clone()); - let toml = dir.join("wanderbooks.toml"); - std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap(); - let data = dir.join("books-data"); - let port = free_port(); - let addr = format!("127.0.0.1:{port}"); - let bin = env!("CARGO_BIN_EXE_ledger-mcp"); - let server = Server( - Command::new(bin) - .arg("--data-dir") - .arg(&data) - .arg("--books-toml") - .arg(&toml) - .arg("--http") - .arg(&addr) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(), - ); - let url = format!("http://{addr}/mcp"); - let setup = connect(&url).await; + // 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"), @@ -178,10 +107,10 @@ async fn agents_posting_at_once_leave_whole_books() { let mut tasks = Vec::new(); for n in 0..CLIENTS { - let url = url.clone(); + let (run, token) = (run.clone(), token.clone()); let contested = contested.clone(); tasks.push(tokio::spawn(async move { - let c = connect(&url).await; + 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. @@ -256,26 +185,10 @@ async fn agents_posting_at_once_leave_whole_books() { } let head = v["head"].clone(); let _ = setup.cancel().await; - drop(server); // A restart replays the same books. - let port = free_port(); - let addr = format!("127.0.0.1:{port}"); - let _server = Server( - Command::new(bin) - .arg("--data-dir") - .arg(&data) - .arg("--books-toml") - .arg(&toml) - .arg("--http") - .arg(&addr) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(), - ); - let c = connect(&format!("http://{addr}/mcp")).await; + 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!( @@ -283,5 +196,4 @@ async fn agents_posting_at_once_leave_whole_books() { "the restarted server sees the same journal" ); let _ = c.cancel().await; - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index d9d7ada..91c500c 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -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; - -/// 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>, -} - -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 { - 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) -> Result { - 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 { - 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 = 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 { - 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, 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. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-cli/tests/mcp_journeys.rs similarity index 89% rename from crates/ledger-mcp/tests/mcp_stdio.rs rename to crates/ledger-cli/tests/mcp_journeys.rs index fa66d98..674dd89 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -1,73 +1,30 @@ -//! End-to-end smoke test: drive the real `ledger-mcp` binary over stdio -//! with raw JSON-RPC, exercising draft → preview → receipt policy → post. +//! End-to-end journeys: drive a real `wanderbooks server` with raw +//! JSON-RPC, as an agent reaches it (common.rs), exercising draft → +//! preview → receipt policy → post, and the rest of an agent's day. +use common::{Books, Wire}; use serde_json::{Value, json}; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, Command, Stdio}; +mod common; + +/// One agent's session with the test's books, over the books server's +/// socket (common.rs). struct Mcp { - stdin: ChildStdin, - stdout: BufReader, + wire: Wire, next_id: u64, - child: Child, -} - -/// Closing stdin ends the server; wait for it to exit, so the next server -/// on the same books never races it for the writer lock. -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } } impl Mcp { - fn spawn(data_dir: &std::path::Path) -> Self { - Self::spawn_with(data_dir, &[]) - } - - fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self { - Self::spawn_env(data_dir, extra, &[]) - } - - fn spawn_env( - data_dir: &std::path::Path, - extra: &[&std::ffi::OsStr], - env: &[(&str, &std::path::Path)], - ) -> Self { - let bin = env!("CARGO_BIN_EXE_ledger-mcp"); - let mut child = Command::new(bin) - .arg("--data-dir") - .arg(data_dir) - .args(extra) - .envs(env.iter().map(|(k, v)| (*k, v.as_os_str()))) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); + /// A new session with `books` (initialized by the test itself). + fn spawn(books: &Books) -> Self { Self { - stdin, - stdout, + wire: books.wire(), next_id: 1, - child, } } fn request(&mut self, method: &str, params: Value) -> Value { - let id = self.next_id; - self.next_id += 1; - let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); - let mut line = String::new(); - self.stdout - .read_line(&mut line) - .expect("read response line"); - let resp: Value = serde_json::from_str(&line).expect("parse response"); - assert_eq!(resp["id"], id, "id mismatch in {resp}"); + let resp = self.request_raw(method, params); assert!( resp.get("error").is_none(), "JSON-RPC error for {method}: {}", @@ -80,21 +37,18 @@ impl Mcp { let id = self.next_id; self.next_id += 1; let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); - let mut line = String::new(); - self.stdout - .read_line(&mut line) - .expect("read response line"); - let resp: Value = serde_json::from_str(&line).expect("parse response"); + 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}"); resp } fn notify(&mut self, method: &str) { - let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); + self.wire + .send(&json!({"jsonrpc": "2.0", "method": method, "params": {}})); } /// Call a tool; panic on tool error. Returns structuredContent. @@ -124,41 +78,19 @@ impl Mcp { ); result["content"][0]["text"].as_str().unwrap().to_string() } -} -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 + /// The bytes of a file a tool handed over (a `HandedFile`): fetched by + /// its download, matching the size and SHA-256 it states. + fn handed(&mut self, file: &Value) -> Vec { + assert!(file["expires_at"].is_string(), "{file}"); + self.wire.fetch(file) } } -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn test_dir(name: &str) -> TestDir { - let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) -} - #[test] -fn mcp_end_to_end_over_stdio() { - let dir = test_dir("main"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_end_to_end() { + let books = Books::new("main"); + let mut mcp = Mcp::spawn(&books); // Initialize: the server identifies itself and delivers the contract. let init = mcp.request( @@ -178,7 +110,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 116, "expected 116 tools, got {}", tools.len()); + assert_eq!(tools.len(), 118, "expected 118 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); @@ -330,9 +262,9 @@ fn mcp_end_to_end_over_stdio() { } #[test] -fn mcp_assets_end_to_end_over_stdio() { - let dir = test_dir("assets"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_assets_end_to_end() { + let books = Books::new("assets"); + let mut mcp = Mcp::spawn(&books); let init = mcp.request( "initialize", json!({ @@ -501,8 +433,8 @@ fn mcp_assets_end_to_end_over_stdio() { #[test] fn mcp_receipt_policy_rejects_split_entries() { - let dir = test_dir("split"); - let mut mcp = Mcp::spawn(&dir); + let books = Books::new("split"); + let mut mcp = Mcp::spawn(&books); let init = mcp.request( "initialize", json!({ @@ -592,9 +524,9 @@ fn mcp_receipt_policy_rejects_split_entries() { } #[test] -fn mcp_receipt_corrections_over_stdio() { - let dir = test_dir("receipt-corrections"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_receipt_corrections() { + let books = Books::new("receipt-corrections"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -729,9 +661,9 @@ fn mcp_receipt_corrections_over_stdio() { } #[test] -fn mcp_resources_over_stdio() { - let dir = test_dir("resources"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_resources() { + let books = Books::new("resources"); + let mut mcp = Mcp::spawn(&books); let init = mcp.request( "initialize", json!({ @@ -805,16 +737,9 @@ fn init(mcp: &mut Mcp) -> Value { /// books.get_receipt (S7 of the P0 review, 2026-10-16). #[test] fn tin_bearing_files_are_refused_when_given_back_to_be_filed() { - let dir = test_dir("tin-files"); - let books = dir.join("books"); - let config = dir.join("config"); - std::fs::create_dir_all(&config).unwrap(); + let books = Books::new("tin-files"); let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846"; - let mut mcp = Mcp::spawn_env( - &books.join("books-data"), - &[], - &[("XDG_CONFIG_HOME", &config)], - ); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); mcp.call( "books.add_account", @@ -835,9 +760,9 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() { "tin": "291-12-2846", "tin_type": "ssn", "w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}), ); - // Each comes back inline over stdio. + // Each comes back by its download. let got = mcp.call("books.get_w9", json!({"party": "Acme"})); - let w9_back = handed(&got["file"]); + let w9_back = mcp.handed(&got["file"]); assert_eq!(w9_back, w9, "the W-9 file, as stored"); let ws = mcp.call( "books.vendor_1099", @@ -849,10 +774,10 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() { json!({"cash_accounts": ["1000"]}), ); assert_eq!(pkg["tins_included"], true); - let handed_over = [w9_back, handed(&ws["csv"]), handed(&pkg["file"])]; + let handed_over = [w9_back, mcp.handed(&ws["csv"]), mcp.handed(&pkg["file"])]; // Given back, each is refused by its bytes, by every way a file is // filed. - let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(); + let journal = books.journal(); for (n, bytes) in handed_over.iter().enumerate() { for (tool, args) in [ ( @@ -876,17 +801,13 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() { ); } } - assert_eq!( - std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(), - journal, - "a refusal stored something" - ); + assert_eq!(books.journal(), journal, "a refusal stored something"); // Without TINs, the 1099 worksheet is an ordinary file: it may be // filed like any other. let plain = mcp.call("books.vendor_1099", json!({"year": 2026, "csv": true})); assert!(plain["warning"].is_null(), "{plain}"); assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv"); - let bytes = handed(&plain["csv"]); + let bytes = mcp.handed(&plain["csv"]); mcp.call( "books.file_document", json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv", @@ -899,20 +820,21 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() { /// uncut, so no account the rule names is out of the agent's reach. #[test] fn every_receipt_account_left_out_of_the_connect_text_is_in_books_settings() { - let dir = test_dir("receipt-accounts"); let long = "6000-A-CODE-LONGER-THAN-ANY-RULE-SHOWS-IT"; let required: Vec = std::iter::once(long.to_string()) .chain((1..20).map(|i| format!("{}", 6000 + i))) .collect(); let exempt: Vec = (0..12).map(|i| format!("{}", 1100 + i)).collect(); - std::fs::write( - dir.join("config.json"), - json!({"receipt_policy": {"require_receipts_above": "0.00", - "receipt_required_accounts": required, "receipt_exempt_accounts": exempt}}) - .to_string(), - ) - .unwrap(); - let mut mcp = Mcp::spawn(&dir); + let books = Books::new("receipt-accounts"); + books.settle_with( + "", + Some( + &json!({"receipt_policy": {"require_receipts_above": "0.00", + "receipt_required_accounts": required, "receipt_exempt_accounts": exempt}}) + .to_string(), + ), + ); + let mut mcp = Mcp::spawn(&books); let init = mcp.request( "initialize", json!({ @@ -973,8 +895,8 @@ fn digest_resource_bare_reads() { /// One run of `digest_resource_bare_reads`: false if the server's month /// turned during it (nothing is checked then). fn digest_bare_reads_once(attempt: u32) -> bool { - let dir = test_dir(&format!("digest-bare-{attempt}")); - let mut mcp = Mcp::spawn(&dir.join("books-data")); + let books = Books::new(&format!("digest-bare-{attempt}")); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -1042,9 +964,9 @@ fn digest_bare_reads_once(attempt: u32) -> bool { /// The handbook is one tool and one resource per section, with the /// receipt rule in force rendered in; the connect text points to it. #[test] -fn handbook_tool_and_resources_over_stdio() { - let dir = test_dir("handbook"); - let mut mcp = Mcp::spawn(&dir.join("books-data")); +fn handbook_tool_and_resources() { + let books = Books::new("handbook"); + let mut mcp = Mcp::spawn(&books); let init = mcp.request( "initialize", json!({ @@ -1159,25 +1081,10 @@ fn base64_like(bytes: &[u8]) -> String { out } -/// The bytes of a file a tool handed over (a `HandedFile`): over stdio -/// they come inline, with no download command, and match the size and -/// SHA-256 it states. -fn handed(file: &Value) -> Vec { - use base64::Engine; - assert!(file.get("download").is_none(), "{file}"); - assert!(file.get("expires_at").is_none(), "{file}"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(file["content_base64"].as_str().expect("content_base64")) - .unwrap(); - assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); - assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}"); - bytes -} - #[test] -fn mcp_import_export_over_stdio() { - let dir = test_dir("import_export"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_import_export() { + let books = Books::new("import_export"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -1221,11 +1128,11 @@ fn mcp_import_export_over_stdio() { } #[test] -fn mcp_statement_pdfs_and_cpa_package_over_stdio() { +fn mcp_statement_pdfs_and_cpa_package() { use base64::Engine; let b64 = base64::engine::general_purpose::STANDARD; - let dir = test_dir("statement-pdfs"); - let mut mcp = Mcp::spawn(&dir); + let books = Books::new("statement-pdfs"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -1317,7 +1224,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() { assert_eq!(pkg["file"]["file_name"], "cpa-package-2026-10-31.zip"); assert_eq!(pkg["file"]["mime_type"], "application/zip"); assert_eq!(pkg["tins_included"], false); - let zip_bytes = handed(&pkg["file"]); + let zip_bytes = mcp.handed(&pkg["file"]); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap(); let mut names: Vec = (0..zip.len()) .map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()) @@ -1369,26 +1276,22 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() { "books.export_cpa_package", json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}), ); - let light = zip::ZipArchive::new(std::io::Cursor::new(handed(&light["file"]))).unwrap(); + let light = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&light["file"]))).unwrap(); assert_eq!(light.len(), rest.len()); } #[test] -fn mcp_period_statements_over_stdio() { - let dir = test_dir("period-statements"); +fn mcp_period_statements() { // A rejected receipt policy would show up as a failed post below: the - // toml lowers the threshold from 25.00 to 5.00. - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - "[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n", - ) - .unwrap(); - let data = dir.join("books-data"); - let mut mcp = Mcp::spawn_with( - &data, - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + // settings lower the threshold from 25.00 to 5.00. + let books = Books::with( + "period-statements", + "USD", + Some( + "[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n", + ), ); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -1608,7 +1511,7 @@ fn mcp_period_statements_over_stdio() { } #[test] -fn mcp_partnership_from_template_over_stdio() { +fn mcp_partnership_from_template() { let init = |mcp: &mut Mcp| { mcp.request( "initialize", @@ -1621,31 +1524,42 @@ fn mcp_partnership_from_template_over_stdio() { mcp.notify("notifications/initialized"); }; // 1. Ask for a template (any server will do). - let dir = test_dir("template"); - let mut scratch = Mcp::spawn(&dir.join("scratch")); + let scratch_books = Books::new("template-scratch"); + let mut scratch = Mcp::spawn(&scratch_books); init(&mut scratch); let t = scratch.call( "books.chart_template", json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}), ); - let settings = t["settings_toml"].as_str().unwrap(); - assert!(settings.contains("distribution_account = \"3101\"")); + let settings = t["settings"].clone(); + let members = settings + .as_array() + .unwrap() + .iter() + .find(|c| c["key"] == "company.members") + .unwrap_or_else(|| panic!("{settings}")); + assert_eq!( + members["value"][1]["distribution_account"], + json!("3101"), + "{settings}" + ); let err = scratch.call_err("books.chart_template", json!({"entity_type": "LLC"})); assert!(err.contains("entity_type"), "{err}"); - // 2. Its settings become the company config; its chart, the chart. - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"), - ) - .unwrap(); - let mut mcp = Mcp::spawn_with( - &dir.join("books-data"), - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + // 2. Its chart becomes the chart; its settings, the company's + // (books.set_setting, the one way settings change). + let books = Books::with( + "template", + "USD", + Some("[books]\nreceipt_threshold = \"10000\"\n"), ); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]})); + mcp.call( + "books.set_setting", + json!({"changes": settings, "decided_by": "Ann"}), + ); let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| { let e = mcp.call( "books.draft_entry", @@ -1730,30 +1644,29 @@ fn mcp_partnership_from_template_over_stdio() { /// 0.01 each. #[test] fn close_period_shows_each_members_running_split() { - let dir = test_dir("members-split"); - let mut scratch = Mcp::spawn(&dir.join("scratch")); + let scratch_books = Books::new("members-split-scratch"); + let mut scratch = Mcp::spawn(&scratch_books); init(&mut scratch); let t = scratch.call( "books.chart_template", json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}), ); - let settings = t["settings_toml"].as_str().unwrap().to_string(); - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"), - ) - .unwrap(); - let spawn = || { - let mut mcp = Mcp::spawn_with( - &dir.join("books-data"), - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], - ); + let books = Books::with( + "members-split", + "USD", + Some("[books]\nreceipt_threshold = \"10000\"\n"), + ); + let spawn = |books: &Books| { + let mut mcp = Mcp::spawn(books); init(&mut mcp); mcp }; - let mut mcp = spawn(); + let mut mcp = spawn(&books); mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]})); + mcp.call( + "books.set_setting", + json!({"changes": t["settings"], "decided_by": "Ann"}), + ); let sale = |mcp: &mut Mcp, date: &str| { let e = mcp.call( "books.draft_entry", @@ -1825,7 +1738,8 @@ fn close_period_shows_each_members_running_split() { ); drop(mcp); - let mut mcp = spawn(); + books.restart(); + let mut mcp = spawn(&books); assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok"); let periods = mcp.call("books.list_periods", json!({})); let rules: Vec<&Value> = periods["periods"] @@ -1855,35 +1769,36 @@ fn close_period_shows_each_members_running_split() { /// old-books fixture x_member_closes (two members at 50%, A with a /// distributions account; three closes untagged, three /// "largest_remainder", then "cumulative" broken by a close to retained -/// earnings), opened by the server. list_periods names each close's rule; -/// December's first close under the rule since P3 counts from close 1 -/// and carries the true-up, the same in the preview as once confirmed. +/// earnings), left in a data root in P2's format. list_periods names each +/// close's rule; December's first close under the rule since P3 counts +/// from close 1 and carries the true-up, refused (naming its feature) +/// until the owner upgrades the books, then the same as previewed. /// By hand (the fixture's writer): the members closed 0.12, A 0.07 and B /// 0.05 of it (exact 0.06 each). December earns 0.02: the running 0.14 /// splits 0.07 each, less 0.07 / 0.05 given, so Ann gets 0.00 and Bo /// 0.02, both at their exact 0.07. #[test] fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() { - let dir = test_dir("members-old"); + // Books P1 kept, which P2 moved to its journal format (format 2, none + // of the features since), left in a data root, with their members set + // as an agent sets them: books waiting for their format upgrade. let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl"); - let books = dir.join("books-data"); - std::fs::create_dir_all(&books).unwrap(); - std::fs::copy(&fixture, books.join("journal.jsonl")).unwrap(); - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, + .join("../ledger-store/tests/fixtures/old-books/x_member_closes"); + let books = Books::placed("members-old", &fixture); + books.stop(); + ledger_store::Store::open(&books.dir()) + .unwrap() + .declare_journal_format(2, &[]) + .unwrap(); + books.start(); + books.settle( "[company]\ntax_form = \"1065\"\n\n\ [[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ distribution_account = \"3100\"\n\n\ [[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\n\ [books]\nreceipt_threshold = \"10000\"\n", - ) - .unwrap(); - let mut mcp = Mcp::spawn_with( - &books, - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], ); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); let rules = |mcp: &mut Mcp| -> Vec { mcp.call("books.list_periods", json!({}))["periods"] @@ -1956,18 +1871,17 @@ fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() { "{refused}" ); // The owner's go-ahead, as the app gives it (ledger-gui owner.rs, after - // the passphrase and a verified backup): the books move to this - // version's format. + // the passphrase and a verified backup): the server stopped, the books + // moved to this version's format, and served again. drop(mcp); - ledger_store::Store::open(&books) + books.stop(); + ledger_store::Store::open(&books.dir()) .unwrap() .upgrade_format() .unwrap() .expect("old books wait for the upgrade"); - let mut mcp = Mcp::spawn_with( - &books, - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], - ); + books.start(); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); let closed = mcp.call( "books.close_period", @@ -1981,8 +1895,8 @@ fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() { } #[test] -fn mcp_schedules_and_recurring_over_stdio() { - let dir = test_dir("schedules"); +fn mcp_schedules_and_recurring() { + let books = Books::new("schedules"); let init = |mcp: &mut Mcp| { mcp.request( "initialize", @@ -1994,7 +1908,7 @@ fn mcp_schedules_and_recurring_over_stdio() { ); mcp.notify("notifications/initialized"); }; - let mut mcp = Mcp::spawn(&dir); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); for (code, name, typ) in [ ("1000", "Bank", "asset"), @@ -2088,7 +2002,8 @@ fn mcp_schedules_and_recurring_over_stdio() { drop(mcp); // Restart: everything replays from the journal. - let mut mcp = Mcp::spawn(&dir); + books.restart(); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); let list = mcp.call("books.list_schedules", json!({})); assert_eq!(list["schedules"][0]["remaining"], "333.34"); @@ -2102,9 +2017,9 @@ fn mcp_schedules_and_recurring_over_stdio() { } #[test] -fn mcp_payout_gross_up_over_stdio() { - let dir = test_dir("payout"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_payout_gross_up() { + let books = Books::new("payout"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2206,8 +2121,8 @@ fn mcp_payout_gross_up_over_stdio() { } #[test] -fn mcp_vendor_1099_over_stdio() { - let dir = test_dir("form1099"); +fn mcp_vendor_1099() { + let books = Books::new("form1099"); let init = |mcp: &mut Mcp| { mcp.request( "initialize", @@ -2219,7 +2134,7 @@ fn mcp_vendor_1099_over_stdio() { ); mcp.notify("notifications/initialized"); }; - let mut mcp = Mcp::spawn(&dir); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); for (code, name, typ) in [ ("1000", "Bank", "asset"), @@ -2298,7 +2213,8 @@ fn mcp_vendor_1099_over_stdio() { ); drop(mcp); - let mut mcp = Mcp::spawn(&dir); + books.restart(); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); let r = mcp.call("books.vendor_1099", json!({"year": 2026})); assert_eq!(r["vendors"][0]["paid"], "2500.00"); @@ -2327,13 +2243,13 @@ fn mcp_vendor_1099_over_stdio() { assert_eq!(vendors["vendors"][0]["tin_on_file"], true); // The server made the books' ops/ as they opened: a viewer open from // before the first books.show is seen by it. - let viewer = ledger_store::ops::Presence::hold(&dir).expect("ops/ made at start"); + let viewer = ledger_store::ops::Presence::hold(&books.dir()).expect("ops/ made at start"); // Show the entry in the viewer: a request in the books' ops/, never // journaled. let shown = mcp.call("books.show", json!({"entry_id": id})); assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}"))); assert_eq!(shown["viewer_open"], true); - let cmd = ledger_store::ops::read(&dir).expect("request written"); + let cmd = ledger_store::ops::read(&books.dir()).expect("request written"); assert_eq!( cmd.target, ledger_store::ops::ViewerTarget::Entry { entry_id: id } @@ -2356,13 +2272,12 @@ fn mcp_vendor_1099_over_stdio() { #[test] fn mcp_receipts_always_required_when_threshold_is_zero() { - let dir = test_dir("always-receipts"); - let toml = dir.join("wanderbooks.toml"); - std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap(); - let mut mcp = Mcp::spawn_with( - &dir.join("books-data"), - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + let books = Books::with( + "always-receipts", + "USD", + Some("[books]\nreceipt_threshold = \"0.00\"\n"), ); + let mut mcp = Mcp::spawn(&books); let init = mcp.request( "initialize", json!({ @@ -2405,32 +2320,6 @@ fn mcp_receipts_always_required_when_threshold_is_zero() { ); } -/// A client reconnecting starts a new server while the old one still has -/// the books: the new one waits for the lock instead of dying. -#[test] -fn a_restarted_server_waits_for_the_old_one_to_let_go() { - let dir = test_dir("restart-race"); - let init = |mcp: &mut Mcp| { - mcp.request( - "initialize", - json!({ - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "smoke-test", "version": "0.1.0"}, - }), - ); - mcp.notify("notifications/initialized"); - }; - let mut old = Mcp::spawn(&dir); - init(&mut old); - let mut new = Mcp::spawn(&dir); - std::thread::sleep(std::time::Duration::from_millis(500)); - drop(old); - init(&mut new); - let chart = new.call("books.chart", json!({})); - assert!(chart.is_object()); -} - /// `books.cash_flow` with no accounts passed reads the roles from /// `[books]` in wanderbooks.toml (rules.md B-14): the expense the owners /// paid through 2100 is borrowed from them and spent on its date, each @@ -2439,17 +2328,14 @@ fn a_restarted_server_waits_for_the_old_one_to_let_go() { /// method's, and it foots. #[test] fn cash_flow_reads_its_account_roles_from_the_books_settings() { - let dir = test_dir("cash-roles"); - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - "[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n", - ) - .unwrap(); - let mut mcp = Mcp::spawn_with( - &dir.join("books-data"), - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + let books = Books::with( + "cash-roles", + "USD", + Some( + "[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n", + ), ); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2547,8 +2433,8 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() { /// naming the setting and the fix, never a statement on a wrong partition. #[test] fn cash_flow_refuses_contradictory_roles() { - let dir = test_dir("cash-refuse"); - let mut mcp = Mcp::spawn(&dir); + let books = Books::new("cash-refuse"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2594,8 +2480,8 @@ fn cash_flow_refuses_contradictory_roles() { /// parent listed after its child). #[test] fn a_chart_file_with_a_bad_row_imports_nothing() { - let dir = test_dir("chart-atomic"); - let mut mcp = Mcp::spawn(&dir); + let books = Books::new("chart-atomic"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2636,8 +2522,8 @@ fn a_chart_file_with_a_bad_row_imports_nothing() { /// transaction that can't become a draft refuses the whole file. #[test] fn bank_import_reads_dates_rules_and_files_whole() { - let dir = test_dir("bank-import"); - let mut mcp = Mcp::spawn(&dir); + let books = Books::new("bank-import"); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2699,7 +2585,7 @@ fn bank_import_reads_dates_rules_and_files_whole() { ), ); assert!(err.contains("closed"), "{err}"); - let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap(); + let journal = String::from_utf8(books.journal()).unwrap(); assert!( !journal.contains("2026-06-01"), "the first row was journaled" @@ -2711,17 +2597,12 @@ fn bank_import_reads_dates_rules_and_files_whole() { /// and settings naming the old code are listed. #[test] fn renumbering_an_account_keeps_its_history() { - let dir = test_dir("renumber"); - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - "[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", - ) - .unwrap(); - let mut mcp = Mcp::spawn_with( - &dir.join("books-data"), - &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + let books = Books::with( + "renumber", + "USD", + Some("[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n"), ); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2793,16 +2674,17 @@ fn renumbering_an_account_keeps_its_history() { .iter() .any(|a| a["code"] == "6100") ); - // The cash account is named in wanderbooks.toml: listed. + // The cash account is named in the books' settings: listed. let out = mcp.call( "books.renumber_account", json!({"account": "1000", "code": "1010"}), ); - assert_eq!(out["config_mentions"], json!(["[books] cash_accounts"])); + assert_eq!(out["config_mentions"], json!(["books.cash_accounts"])); drop(mcp); // A restart replays the journal to the same chart. - let mut mcp = Mcp::spawn(&dir.join("books-data")); + books.restart(); + let mut mcp = Mcp::spawn(&books); mcp.request( "initialize", json!({ @@ -2829,19 +2711,15 @@ fn renumbering_an_account_keeps_its_history() { ); } -/// A server on books whose settings come from `toml`, initialized. -fn served(dir: &std::path::Path, toml: &str) -> Mcp { - let path = dir.join("wanderbooks.toml"); - std::fs::write(&path, toml).unwrap(); - let mut mcp = Mcp::spawn_with( - &dir.join("books-data"), - &[std::ffi::OsStr::new("--books-toml"), path.as_os_str()], - ); +/// A session with `books`, settings set from `toml` first, initialized. +fn served(books: &Books, toml: &str) -> Mcp { + books.settle(toml); + let mut mcp = Mcp::spawn(books); init(&mut mcp); mcp } -/// Post one balanced two-line entry over stdio. +/// Post one balanced two-line entry. fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64 { let e = mcp.call( "books.draft_entry", @@ -2873,9 +2751,9 @@ fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64 /// after, so midnight can't fail it). #[test] fn one_default_period_everywhere_defect_16() { - let dir = test_dir("one-default"); + let books = Books::new("one-default"); let mut mcp = served( - &dir, + &books, "[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", ); mcp.call( @@ -2941,7 +2819,7 @@ fn one_default_period_everywhere_defect_16() { "books.export_cpa_package", json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}), ); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&pkg["file"]))).unwrap(); let mut readme = String::new(); std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap(); assert!( @@ -3003,9 +2881,9 @@ fn iif_docnums(iif: &str) -> Vec { /// one before its fiscal year included. #[test] fn the_default_period_never_shortens_the_iif_or_the_package() { - let dir = test_dir("default-vs-iif"); + let books = Books::new("default-vs-iif"); let mut mcp = served( - &dir, + &books, "[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", ); mcp.call( @@ -3080,7 +2958,7 @@ fn the_default_period_never_shortens_the_iif_or_the_package() { "books.export_cpa_package", json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}), ); - let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&pkg["file"]))).unwrap(); let mut read = |name: &str| { let mut text = String::new(); std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut text).unwrap(); @@ -3103,9 +2981,9 @@ fn the_default_period_never_shortens_the_iif_or_the_package() { /// a restarted server reads the new name back. #[test] fn rename_account_end_to_end_defect_15() { - let dir = test_dir("rename-e2e"); + let books = Books::new("rename-e2e"); let mut mcp = served( - &dir, + &books, "[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", ); mcp.call( @@ -3191,7 +3069,8 @@ fn rename_account_end_to_end_defect_15() { ); assert!(!out["notes"].to_string().contains("cash account"), "{out}"); drop(mcp); - let mut mcp = Mcp::spawn(&dir.join("books-data")); + books.restart(); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); let chart = mcp.call("books.chart", json!({})); let names: Vec<(String, String)> = chart["accounts"] @@ -3228,9 +3107,9 @@ fn rename_account_end_to_end_defect_15() { /// name is listed, and one that doesn't isn't. #[test] fn rename_says_what_the_new_name_moves_defect_15() { - let dir = test_dir("rename-sets"); + let books = Books::new("rename-sets"); let mut mcp = served( - &dir, + &books, "[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n", ); mcp.call( @@ -3292,9 +3171,9 @@ fn rename_says_what_the_new_name_moves_defect_15() { /// 12 months: March 100 × 17/31 = 54.84, then 100.00 a month, the last /// March 45.16; through April 30, 154.84 posted, 1,045.16 left. #[test] -fn mcp_daily_schedule_over_stdio() { - let dir = test_dir("daily-schedule"); - let mut mcp = Mcp::spawn(&dir); +fn mcp_daily_schedule() { + let books = Books::new("daily-schedule"); + let mut mcp = Mcp::spawn(&books); init(&mut mcp); for (code, name, typ) in [ ("1300", "Prepaid Expenses", "asset"), @@ -3350,13 +3229,7 @@ fn mcp_daily_schedule_over_stdio() { assert_eq!(one["next"], "2026-05-31"); assert_eq!(one["remaining"], "1045.16"); - // The books take their settings in (there was no file: the defaults), - // then new schedules are set to equal months. - let done = mcp.call( - "books.import_settings", - json!({"toml_text": "", "decided_by": "Owner", "confirmed": true}), - ); - assert_eq!(done["status"], "imported"); + // New schedules are set to equal months. mcp.call( "books.set_setting", json!({"changes": [{"key": "books.schedule_convention", "value": "monthly_even"}], diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs index 2b2e583..f9134e9 100644 --- a/crates/ledger-cli/tests/one_executable.rs +++ b/crates/ledger-cli/tests/one_executable.rs @@ -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, - 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-") diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-cli/tests/oracle_mcp.rs similarity index 95% rename from crates/ledger-mcp/tests/oracle_mcp.rs rename to crates/ledger-cli/tests/oracle_mcp.rs index d091758..fe4c078 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-cli/tests/oracle_mcp.rs @@ -1,4 +1,5 @@ -//! Oracle tests for `ledger-mcp`, driven over stdio against the real binary. +//! Oracle tests for the books server's tools, driven through a real +//! `wanderbooks server` as an agent reaches it (common.rs). //! //! Every number a tool reports is checked against what `ledger-core` (or //! `ledger-store`) computes for the same books, computed here from their @@ -7,10 +8,12 @@ //! JSON deserializes, zips and XLSX unzip, IIF splits into its records. //! //! The books are mostly built in-process with [`Store`] (fast, and the -//! oracle sees exactly the books the server will open), then the store is -//! closed and the server started on the same data directory. +//! oracle sees exactly the books the server will open), in the books' +//! folder of a data root of the test's own; then the store is closed and +//! the server started on that root. use base64::Engine as _; +use common::{Place, Wire}; use ledger_core::{ AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital, Money, NewAsset, Side, depreciation_schedule, reports, @@ -20,15 +23,11 @@ use ledger_store::ops::{ViewerCommand, ViewerTarget}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashMap}; -use std::io::{BufRead, BufReader, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; +use std::io::{Read, Write}; +use std::path::PathBuf; use std::str::FromStr; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, channel}; -use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; // ---------------------------------------------------------------- harness @@ -40,110 +39,47 @@ enum Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, - run: PathBuf, + /// The books' folder in the data root. + data: PathBuf, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-oracle-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) + Place::new(&format!("oracle-{name}")) } /// `wanderbooks.toml` for two 50/50 members on 3000/3001, cash 1000, /// financing 2100, with the given receipt threshold. -fn books_toml(dir: &Path, threshold: &str) -> PathBuf { - let path = dir.join(format!("wanderbooks-{threshold}.toml")); - std::fs::write( - &path, - format!( - "[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"01-01\"\n\ - [[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ - [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ - [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n\ - financing_accounts = [\"2100\"]\n" - ), +fn books_toml(threshold: &str) -> String { + format!( + "[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"01-01\"\n\ + [[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ + [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n\ + financing_accounts = [\"2100\"]\n" ) - .unwrap(); - path } -fn data_dir(dir: &Path) -> PathBuf { - dir.join("books-data") +/// The books' folder in `dir`'s data root, where the oracle writes the +/// books the server then opens. +fn data_dir(dir: &TestDir) -> PathBuf { + dir.books_data() } impl Mcp { - fn start(dir: &Path, threshold: &str) -> Self { - let toml = books_toml(dir, threshold); - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(data_dir(dir)) - .arg("--books-toml") - .arg(&toml) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + /// Start (or restart) `dir`'s server, and a session whose books keep + /// [`books_toml`]'s settings with this receipt threshold (set as an + /// agent sets them: a value already set changes nothing). + fn start(dir: &TestDir, threshold: &str) -> Self { + let wire = dir.start(None); let mut m = Self { - stdin, - rx, - child, + wire, next_id: 1, - run, + data: dir.books_data(), }; let init = m.rpc( "initialize", @@ -152,12 +88,16 @@ impl Mcp { ); assert!(init.get("result").is_some(), "initialize failed: {init}"); m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string()); + m.ok( + "books.set_setting", + json!({"changes": common::settings_changes(&books_toml(threshold), None, "USD"), + "decided_by": "Test Owner"}), + ); m } fn send(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -166,18 +106,12 @@ impl Mcp { self.send( &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(), ); - loop { - let line = self - .rx - .recv_timeout(TIMEOUT) - .unwrap_or_else(|_| panic!("no response to {method}")); - let v: Value = serde_json::from_str(&line).expect("server wrote JSON"); - if v.get("id").is_none() { - continue; - } - assert_eq!(v["id"], json!(id)); - return v; - } + let v = self + .wire + .recv(common::TIMEOUT) + .unwrap_or_else(|| panic!("no response to {method}: {:?}", self.wire.refused)); + assert_eq!(v["id"], json!(id)); + v } fn tool(&mut self, name: &str, args: Value) -> Out { @@ -236,14 +170,16 @@ impl Mcp { } /// The viewer command the last `books.show` wrote, in the books' own - /// `ops/` (nothing in the runtime folder). + /// `ops/`. fn viewer_command(&self) -> ViewerCommand { - let books = self.run.parent().unwrap(); - assert!( - !self.run.join("wanderbooks").exists(), - "nothing written in the runtime folder" - ); - ledger_store::ops::read(&data_dir(books)).expect("a show request") + ledger_store::ops::read(&self.data).expect("a show request") + } + + /// The bytes of a file a tool handed over (a `HandedFile`): fetched by + /// its download, matching the size and SHA-256 it states. + fn handed(&mut self, file: &Value) -> Vec { + assert!(file["expires_at"].is_string(), "{file}"); + self.wire.fetch(file) } } @@ -263,18 +199,6 @@ fn sha256(bytes: &[u8]) -> String { ledger_store::journal::sha256_hex(bytes) } -/// The bytes of a file a tool handed over (a `HandedFile`): over stdio -/// they come inline, with no download command, and match the size and -/// SHA-256 it states. -fn handed(file: &Value) -> Vec { - assert!(file.get("download").is_none(), "{file}"); - assert!(file.get("expires_at").is_none(), "{file}"); - let bytes = b64d(file["content_base64"].as_str().expect("content_base64")); - assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); - assert_eq!(file["sha256"], json!(sha256(&bytes)), "{file}"); - bytes -} - fn d(s: &str) -> chrono::NaiveDate { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() } @@ -441,7 +365,7 @@ const CHART: &[(&str, &str, AccountType)] = &[ ("7100", "Gain or Loss on Disposal", AccountType::Expense), ]; -fn new_store(dir: &Path) -> Store { +fn new_store(dir: &TestDir) -> Store { let mut s = Store::create(&data_dir(dir), Currency::USD).unwrap(); for (code, name, ty) in CHART { s.add_account(code, name, *ty, None, false).unwrap(); @@ -1993,6 +1917,7 @@ fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() { assert_eq!(out["status"], "posted"); let entry = out["entry_id"].as_u64().unwrap(); drop(m); + dir.stop(); let s = Store::open(&data_dir(&dir)).unwrap(); let l = s.ledger(); assert_eq!( @@ -2121,6 +2046,7 @@ fn remit_payroll_total_is_what_payroll_withheld_and_owed() { assert_eq!(jamt(&out["total"]), owed); let entry = out["entry_id"].as_u64().unwrap(); drop(m); + dir.stop(); let s = Store::open(&data_dir(&dir)).unwrap(); let e = s.ledger().get_entry(entry).unwrap(); let cash = id_of(&s, "1000"); @@ -2316,6 +2242,7 @@ fn digest_reports_journal_repairs_since_the_date() { let out = m.ok("books.digest", json!({"since": "2025-01-01"})); let repairs = out["journal_repairs"].as_array().unwrap(); drop(m); + dir.stop(); let s = Store::open(&data_dir(&dir)).unwrap(); let want = s.tail_repairs().unwrap(); drop(s); @@ -2485,8 +2412,7 @@ fn show_writes_the_viewer_command_for_each_target() { /// The command `books.show` last wrote, or None before any. fn last_show(m: &Mcp) -> Option { - let books = m.run.parent().unwrap().to_path_buf(); - ledger_store::ops::read(&data_dir(&books)) + ledger_store::ops::read(&m.data) } /// Defect 15, repros 1.1.1 and 1.1.2: a start after the end, given, is @@ -3032,11 +2958,12 @@ fn inline_files_up_to_256_kib_are_accepted_and_no_more() { /// or an upload; never both, never neither. A sha256 missing, or not the /// bytes' own (a byte lost on the way), or base64 that isn't, is refused /// (guard base64_corrupt); inline bytes need their file_name and -/// mime_type. Over stdio there are no uploads: an upload_id is refused, -/// and books.begin_upload answers only for bytes stored already (the -/// attachment holding them). Every refusal stores nothing. +/// mime_type. An upload_id that names no upload is refused, and +/// books.begin_upload names the attachment for bytes stored already, and +/// begins an upload for new ones (journaling nothing). Every refusal +/// stores nothing. #[test] -fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() { +fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() { let dir = test_dir("inline"); let s = new_store(&dir); drop(s); @@ -3083,8 +3010,11 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() { with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})), "base64_corrupt", ), - // An upload, over stdio. - (with(json!({"upload_id": "u1"})), "takes files inline only"), + // An upload there is none of. + ( + with(json!({"upload_id": "u1"})), + "there is no upload \"u1\"", + ), ] { let why = m.refuse(tool, args.clone()); assert!(why.contains(said), "{tool} {args}: {why}"); @@ -3109,7 +3039,7 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() { let id = out["attachment_id"].as_u64().unwrap(); assert_ne!(journal(), before); // begin_upload: the bytes stored already name their attachment; new - // ones need the books server. + // ones begin an upload. let up = |sha: String| { json!({"sha256": sha, "size": bytes.len(), "mime_type": "application/pdf", "file_name": "r.pdf", "purpose": "receipt"}) @@ -3121,10 +3051,11 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() { let got = m.ok("books.begin_upload", up(sha256(bytes).to_ascii_uppercase())); assert_eq!(got["existing_attachment_id"], json!(id), "{got}"); let stored = journal(); - let why = m.refuse("books.begin_upload", up(sha256(other))); - assert!(why.contains("takes files inline only"), "{why}"); + let begun = m.ok("books.begin_upload", up(sha256(other))); + assert!(begun["upload_id"].is_string(), "{begun}"); + assert_eq!(journal(), stored, "beginning an upload journals nothing"); // The CSV imports take csv_text or an upload, never both, never - // neither; an upload over stdio is refused. + // neither; an upload there is none of is refused. for (tool, base) in [ ( "books.import_bank_csv", @@ -3135,7 +3066,7 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() { for (extra, said) in [ (json!({}), "csv_text"), (json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"), - (json!({"upload_id": "u1"}), "takes files inline only"), + (json!({"upload_id": "u1"}), "there is no upload \"u1\""), ] { let mut args = base.clone(); for (k, v) in extra.as_object().unwrap() { @@ -3148,11 +3079,12 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() { assert_eq!(journal(), stored, "a refusal stored something"); } -/// books.get_receipt's view bytes gives the bytes of a file of at most -/// 256 KiB; a larger one (filed before, or by an upload) is refused over -/// stdio, where there is no download, while its meta view still answers. +/// books.get_receipt with view "bytes" gives a file's bytes inline up to +/// 256 KiB; a larger one, its download (a stored file, fetched with +/// books:read by `GET /b/{book}/a/{id}`: those very bytes), never its +/// bytes inline. #[test] -fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() { +fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() { const LIMIT: usize = 256 * 1024; let dir = test_dir("get-bytes"); let mut s = new_store(&dir); @@ -3171,17 +3103,22 @@ fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() { json!({"attachment_id": at_id, "view": "bytes"}), ); assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at); - assert!(got.get("download").is_none(), "{got}"); - let why = m.refuse( + let big = m.ok( "books.get_receipt", json!({"attachment_id": over_id, "view": "bytes"}), ); - assert!(why.contains(&(LIMIT + 1).to_string()), "{why}"); + assert!(big.get("content_base64").is_none(), "{big}"); + let download = big["download"].as_str().unwrap_or_else(|| panic!("{big}")); + assert!(download.contains(&format!(" {over_id} ")), "{download}"); + assert_eq!(big["size_bytes"], json!(LIMIT + 1)); + assert_eq!(big["sha256"], sha256(&over)); + let (status, bytes) = m.wire.get(&format!("a/{over_id}")); + assert_eq!(status, 200); + assert_eq!(bytes, over); let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id})); assert_eq!(meta["size_bytes"], json!(LIMIT + 1)); assert_eq!(meta["sha256"], sha256(&over)); assert!(meta.get("content_base64").is_none(), "{meta}"); - assert!(meta.get("download").is_none(), "{meta}"); } /// Bank CSV import: money in debits the bank, money out credits it; the @@ -3533,7 +3470,7 @@ fn cpa_package_holds_the_year_and_its_evidence() { ); assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip")); assert_eq!(out["file"]["mime_type"], "application/zip"); - let files = unzip(&handed(&out["file"])); + let files = unzip(&m.handed(&out["file"])); let names: Vec<&str> = files.keys().map(String::as_str).collect(); let mut want = vec![ @@ -3703,7 +3640,7 @@ fn cpa_package_holds_the_year_and_its_evidence() { // payment after as_of counts, and the files differ. let mut tool_csv = |args: Value| { let out = m.ok("books.vendor_1099", args); - handed(&out["csv"]) + m.handed(&out["csv"]) }; let same = tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"], "through": END})); @@ -3735,7 +3672,7 @@ fn cpa_package_holds_the_year_and_its_evidence() { "books.export_cpa_package", json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}), ); - let files = unzip(&handed(&out["file"])); + let files = unzip(&m.handed(&out["file"])); assert!( !files .keys() diff --git a/crates/ledger-mcp/tests/p3_seams.rs b/crates/ledger-cli/tests/p3_seams.rs similarity index 77% rename from crates/ledger-mcp/tests/p3_seams.rs rename to crates/ledger-cli/tests/p3_seams.rs index 6dd59e4..e07b839 100644 --- a/crates/ledger-mcp/tests/p3_seams.rs +++ b/crates/ledger-cli/tests/p3_seams.rs @@ -1,7 +1,8 @@ //! P3's seams through the tools: on the seam review's books (a year of a //! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), built -//! in-process with the real `Store`, closed, then served by the real -//! binary over stdio from the same folder and its settings file, every +//! 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 @@ -11,101 +12,27 @@ mod books; use books::{books, d, dec}; +use common::{Books, Wire}; use serde_json::{Value, json}; -use std::io::{BufRead, BufReader, Write}; -use std::path::Path; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::mpsc::{Receiver, channel}; -use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + const START: &str = "2026-01-01"; struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, - next_id: u64, -} - -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } + wire: Wire, } impl Mcp { - fn start(dir: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(dir.join("wanderbooks.toml")) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); - let mut mcp = Mcp { - stdin, - rx, - child, - next_id: 1, - }; - let init = mcp.rpc( - "initialize", - json!({ - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "p3-seams", "version": "0.1.0"}, - }), - ); - assert!(init.get("result").is_some(), "initialize failed: {init}"); - writeln!( - mcp.stdin, - "{}", - json!({"jsonrpc": "2.0", "method": "notifications/initialized"}) - ) - .unwrap(); - mcp - } - - fn rpc(&mut self, method: &str, params: Value) -> Value { - let id = self.next_id; - self.next_id += 1; - let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); - loop { - let line = self - .rx - .recv_timeout(TIMEOUT) - .unwrap_or_else(|_| panic!("no response to {method}")); - let v: Value = serde_json::from_str(&line).unwrap(); - if v.get("id") == Some(&json!(id)) { - return v; - } - } + 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.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = self.wire.call(name, args.clone()); let r = &resp["result"]; assert!( resp.get("error").is_none() && r.get("isError") != Some(&json!(true)), @@ -199,7 +126,13 @@ fn the_tools_figures_are_the_engines_on_every_date() { .collect(); drop(b); - let mut mcp = Mcp::start(dir.path()); + 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. diff --git a/crates/ledger-cli/tests/projects_parity.rs b/crates/ledger-cli/tests/projects_parity.rs index 7ed9b32..49c89ea 100644 --- a/crates/ledger-cli/tests/projects_parity.rs +++ b/crates/ledger-cli/tests/projects_parity.rs @@ -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, - 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"}), diff --git a/crates/ledger-mcp/tests/spec_1099.rs b/crates/ledger-cli/tests/spec_1099.rs similarity index 90% rename from crates/ledger-mcp/tests/spec_1099.rs rename to crates/ledger-cli/tests/spec_1099.rs index 8aaca8c..fc90e76 100644 --- a/crates/ledger-mcp/tests/spec_1099.rs +++ b/crates/ledger-cli/tests/spec_1099.rs @@ -1,20 +1,19 @@ //! The 1099 decision and payment methods through the server (plan P3, -//! defects 12, 18 and 23; D-40), driven over stdio against the real -//! binary: every success validates against the tool's outputSchema, and +//! 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::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -39,57 +38,20 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-1099-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) + Place::new(name) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -108,38 +70,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), }; let init = mcp.rpc( @@ -186,24 +123,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -366,7 +290,7 @@ fn books_in(name: &str, p2: bool) -> Mcp { let dir = test_dir(name); if p2 { ledger_store::Store::create_in_format( - &dir.join("books-data"), + &dir.books_data(), ledger_core::Currency::USD, &ledger_store::JournalFormat { format: 2, @@ -390,7 +314,8 @@ impl Mcp { fn upgraded(mut self) -> Mcp { let dir = self.books_dir.take().expect("books_dir"); drop(self); - ledger_store::Store::open(&dir.join("books-data")) + dir.stop(); + ledger_store::Store::open(&dir.books_data()) .unwrap() .upgrade_format() .unwrap() diff --git a/crates/ledger-mcp/tests/spec_assets.rs b/crates/ledger-cli/tests/spec_assets.rs similarity index 95% rename from crates/ledger-mcp/tests/spec_assets.rs rename to crates/ledger-cli/tests/spec_assets.rs index 923c49c..55719de 100644 --- a/crates/ledger-mcp/tests/spec_assets.rs +++ b/crates/ledger-cli/tests/spec_assets.rs @@ -1,5 +1,6 @@ -//! Spec-blind tests for the fixed-asset tag tools of `ledger-mcp`, driven -//! over stdio against the real binary. +//! Spec-blind tests for the fixed-asset tag tools of the books server, +//! driven through a real `wanderbooks server` as an agent reaches it +//! (common.rs). //! //! Expected behavior comes only from the tool descriptions and parameter / //! result doc comments in `src/server.rs` and `src/types.rs` (the declared @@ -9,16 +10,15 @@ //! the tool's `outputSchema`, and every refusal leaves `journal.jsonl` //! byte-identical (server invariant 2: a refused call changes nothing). +use common::{Place, Wire}; use serde_json::{Value, json}; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -43,57 +43,20 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-assets-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) + Place::new(name) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -112,38 +75,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), }; let init = mcp.rpc( @@ -190,24 +128,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -788,7 +713,7 @@ fn asset_labels_refuses_an_asset_without_a_tag() { // Books written by the store, outside the server: an untagged asset. { use ledger_core::{AccountType, Currency, Money, NewAsset}; - let data = dir.join("books-data"); + let data = dir.books_data(); std::fs::create_dir_all(&data).unwrap(); let mut s = ledger_store::Store::create(&data, Currency::USD).unwrap(); let mut add = |c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap(); @@ -1780,6 +1705,7 @@ fn correcting_a_legacy_contributions_cost_says_its_built_in_gain_moved() { let dir = m.books_dir.take().unwrap(); let data = m.data.clone(); drop(m); + dir.stop(); let mut w = ledger_store::JournalWriter::open(&data.join("journal.jsonl")).unwrap(); w.append( "asset.tax_basis_set", diff --git a/crates/ledger-mcp/tests/spec_attachments.rs b/crates/ledger-cli/tests/spec_attachments.rs similarity index 91% rename from crates/ledger-mcp/tests/spec_attachments.rs rename to crates/ledger-cli/tests/spec_attachments.rs index f2a6ab6..2514d28 100644 --- a/crates/ledger-mcp/tests/spec_attachments.rs +++ b/crates/ledger-cli/tests/spec_attachments.rs @@ -1,5 +1,6 @@ //! Spec-blind tests for the attachment-link, statement, and reconciliation -//! tools of `ledger-mcp`, driven over stdio against the real binary. +//! tools of the books server, driven through a real `wanderbooks server` +//! as an agent reaches it (common.rs). //! //! Expected behavior comes only from the tool descriptions and parameter / //! result doc comments in `src/server.rs` and `src/types.rs` (the declared @@ -10,16 +11,15 @@ //! against the tool's `outputSchema`, and every refusal leaves //! `journal.jsonl` byte-identical (server invariant 2). +use common::{Place, Wire}; use serde_json::{Value, json}; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -44,82 +44,33 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, /// Every response line the server wrote, with the tool it answered. transcript: Vec<(String, String)>, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir and the folder beside it (`-outside`), -/// removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display())); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-attachments-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) -} - -/// A directory beside the books dir `dir`, outside it: the vault key, -/// files the tests hand the server, files it writes, its stderr. -fn outside(dir: &Path) -> PathBuf { - let d = PathBuf::from(format!("{}-outside", dir.display())); - std::fs::create_dir_all(&d).unwrap(); - d + Place::new(name) } /// `wanderbooks.toml` with a company name, the receipt threshold, and -/// extra `[books]` TOML; the vault key file is outside the books dir. +/// extra `[books]` TOML. fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { let path = dir.join("wanderbooks.toml"); std::fs::write( &path, format!( "[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\ - [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\ - [vault]\nkey_file = \"{}\"\n", - outside(dir).join("vault.key").display() + [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n" ), ) .unwrap(); @@ -127,43 +78,13 @@ fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - .env("XDG_RUNTIME_DIR", &run) - // Never the user's real vault key (the default key file is - // under XDG_CONFIG_HOME); the tests name theirs anyway. - .env("XDG_CONFIG_HOME", outside(dir).join("config")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::from( - std::fs::File::create(outside(dir).join("stderr.log")).unwrap(), - )) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), transcript: Vec::new(), }; @@ -211,24 +132,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -1282,7 +1190,6 @@ fn a_mis_recorded_statement_is_voided_and_recorded_again() { /// of the package entirely. #[test] fn the_cpa_package_carries_receipts_linked_to_entries() { - use base64::Engine as _; let mut m = books_at("cpa-linked", "0.00", ""); let receipt = m.receipt(None, b"%PDF linked receipt"); let e = m.draft( @@ -1305,14 +1212,8 @@ fn the_cpa_package_carries_receipts_linked_to_entries() { "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ); - // The zip comes back inline over stdio, as stated (D-29). - let file = &pkg["file"]; - assert!(file.get("download").is_none(), "{file}"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(file["content_base64"].as_str().unwrap()) - .unwrap(); - assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); - assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}"); + // The zip is handed over by its download, as stated (D-29). + let bytes = m.wire.fetch(&pkg["file"]); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let names: Vec = (0..zip.len()) .map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()) diff --git a/crates/ledger-mcp/tests/spec_kept.rs b/crates/ledger-cli/tests/spec_kept.rs similarity index 54% rename from crates/ledger-mcp/tests/spec_kept.rs rename to crates/ledger-cli/tests/spec_kept.rs index 10ba0f1..0f25ee7 100644 --- a/crates/ledger-mcp/tests/spec_kept.rs +++ b/crates/ledger-cli/tests/spec_kept.rs @@ -1,59 +1,24 @@ -//! P1 through the real server: settings moving into the books, the books' -//! own settings, conventions, approvals, books.start (plan/served.md P1). -//! Drives the real `ledger-mcp` binary over stdio with raw JSON-RPC. +//! 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}; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, Command, Stdio}; +mod common; + +/// One agent's session with the test's books (common.rs). struct Mcp { - stdin: ChildStdin, - stdout: BufReader, + wire: Wire, next_id: u64, - child: Child, -} - -/// Closing stdin ends the server; wait for it to exit, so the next server -/// on the same books never races it for the writer lock. -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } } impl Mcp { - fn spawn(data_dir: &std::path::Path) -> Self { - Self::spawn_with(data_dir, &[]) - } - - fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self { - Self::spawn_env(data_dir, extra, &[]) - } - - fn spawn_env( - data_dir: &std::path::Path, - extra: &[&std::ffi::OsStr], - env: &[(&str, &std::path::Path)], - ) -> Self { - let bin = env!("CARGO_BIN_EXE_ledger-mcp"); - let mut child = Command::new(bin) - .arg("--data-dir") - .arg(data_dir) - .args(extra) - .envs(env.iter().map(|(k, v)| (*k, v.as_os_str()))) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); + /// A new session with `books` (initialized by the test). + fn spawn(books: &Books) -> Self { Self { - stdin, - stdout, + wire: books.wire(), next_id: 1, - child, } } @@ -61,13 +26,11 @@ impl Mcp { let id = self.next_id; self.next_id += 1; let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); - let mut line = String::new(); - self.stdout - .read_line(&mut line) - .expect("read response line"); - let resp: Value = serde_json::from_str(&line).expect("parse response"); + 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(), @@ -78,9 +41,8 @@ impl Mcp { } fn notify(&mut self, method: &str) { - let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); + self.wire + .send(&json!({"jsonrpc": "2.0", "method": method, "params": {}})); } /// Call a tool; panic on tool error. Returns structuredContent. @@ -112,35 +74,6 @@ impl Mcp { } } -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn test_dir(name: &str) -> TestDir { - let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) -} - fn init(m: &mut Mcp) -> String { let r = m.request( "initialize", @@ -191,78 +124,21 @@ fn chart(m: &mut Mcp) { } } -/// A settings file moves into the books, once, with the human's go: set -/// is refused before (it would drop the rest of the file); the preview -/// changes nothing; after, the file is never read, even to start, and the -/// company's EIN only ever leaves the server masked. +/// 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 a_settings_file_moves_into_the_books() { - let dir = test_dir("kept-move"); - let data = dir.join("books-data"); - std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap(); - let mut m = Mcp::spawn(&data); +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}"); - assert!( - told.contains("the settings file books.settings names"), - "{told}" - ); chart(&mut m); - assert_eq!(m.call("books.settings", json!({}))["source"], "file"); - let start = m.call("books.start", json!({})); - assert_eq!(start["settings_source"], "file"); - assert!( - start["next_steps"] - .to_string() - .contains("books.import_settings") - ); - let refused = m.call_err( - "books.set_setting", - json!({"changes": [{"key": "company.cpa", "value": "Casey"}], "decided_by": "A"}), - ); - assert!(refused.contains("books.import_settings"), "{refused}"); - // Nor is there an export yet: the file is the copy. - let refused = m.call_err("books.export_settings", json!({})); - assert!( - refused.contains("still read their settings file") - && refused.contains("books.import_settings"), - "{refused}" - ); - // The preview records nothing. - let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone(); - let before = head(&mut m); - let preview = m.call( - "books.import_settings", - json!({"toml_text": TOML, "decided_by": "A", "note": "Moving in."}), - ); - assert_eq!(preview["status"], "preview"); - assert_eq!(head(&mut m), before); - assert!( - preview["notes"].to_string().contains("data_dir"), - "{preview}" - ); - assert!( - preview["unknown"].as_array().unwrap().is_empty(), - "{preview}" - ); - // The file's comments are listed, never dropped unseen. - assert_eq!( - preview["comments"], - json!(["Form 1065; a Schedule K-1 per member"]), - "{preview}" - ); - let shown = preview.to_string(); - assert!(!shown.contains("2215893"), "the EIN is masked: {shown}"); - assert!(shown.contains("**-***5893"), "{shown}"); - let done = m.call( - "books.import_settings", - json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}), - ); - assert_eq!(done["status"], "imported"); let settings = m.call("books.settings", json!({})); - assert_eq!(settings["source"], "books"); - assert_eq!(settings["books_toml"], Value::Null); assert_eq!(settings["parties"], "require"); + let shown = settings.to_string(); + assert!(!shown.contains("8525893"), "the EIN is masked: {shown}"); let ein = settings["keys"] .as_array() .unwrap() @@ -271,12 +147,10 @@ fn a_settings_file_moves_into_the_books() { .unwrap() .clone(); assert_eq!(ein["value"], "**-***5893"); - assert_eq!(ein["set_by"]["decided_by"], "A"); - // The file is no longer read: an edit changes nothing. - std::fs::write(dir.join("wanderbooks.toml"), TOML.replace("0.00", "99.00")).unwrap(); - let rule = m.call("books.settings", json!({}))["receipt_rule"].clone(); + assert_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 now. + // Settings change through the tools. let set = m.call( "books.set_setting", json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}, @@ -289,88 +163,41 @@ fn a_settings_file_moves_into_the_books() { assert!(text.contains("timezone = \"America/Denver\""), "{text}"); assert!(!text.contains("3456789"), "{text}"); let start = m.call("books.start", json!({})); - assert_eq!(start["settings_source"], "books"); assert_eq!(start["book"]["timezone"], "America/Denver"); assert!(!start.to_string().contains("3456789")); drop(m); - // A broken settings file beside books that keep their own can't stop - // the server: it is never read. - std::fs::write(dir.join("wanderbooks.toml"), "[books\nthis is not toml").unwrap(); - let mut m = Mcp::spawn(&data); + 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("local file you may edit"), "{told}"); assert!(told.contains("Kept Co LLC"), "{told}"); - assert_eq!(m.call("books.settings", json!({}))["source"], "books"); + // 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"); } -/// INVARIANT 2 (tools/kept.rs): an import that would read any setting -/// differently than the server does now is refused. Here the server reads -/// its receipt policy from config.json, which the import must carry. -#[test] -fn an_import_that_reads_differently_is_refused() { - let dir = test_dir("kept-differs"); - let data = dir.join("books-data"); - std::fs::create_dir_all(&data).unwrap(); - std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap(); - let policy = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#; - std::fs::write(data.join("config.json"), policy).unwrap(); - let mut m = Mcp::spawn(&data); - init(&mut m); - let e = m.call_err( - "books.import_settings", - json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}), - ); - assert!(e.contains("policy.evidence.threshold"), "{e}"); - assert!(e.contains("config_json_text"), "{e}"); - assert_eq!(m.call("books.settings", json!({}))["source"], "file"); - m.call( - "books.import_settings", - json!({"toml_text": TOML, "decided_by": "A", "confirmed": true, - "config_json_text": policy}), - ); - let s = m.call("books.settings", json!({})); - assert_eq!(s["source"], "books"); - assert_eq!(s["receipt_exempt_accounts"], json!(["6999"])); -} - -/// The vault key a settings file names must come from the command line -/// before the settings move in, or the vault would stop opening. -#[test] -fn an_import_needs_the_vault_key_on_the_command_line() { - let dir = test_dir("kept-vault"); - let data = dir.join("books-data"); - let key = dir.join("elsewhere").join("vault.key"); - let toml = format!("{TOML}\n[vault]\nkey_file = \"{}\"\n", key.display()); - std::fs::write(dir.join("wanderbooks.toml"), &toml).unwrap(); - let mut m = Mcp::spawn(&data); - init(&mut m); - let e = m.call_err( - "books.import_settings", - json!({"toml_text": toml, "decided_by": "A", "confirmed": true}), - ); - assert!(e.contains("--vault-key-file"), "{e}"); - drop(m); - let mut m = Mcp::spawn_with(&data, &["--vault-key-file".as_ref(), key.as_os_str()]); - init(&mut m); - m.call( - "books.import_settings", - json!({"toml_text": toml, "decided_by": "A", "confirmed": true}), - ); - assert_eq!(m.call("books.settings", json!({}))["source"], "books"); -} - /// Approval rules through the server: enforced, a covered post is refused /// until its approver approves, then posts; off, they block nothing. #[test] fn approval_rules_through_the_server() { - let dir = test_dir("kept-approvals"); - let data = dir.join("books-data"); - let mut m = Mcp::spawn(&data); + let books = Books::new("kept-approvals"); + let mut m = Mcp::spawn(&books); init(&mut m); chart(&mut m); m.call( @@ -431,9 +258,8 @@ fn approval_rules_through_the_server() { /// A posting template drafts through the server, to the cent. #[test] fn templates_draft_through_the_server() { - let dir = test_dir("kept-template"); - let data = dir.join("books-data"); - let mut m = Mcp::spawn(&data); + let books = Books::new("kept-template"); + let mut m = Mcp::spawn(&books); init(&mut m); chart(&mut m); m.call( @@ -492,9 +318,8 @@ fn templates_draft_through_the_server() { /// it left out. #[test] fn start_fits_in_8_kb() { - let dir = test_dir("kept-start"); - let data = dir.join("books-data"); - let mut m = Mcp::spawn(&data); + 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. " @@ -528,17 +353,14 @@ fn start_fits_in_8_kb() { assert_eq!(start["isolation"], "same_user (advisory)"); } -/// Review of P1 (2026-10-17): the text imported is the file the server -/// reads, whole; books that keep their own settings take no import that -/// changes them; a settings file never turns approvals on; after the -/// move, a broken config.json can't stop the server; and books.settings -/// shows a file's values before the move. +/// 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 an_import_is_the_file_the_server_reads() { - let dir = test_dir("kept-whole"); - let data = dir.join("books-data"); - std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap(); - let mut m = Mcp::spawn(&data); +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 @@ -548,48 +370,20 @@ fn an_import_is_the_file_the_server_reads() { .find(|k| k["key"] == "company.ein") .unwrap() .clone(); - assert_eq!(ein["value"], "**-***5893", "the file's value, masked"); - // A text that leaves the EIN out is not the file. - let short = TOML.replace("ein = \"28-8525893\"\n", ""); - let e = m.call_err( - "books.import_settings", - json!({"toml_text": short, "decided_by": "A", "confirmed": true}), + assert_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}" ); - assert!(e.contains("company.ein"), "{e}"); - // Approval rules never come in from a file. - std::fs::write( - dir.join("wanderbooks.toml"), - format!("{TOML}approval_rules = \"enforce\"\n") - .replace("[viewer]\nopen_at_start = \"ask\"\n", ""), - ) - .unwrap(); - let with_rules = std::fs::read_to_string(dir.join("wanderbooks.toml")).unwrap(); - let e = m.call_err( - "books.import_settings", - json!({"toml_text": with_rules, "decided_by": "A", "confirmed": true}), - ); - assert!(e.contains("approval"), "{e}"); - std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap(); - m.call( - "books.import_settings", - json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}), - ); - // Imported books: the same text again is no change; another is refused. - let again = m.call( - "books.import_settings", - json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}), - ); - assert!(again["changes"].as_array().unwrap().is_empty(), "{again}"); - let e = m.call_err( - "books.import_settings", - json!({"toml_text": short, "decided_by": "A", "confirmed": true}), - ); - assert!(e.contains("books.set_setting"), "{e}"); - drop(m); - std::fs::write(data.join("config.json"), "{ not json").unwrap(); - let mut m = Mcp::spawn(&data); - init(&mut m); - assert_eq!(m.call("books.settings", json!({}))["source"], "books"); + 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 @@ -597,9 +391,8 @@ fn an_import_is_the_file_the_server_reads() { /// chart. #[test] fn renumbering_names_the_conventions_to_revise() { - let dir = test_dir("kept-renumber"); - let data = dir.join("books-data"); - let mut m = Mcp::spawn(&data); + let books = Books::new("kept-renumber"); + let mut m = Mcp::spawn(&books); init(&mut m); chart(&mut m); m.call( @@ -632,9 +425,8 @@ fn renumbering_names_the_conventions_to_revise() { /// tools do. #[test] fn the_kept_resources_read() { - let dir = test_dir("kept-resources"); - let data = dir.join("books-data"); - let mut m = Mcp::spawn(&data); + let books = Books::new("kept-resources"); + let mut m = Mcp::spawn(&books); init(&mut m); chart(&mut m); m.call( @@ -651,22 +443,19 @@ fn the_kept_resources_read() { assert_eq!(start["state"], "active", "{start}"); assert_eq!(start["conventions"][0]["id"], "software", "{start}"); let settings = read(&mut m, "books://settings"); - assert_eq!(settings["source"], "file", "{settings}"); + assert_eq!(settings, m.call("books.settings", json!({})), "{settings}"); let conventions = read(&mut m, "books://conventions"); assert_eq!( conventions["conventions"][0]["id"], "software", "{conventions}" ); - drop(m); - let _ = std::fs::remove_dir_all(&dir); } /// A renumber lists exactly the conventions naming the old code. #[test] fn renumbering_lists_only_the_conventions_naming_it() { - let dir = test_dir("kept-renumber-only"); - let data = dir.join("books-data"); - let mut m = Mcp::spawn(&data); + 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")] { @@ -696,6 +485,4 @@ fn renumbering_lists_only_the_conventions_naming_it() { ["convention software (revise it with books.set_convention)"], "{out}" ); - drop(m); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs similarity index 87% rename from crates/ledger-mcp/tests/spec_mcp.rs rename to crates/ledger-cli/tests/spec_mcp.rs index 0dc6b29..fce59f5 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -1,5 +1,5 @@ -//! Spec-blind tests for `ledger-mcp`, driven over stdio against the real -//! binary. +//! Spec-blind tests for the books server, driven through a real +//! `wanderbooks server` as an agent reaches it (common.rs). //! //! Expected behavior comes only from the GOAL/SOURCE/INVARIANTS blocks of //! `src/server.rs` and `src/policy.rs`, the server's connect text and handbook, @@ -19,20 +19,19 @@ //! files under the data dir as they were, success or refusal (plan 4.9, //! "Annotations (P0)": the hint is true in behaviour). +use common::{Place, Wire}; use proptest::prelude::*; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet, HashMap}; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; +mod common; + /// How long any single response may take before the server counts as hung. /// Long enough for a server's first answer while a mutation run's jobs /// contend for the disk (every journal append is synced). -const TIMEOUT: Duration = Duration::from_secs(180); +const TIMEOUT: Duration = common::TIMEOUT; /// Random cases for the state machine: 64 on every check; `MCP_CASES` /// sets fewer for a mutation run, more for a deeper one. @@ -146,9 +145,7 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, schemas: HashMap, @@ -159,64 +156,16 @@ struct Mcp { read_only_checked: BTreeSet, /// Invariant 3 (amounts out) violations: `tool: path = value`. money_violations: Vec, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir and the folders beside it (`.inbox`, `-outside`), -/// removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - for beside in [".inbox", "-outside"] { - let p = PathBuf::from(format!("{}{beside}", self.0.display())); - let _ = std::fs::remove_dir_all(&p); - let _ = std::fs::remove_file(&p); - } - } -} - -/// A fresh, empty per-test directory under the system temp dir. fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = - std::env::temp_dir().join(format!("ledger-mcp-spec-{}-{name}-{n}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) -} - -/// A directory beside the books dir `dir`, outside it (vault keys, files -/// with TINs). -fn outside(dir: &Path) -> PathBuf { - let d = PathBuf::from(format!("{}-outside", dir.display())); - std::fs::create_dir_all(&d).unwrap(); - d + Place::new(name) } /// Write a `wanderbooks.toml` with the given receipt threshold and extra @@ -234,56 +183,16 @@ fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf { path } -fn spawn_child(dir: &Path, toml: &Path) -> Child { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - // A settings file naming the vault's key (the happy path's) names - // it on the command line too, as books that move their settings - // in must. - .args( - std::fs::read_to_string(toml) - .is_ok_and(|t| t.contains("[vault]")) - .then(|| ["--vault-key-file".into(), outside(dir).join("vault.key")]) - .into_iter() - .flatten(), - ) - // The TIN vault's default key file is under XDG_CONFIG_HOME: never - // the user's real one (the happy path names its own anyway). - .env("XDG_CONFIG_HOME", outside(dir).join("config")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp") -} - impl Mcp { - /// Spawn on `dir` with `toml`, initialize, and load `tools/list`. - fn start(dir: &Path, toml: &Path) -> Self { - let mut child = spawn_child(dir, toml); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + /// Start `dir`'s server with `toml`'s settings, initialize, and load + /// `tools/list`. + fn start(dir: &TestDir, toml: &Path) -> Self { + let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap())); let mut mcp = Self { books_dir: None, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), schemas: HashMap::new(), validators: HashMap::new(), exercised: BTreeSet::new(), @@ -319,45 +228,28 @@ impl Mcp { std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() } - /// What a read-only tool must leave as it was: every file under the - /// data dir, the books dir's vault/ and its settings file, by path, - /// each with the SHA-256 of its contents. The database is compared by - /// what it holds (every row of every table, in order: SQLite moves - /// pages between the file and its write-ahead log on its own, and a - /// reader writes its read marks into the shared-memory file), so the - /// -wal and -shm files are left out. + /// What a read-only tool must leave as it was: every file of the books + /// (their folder in the data root, the vault in it), by path, each with + /// the SHA-256 of its contents; but what every call writes by design + /// (common.rs `book_files`: the access log, the viewer's side channel, + /// the books' own backup, files handed over or on their way in). The + /// database is compared by what it holds (every row of every table, in + /// order: SQLite moves pages between the file and its write-ahead log + /// on its own, and a reader writes its read marks into the + /// shared-memory file), so the -wal and -shm files are left out. fn data_files(&self) -> BTreeMap { - fn walk(dir: &Path, out: &mut BTreeMap) { - for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { - let p = e.path(); - let name = p.file_name().unwrap().to_string_lossy().into_owned(); - // The viewer's side channel (books.show's requests) is not - // the books: never journaled (ledger_store::ops). - if p.is_dir() && name == ledger_store::ops::OPS_DIR { - continue; - } - if p.is_dir() { - walk(&p, out); - } else if name.ends_with("-wal") || name.ends_with("-shm") { - continue; - } else if ledger_store::is_database_file(&name) { - out.insert(p.clone(), db_digest(&p)); + common::book_files(&self.data) + .into_iter() + .map(|(rel, sha)| { + let p = self.data.join(&rel); + let name = rel.file_name().unwrap().to_string_lossy().into_owned(); + if ledger_store::is_database_file(&name) { + (p.clone(), db_digest(&p)) } else { - let bytes = std::fs::read(&p).unwrap_or_default(); - out.insert(p, ledger_store::journal::sha256_hex(&bytes)); + (p, sha) } - } - } - let mut out = BTreeMap::new(); - walk(&self.data, &mut out); - let books = self.data.parent().unwrap(); - walk(&books.join("vault"), &mut out); - let toml = books.join("wanderbooks.toml"); - out.insert( - toml.clone(), - ledger_store::journal::sha256_hex(&std::fs::read(&toml).unwrap_or_default()), - ); - out + }) + .collect() } fn read_only(&self, name: &str) -> bool { @@ -367,26 +259,13 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } /// The next JSON-RPC response (messages without an id, i.e. server - /// notifications, are skipped). `None` on timeout or EOF. + /// notifications, are skipped). `None` when the server sent none. fn recv(&mut self, timeout: Duration) -> Option { - loop { - match self.rx.recv_timeout(timeout) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(timeout) } /// Send a request and return its response, checking JSON-RPC 2.0 §5: @@ -480,7 +359,21 @@ impl Mcp { ); let mut bad = Vec::new(); money_walk(&sc, "$", None, &mut bad); + // A key the tool's outputSchema declares an integer is a count, + // not money (books.access_log's `total`). + let counts: Vec = self.schemas[name]["outputSchema"]["properties"] + .as_object() + .map(|p| { + p.iter() + .filter(|(_, s)| s["type"] == "integer") + .map(|(k, _)| format!("$.{k} ")) + .collect() + }) + .unwrap_or_default(); for b in bad { + if counts.iter().any(|c| b.starts_with(c.as_str())) { + continue; + } self.money_violations.push(format!("{name}: {b}")); } self.exercised.insert(name.to_string()); @@ -696,13 +589,9 @@ fn happy_path(name: &str) -> Mcp { let toml = books_toml( &dir, "1000000.00", - &format!( - "financing_accounts = [\"2100\"]\n\ + "financing_accounts = [\"2100\"]\n\ [[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ - [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ - [vault]\nkey_file = \"{}\"\n", - outside(&dir).join("vault.key").display() - ), + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", ); let mut m = Mcp::start(&dir, &toml); @@ -940,7 +829,7 @@ fn happy_path(name: &str) -> Mcp { json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}), ); // An upload of bytes already stored: begin_upload names the - // attachment that holds them (over stdio, the only upload answered). + // attachment that holds them. let local = m.attach(None, b"a local receipt"); let up = m.ok( "books.begin_upload", @@ -1217,15 +1106,6 @@ fn happy_path(name: &str) -> Mcp { json!({"id": item, "resolution": "Mailed.", "decided_by": "A"}), ); m.ok("books.open_items", json!({"include_resolved": true})); - let toml_text = std::fs::read_to_string(&toml).unwrap(); - m.ok( - "books.import_settings", - json!({"toml_text": toml_text, "decided_by": "A"}), - ); - m.ok( - "books.import_settings", - json!({"toml_text": toml_text, "decided_by": "A", "confirmed": true}), - ); m.ok( "books.set_setting", json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}], @@ -1311,6 +1191,12 @@ fn happy_path(name: &str) -> Mcp { json!({"as_of": "2026-12-31", "include_receipts": false}), ); + // The access log, the backups' status, and a backup now (none is set + // up: it says so). + m.ok("books.access_log", json!({})); + m.ok("books.backup", json!({})); + m.ok("books.back_up_now", json!({})); + m.books_dir = Some(dir); m } @@ -2053,56 +1939,26 @@ fn inv5_reversals_are_exempt() { } /// Policy invariant 4: a threshold that isn't a decimal, or is below -/// zero, is refused at startup. +/// zero, is refused. The books keep their settings, so it is refused as it +/// is set (books.set_setting), journaling nothing, and the rule in force +/// stays. #[test] -fn inv5_bad_threshold_refused_at_startup() { +fn inv5_bad_threshold_refused_when_set() { + let dir = test_dir("inv5-set"); + let toml = books_toml(&dir, "25.00", ""); + let mut m = Mcp::start(&dir, &toml); + let before = m.journal(); + assert!(!before.is_empty(), "the books keep a journal"); for bad in ["-1", "abc", "-0.01", "", "1,000", "ten"] { - let dir = test_dir("inv5-startup"); - let toml = books_toml(&dir, bad, ""); - let mut child = spawn_child(&dir, &toml); - let mut stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { - "protocolVersion": "2025-06-18", "capabilities": {}, - "clientInfo": {"name": "spec-test", "version": "0.1.0"}}}); - let _ = writeln!(stdin, "{init}"); - let _ = stdin.flush(); - let (tx, rx) = channel(); - std::thread::spawn(move || { - let mut line = String::new(); - let _ = BufReader::new(stdout).read_line(&mut line); - let _ = tx.send(line); - }); - let line = rx.recv_timeout(TIMEOUT).unwrap_or_default(); - let answered = serde_json::from_str::(&line) - .ok() - .is_some_and(|v| v.get("result").is_some()); - drop(stdin); - let status = wait_with_timeout(&mut child, TIMEOUT); - assert!( - !answered, - "threshold {bad:?}: server completed initialize: {line}" - ); - assert!( - status.is_some_and(|s| !s.success()), - "threshold {bad:?}: server did not exit nonzero ({status:?})" + m.refuse( + "books.set_setting", + json!({"changes": [{"key": "policy.evidence.threshold", "value": bad}], + "decided_by": "Test Owner"}), ); } -} - -fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Option { - let start = std::time::Instant::now(); - loop { - if let Ok(Some(s)) = child.try_wait() { - return Some(s); - } - if start.elapsed() > timeout { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - std::thread::sleep(Duration::from_millis(20)); - } + assert_eq!(m.journal(), before, "a refused threshold journals nothing"); + let rule = m.ok("books.settings", json!({}))["receipt_rule"].clone(); + assert!(rule.as_str().unwrap().contains("25.00"), "{rule}"); } // --------------------------------------- invariant 6: ambiguous references @@ -2230,8 +2086,8 @@ fn inv6_ambiguous_class_reference_refused() { /// Books an earlier version wrote holding these classes, in id order: a /// name of digits only can't be added now (it would read as an id), so /// only old books hold the id/name pairs invariant 6 refuses. -fn old_classes(dir: &Path, names: &[&str]) { - let books = dir.join("books-data"); +fn old_classes(dir: &TestDir, names: &[&str]) { + let books = dir.books_data(); drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap()); let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap(); for (i, n) in names.iter().enumerate() { @@ -2321,23 +2177,37 @@ fn jsonrpc_unknown_method_is_32601() { m.ok("books.chart", json!({})); } -/// jsonrpc-2.0.md §5 and §5.1: invalid JSON → a response with code -/// -32700 and id null; the server keeps serving. +/// jsonrpc-2.0.md §5 and §5.1: invalid JSON is refused, never run, and +/// the server keeps serving. Over the server's Streamable HTTP door +/// (mcp-transports: "the server MUST return an HTTP error status code"; +/// its body MAY be a JSON-RPC error with no id) that is a 4xx, and any +/// error it carries is -32700 with id null. (An agent's malformed line is +/// answered -32700 by its bridge: ledger-host bridge.rs, +/// the_agents_input_is_read_message_by_message.) #[test] -fn jsonrpc_parse_error_is_32700_with_null_id() { +fn jsonrpc_parse_error_is_refused_with_32700() { let dir = test_dir("rpc-parse"); let toml = books_toml(&dir, "25.00", ""); let mut m = Mcp::start(&dir, &toml); m.send_line("{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": "); - let resp = m.recv(Duration::from_secs(5)); - let resp = resp.expect("no response to a malformed JSON line"); - check_response_shape(&resp); - assert_eq!(resp["error"]["code"], -32700, "{resp}"); - assert_eq!(resp["id"], Value::Null, "{resp}"); + let status = m.wire.status; + assert!( + (400..500).contains(&status), + "{status} {:?}", + m.wire.refused + ); + if let Some(resp) = m.recv(Duration::from_secs(5)) { + check_response_shape(&resp); + assert_eq!(resp["error"]["code"], -32700, "{resp}"); + assert_eq!(resp["id"], Value::Null, "{resp}"); + } + m.ok("books.chart", json!({})); } -/// jsonrpc-2.0.md §5: `id` is REQUIRED in every response; when it can't -/// be detected (Invalid Request) it MUST be null, else the request's. +/// jsonrpc-2.0.md §5: an Invalid Request is refused; an answer to it +/// carries an `id`, the request's when it can be read, else null. Over the +/// server's door (as above) that is a 4xx, with any error it carries +/// -32600. #[test] fn jsonrpc_invalid_request_response_has_id() { let dir = test_dir("rpc-invalid"); @@ -2348,15 +2218,23 @@ fn jsonrpc_invalid_request_response_has_id() { ("{\"jsonrpc\": \"2.0\", \"id\": 98}", json!(98)), ] { m.send_line(junk); - let resp = m.recv(Duration::from_secs(5)).expect("no response"); - assert_eq!(resp["error"]["code"], -32600, "{resp}"); + let status = m.wire.status; assert!( - resp.get("id").is_some(), - "{junk}: response has no id: {resp}" + (400..500).contains(&status), + "{junk}: {status} {:?}", + m.wire.refused ); - // A detectable id should be echoed; Null when it isn't. - assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}"); + if let Some(resp) = m.recv(Duration::from_secs(5)) { + assert_eq!(resp["error"]["code"], -32600, "{resp}"); + assert!( + resp.get("id").is_some(), + "{junk}: response has no id: {resp}" + ); + // A detectable id should be echoed; Null when it isn't. + assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}"); + } } + m.ok("books.chart", json!({})); } /// Whatever the server does with a malformed line, it keeps serving @@ -2431,36 +2309,32 @@ fn helpers_self_check() { assert_eq!(bad.len(), 2, "{bad:?}"); } -// ------------------------------------- settings reload (settings.rs spec) +// ------------------------------------------- settings (the books keep them) -/// INVARIANT 1: a call after an edit sees it, with no restart, and two -/// same-size edits in a row are both seen (compared by content). +/// A setting changed (books.set_setting) is read by the next call, with no +/// restart, and two changes in a row are both seen. #[test] -fn settings_reload_on_the_next_call() { - let dir = test_dir("reload"); +fn a_changed_setting_is_read_on_the_next_call() { + let dir = test_dir("reread"); let toml = books_toml(&dir, "25.00", ""); let mut m = Mcp::start(&dir, &toml); let s = m.ok("books.settings", json!({})); assert_eq!(s["company"], "Spec Co"); - assert_eq!(s["loads"], 1); for name in ["Aaaa Co", "Bbbb Co"] { - let text = std::fs::read_to_string(&toml) - .unwrap() - .replace("Spec Co", name) - .replace("Aaaa Co", name); - std::fs::write(&toml, text).unwrap(); + m.ok( + "books.set_setting", + json!({"changes": [{"key": "company.name", "value": name}], + "decided_by": "Test Owner"}), + ); let s = m.ok("books.settings", json!({})); assert_eq!(s["company"], name); - assert_eq!(s["reload_error"], Value::Null); } - assert_eq!(m.ok("books.settings", json!({}))["loads"], 3); } -/// INVARIANT 1, enforced: a lowered receipt threshold refuses the next -/// post without a receipt. +/// A lowered receipt threshold refuses the next post without a receipt. #[test] -fn a_reloaded_threshold_is_enforced() { - let dir = test_dir("reload-threshold"); +fn a_lowered_threshold_is_enforced() { + let dir = test_dir("lowered-threshold"); let toml = books_toml(&dir, "1000.00", ""); let mut m = Mcp::start(&dir, &toml); m.accounts(BASE_CHART); @@ -2470,10 +2344,11 @@ fn a_reloaded_threshold_is_enforced() { &[("6000", "debit", "1.00"), ("1000", "credit", "1.00")], ); m.post(first).ok("under the threshold"); - let text = std::fs::read_to_string(&toml) - .unwrap() - .replace("\"1000.00\"", "\"0.00\""); - std::fs::write(&toml, text).unwrap(); + m.ok( + "books.set_setting", + json!({"changes": [{"key": "policy.evidence.threshold", "value": "0.00"}], + "decided_by": "Test Owner"}), + ); let second = m.draft( "2026-03-02", "small", @@ -2484,46 +2359,6 @@ fn a_reloaded_threshold_is_enforced() { assert_eq!(m.entry_status(second), "draft"); } -/// INVARIANTS 2 and 3: a broken edit, and a named file gone missing, keep -/// the last good settings and say why; fixing it loads again. -#[test] -fn a_broken_edit_keeps_the_last_good_settings() { - let dir = test_dir("reload-broken"); - let toml = books_toml(&dir, "25.00", ""); - let good = std::fs::read_to_string(&toml).unwrap(); - let mut m = Mcp::start(&dir, &toml); - std::fs::write( - &toml, - good.replace("Spec Co", "Next Co") - .replace("[books]", "[books"), - ) - .unwrap(); - let s = m.ok("books.settings", json!({})); - assert_eq!(s["company"], "Spec Co"); - assert!(s["reload_error"].is_string(), "{s}"); - let digest = m.ok("books.digest", json!({"since": "2026-01-01"})); - assert!( - digest["settings_problems"][0] - .as_str() - .unwrap() - .contains("didn't load"), - "{digest}" - ); - std::fs::remove_file(&toml).unwrap(); - let s = m.ok("books.settings", json!({})); - assert!( - s["reload_error"].is_string(), - "a named file gone missing: {s}" - ); - assert_eq!(s["books_toml_exists"], false); - assert_eq!(s["company"], "Spec Co"); - std::fs::write(&toml, good.replace("Spec Co", "Fixed Co")).unwrap(); - let s = m.ok("books.settings", json!({})); - assert_eq!(s["company"], "Fixed Co"); - assert_eq!(s["reload_error"], Value::Null); - assert_eq!(s["loads"], 2); -} - /// books.settings and the digest list settings that disagree with the /// chart, each naming the setting. #[test] @@ -2545,27 +2380,31 @@ fn settings_that_disagree_with_the_chart_are_problems() { .collect(); assert_eq!(problems.len(), 1, "{problems:?}"); assert!( - problems[0].contains("[books] financing_accounts lists 6000"), + problems[0].contains("books.financing_accounts lists 6000"), "{problems:?}" ); - assert!(problems[0].ends_with("remove it from [books] financing_accounts in the settings")); - let text = std::fs::read_to_string(&toml) - .unwrap() - .replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]") - .replace("close_into = \"3000\"", "close_into = \"4000\""); - std::fs::write(&toml, text).unwrap(); + assert!( + problems[0].ends_with("remove it from books.financing_accounts with books.set_setting"), + "{problems:?}" + ); + m.ok( + "books.set_setting", + json!({"changes": [{"key": "books.cash_accounts", "value": ["1010"]}, + {"key": "books.close_into", "value": "4000"}], + "decided_by": "Test Owner"}), + ); let digest = m.ok("books.digest", json!({"since": "2026-01-01"})); let problems = digest["settings_problems"].to_string(); assert!( - problems.contains("[books] cash_accounts names 1010, which isn't in the chart"), + problems.contains("books.cash_accounts names 1010, which isn't in the chart"), "{problems}" ); assert!( - problems.contains("[books] close_into names 4000"), + problems.contains("books.close_into names 4000"), "{problems}" ); assert!( - problems.contains("[books] financing_accounts lists 6000"), + problems.contains("books.financing_accounts lists 6000"), "{problems}" ); } diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-cli/tests/spec_parties.rs similarity index 94% rename from crates/ledger-mcp/tests/spec_parties.rs rename to crates/ledger-cli/tests/spec_parties.rs index a1c8ad1..f2e3db0 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-cli/tests/spec_parties.rs @@ -1,5 +1,6 @@ -//! Spec-blind tests for the party directory tools of `ledger-mcp`, driven -//! over stdio against the real binary. +//! Spec-blind tests for the party directory tools of the books server, +//! driven through a real `wanderbooks server` as an agent reaches it +//! (common.rs). //! //! Expected behavior comes only from the tool descriptions and parameter / //! result doc comments in `src/server.rs` and `src/types.rs` (the declared @@ -10,16 +11,15 @@ //! `journal.jsonl` byte-identical (server invariant 2: a refused call //! changes nothing). +use common::{Place, Wire}; use serde_json::{Value, json}; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -44,57 +44,20 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-parties-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) + Place::new(name) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -113,38 +76,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), }; let init = mcp.rpc( @@ -191,24 +129,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -1721,22 +1646,18 @@ fn a_posted_derived_split_survives_settings_changes_and_archiving() { both(), &[("2100", "debit", "100.00"), ("1000", "credit", "100.00")], ); - // The members' split changes to 60/40. - let toml = m.data.parent().unwrap().join("wanderbooks.toml"); + // The members' split changes to 60/40, as an agent changes it. + let toml = m.books_dir.as_ref().unwrap().join("wanderbooks.toml"); let text = std::fs::read_to_string(&toml).unwrap(); let changed = text .replacen("share = \"50%\"", "share = \"60%\"", 1) .replacen("share = \"50%\"", "share = \"40%\"", 1); - std::fs::write(&toml, changed).unwrap(); - // The server reloads it on change: wait until the draft reads 60/40. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); - let d = loop { - let d = m.ok("books.post_entry", json!({"entry_id": draft})); - if resolved(&d)[0].1 == "60.00" || std::time::Instant::now() > deadline { - break d; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - }; + m.ok( + "books.set_setting", + json!({"changes": common::settings_changes(&changed, None, "USD"), + "decided_by": "Test Owner"}), + ); + let d = m.ok("books.post_entry", json!({"entry_id": draft})); assert_eq!( resolved(&d), [ diff --git a/crates/ledger-mcp/tests/spec_party_totals.rs b/crates/ledger-cli/tests/spec_party_totals.rs similarity index 87% rename from crates/ledger-mcp/tests/spec_party_totals.rs rename to crates/ledger-cli/tests/spec_party_totals.rs index a1d80e8..3ababb9 100644 --- a/crates/ledger-mcp/tests/spec_party_totals.rs +++ b/crates/ledger-cli/tests/spec_party_totals.rs @@ -1,8 +1,9 @@ -//! P3 defects 14 and 24 through the MCP, over stdio against the real -//! binary: each party's part of an entry (`books.get_entry` `resolved`, -//! `direction`, `cash`, `in_kind`), party totals on a basis -//! (`books.party`, `books.parties` `basis`), and a party named the -//! creditor of a liability (`books.update_party` `creditor_of`). +//! 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 @@ -11,16 +12,15 @@ //! 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::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -45,57 +45,20 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-party-totals-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) + Place::new(name) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -114,38 +77,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), }; let init = mcp.rpc( @@ -192,24 +130,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -371,7 +296,7 @@ fn books_in(name: &str, p2: bool) -> Mcp { let dir = test_dir(name); if p2 { ledger_store::Store::create_in_format( - &dir.join("books-data"), + &dir.books_data(), ledger_core::Currency::USD, &ledger_store::JournalFormat { format: 2, @@ -404,7 +329,8 @@ impl Mcp { fn upgraded(mut self) -> Mcp { let dir = self.books_dir.take().expect("books_dir"); drop(self); - ledger_store::Store::open(&dir.join("books-data")) + dir.stop(); + ledger_store::Store::open(&dir.books_data()) .unwrap() .upgrade_format() .unwrap() diff --git a/crates/ledger-mcp/tests/spec_projects.rs b/crates/ledger-cli/tests/spec_projects.rs similarity index 93% rename from crates/ledger-mcp/tests/spec_projects.rs rename to crates/ledger-cli/tests/spec_projects.rs index 1a3b17c..3ab0742 100644 --- a/crates/ledger-mcp/tests/spec_projects.rs +++ b/crates/ledger-cli/tests/spec_projects.rs @@ -1,5 +1,6 @@ -//! Spec-blind tests for the project tools of `ledger-mcp` (rules.md -//! B-37), driven over stdio against the real binary. +//! Spec-blind tests for the project tools of the books server (rules.md +//! B-37), driven through a real `wanderbooks server` as an agent reaches +//! it (common.rs). //! //! Expected behavior comes only from the tool descriptions and parameter / //! result doc comments in `src/server.rs` and `src/types.rs` (the declared @@ -10,16 +11,15 @@ //! `journal.jsonl` byte-identical (server invariant 2: a refused call //! changes nothing). +use common::{Place, Wire}; use serde_json::{Value, json}; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -44,57 +44,20 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +/// A test's place (common.rs): its home, its server, its books. +type TestDir = Place; fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-projects-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) + Place::new(name) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -113,38 +76,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - .env("XDG_RUNTIME_DIR", &run) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), }; let init = mcp.rpc( @@ -191,24 +129,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -330,7 +255,7 @@ fn books_in(name: &str, p2: bool) -> Mcp { let dir = test_dir(name); if p2 { ledger_store::Store::create_in_format( - &dir.join("books-data"), + &dir.books_data(), ledger_core::Currency::USD, &ledger_store::JournalFormat { format: 2, @@ -1132,7 +1057,7 @@ fn a_name_resolves_as_the_duplicate_check_compares() { /// checks names): no uniqueness, digits allowed. fn old_classes(name: &str, names: &[&str]) -> Mcp { let dir = test_dir(name); - let books = dir.join("books-data"); + let books = dir.books_data(); drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap()); let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap(); for (i, n) in names.iter().enumerate() { @@ -1212,12 +1137,8 @@ fn books_before_document_projects(name: &str) -> Mcp { format .features .retain(|f| f != ledger_store::format::DOCUMENT_LINE_PROJECTS); - ledger_store::Store::create_in_format( - &dir.join("books-data"), - ledger_core::Currency::USD, - &format, - ) - .unwrap(); + ledger_store::Store::create_in_format(&dir.books_data(), ledger_core::Currency::USD, &format) + .unwrap(); let toml = books_toml(&dir, "Spec Co", ""); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); diff --git a/crates/ledger-mcp/tests/spec_tax_policy.rs b/crates/ledger-cli/tests/spec_tax_policy.rs similarity index 87% rename from crates/ledger-mcp/tests/spec_tax_policy.rs rename to crates/ledger-cli/tests/spec_tax_policy.rs index f2bdca4..e6f8538 100644 --- a/crates/ledger-mcp/tests/spec_tax_policy.rs +++ b/crates/ledger-cli/tests/spec_tax_policy.rs @@ -1,5 +1,6 @@ //! Tax categories at account creation, and `policy.require_tax_category` -//! (P3, defect 8; rules.md B-32), through the real server over stdio. +//! (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, @@ -12,51 +13,30 @@ //! //! Fixtures are fictional (Fernhill Labs LLC). +use common::{Books, Wire}; use serde_json::{Value, json}; -use std::io::{BufRead, BufReader, Write}; -use std::process::{Child, ChildStdin, Command, Stdio}; + +mod common; struct Mcp { - stdin: ChildStdin, - stdout: BufReader, + wire: Wire, next_id: u64, - child: Child, -} - -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } } impl Mcp { - /// A server on books that keep their own settings, initialized. - fn spawn(data_dir: &std::path::Path) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(data_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); + /// A session with books that keep their own settings, initialized. + fn spawn(books: &Books) -> Self { let mut m = Self { - stdin, - stdout, + wire: books.wire(), next_id: 1, - child, }; m.request( "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "spec-tax-policy", "version": "0"}}), ); - let msg = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}); - writeln!(m.stdin, "{msg}").unwrap(); - m.stdin.flush().unwrap(); + m.wire + .send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})); m } @@ -64,13 +44,11 @@ impl Mcp { let id = self.next_id; self.next_id += 1; let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - writeln!(self.stdin, "{msg}").unwrap(); - self.stdin.flush().unwrap(); - let mut line = String::new(); - self.stdout - .read_line(&mut line) - .expect("read response line"); - let resp: Value = serde_json::from_str(&line).expect("parse response"); + 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 @@ -121,22 +99,6 @@ impl Mcp { } } -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn test_dir(name: &str) -> TestDir { - let dir = std::env::temp_dir().join(format!("ledger-mcp-tax-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) -} - fn add(m: &mut Mcp, args: Value) -> Value { m.call("books.add_account", args) } @@ -158,8 +120,8 @@ fn chart_codes(m: &mut Mcp) -> Vec { /// a category. #[test] fn add_account_warns_without_tax_category_defect_8() { - let dir = test_dir("warn"); - let mut m = Mcp::spawn(&dir.0.join("books-data")); + let books = Books::new("warn"); + let mut m = Mcp::spawn(&books); let out = add( &mut m, json!({"code": "6150", "name": "Software", "account_type": "expense"}), @@ -238,8 +200,8 @@ fn add_account_warns_without_tax_category_defect_8() { /// warning. #[test] fn require_tax_category_refuses_defect_8() { - let dir = test_dir("refuse"); - let mut m = Mcp::spawn(&dir.0.join("books-data")); + let books = Books::new("refuse"); + let mut m = Mcp::spawn(&books); add( &mut m, json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}), @@ -349,8 +311,8 @@ fn require_tax_category_refuses_defect_8() { /// free. #[test] fn a_new_account_named_like_a_sibling_is_refused() { - let dir = test_dir("names"); - let mut m = Mcp::spawn(&dir.0.join("books-data")); + let books = Books::new("names"); + let mut m = Mcp::spawn(&books); add( &mut m, json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}), @@ -399,8 +361,8 @@ fn a_new_account_named_like_a_sibling_is_refused() { /// summary could place). #[test] fn a_gain_on_business_property_is_exported_apart() { - let dir = test_dir("form4797"); - let mut m = Mcp::spawn(&dir.0.join("books-data")); + 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"}), diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-cli/tests/spec_vault.rs similarity index 87% rename from crates/ledger-mcp/tests/spec_vault.rs rename to crates/ledger-cli/tests/spec_vault.rs index 3f9b950..237308d 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-cli/tests/spec_vault.rs @@ -1,5 +1,5 @@ -//! Spec-blind tests for the TIN vault tools of `ledger-mcp`, driven over -//! stdio against the real binary. +//! Spec-blind tests for the TIN vault tools of the books server, driven +//! through a real `wanderbooks server` as an agent reaches it (common.rs). //! //! Expected behavior comes only from the tool descriptions and parameter / //! result doc comments in `src/server.rs` and `src/types.rs`, @@ -10,22 +10,21 @@ //! worksheet for the CPA when asked (`books.vendor_1099` with //! `include_tins`); and the vault holds everyone's, members included, for //! the CPA package, which carries them only in the zip it hands over, -//! never kept in the books dir (DECISION, owner, 2026-10-15: "In the vault, that's the +//! never kept in the books' folder but as the file handed over (DECISION, owner, 2026-10-15: "In the vault, that's the //! WHOLE reason we built it. And it needs to be in the damn CPA //! package."). The harness is `tests/spec_parties.rs`'s, with a -//! transcript of every response and the server's stderr kept, and the vault -//! key in a temp file outside the books dir (never `~/.config`). +//! transcript of every response and the server's stderr kept; the vault's +//! key is the book's own `keys/legacy.key` in the test's data root. +use common::{Place, Wire}; use serde_json::{Value, json}; use std::collections::HashMap; -use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; use std::time::Duration; -const TIMEOUT: Duration = Duration::from_secs(180); +mod common; + +const TIMEOUT: Duration = common::TIMEOUT; // ---------------------------------------------------------------- harness @@ -50,71 +49,17 @@ impl Out { } struct Mcp { - stdin: ChildStdin, - rx: Receiver, - child: Child, + wire: Wire, next_id: u64, data: PathBuf, validators: HashMap, /// Every response line the server wrote, with the tool it answered. transcript: Vec<(String, String)>, - /// The books dir when the server owns it: removed after the server - /// stops (fields drop after `Drop::drop`). + /// The test's place, when the session owns it (its server is closed + /// and its home removed after the session). books_dir: Option, } -impl Drop for Mcp { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -static DIR_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A test's dir and the folder beside it (`-outside`), -/// removed when the test ends, pass or panic. -struct TestDir(PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display())); - } -} - -fn test_dir(name: &str) -> TestDir { - let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-spec-vault-{}-{name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - TestDir(dir) -} - -/// A directory beside the books dir `dir`, outside it: the vault key, -/// files the tests hand the server, files it writes, its stderr. -fn outside(dir: &Path) -> PathBuf { - let d = PathBuf::from(format!("{}-outside", dir.display())); - std::fs::create_dir_all(&d).unwrap(); - d -} - /// `bytes` as a tool takes them inline: base64. fn b64(bytes: &[u8]) -> String { use base64::Engine as _; @@ -126,17 +71,22 @@ fn sha(bytes: &[u8]) -> String { ledger_store::journal::sha256_hex(bytes) } +/// 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, the receipt threshold, and -/// extra `[books]` TOML; the vault key file is outside the books dir. +/// extra `[books]` TOML. fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { let path = dir.join("wanderbooks.toml"); std::fs::write( &path, format!( "[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\ - [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\ - [vault]\nkey_file = \"{}\"\n", - outside(dir).join("vault.key").display() + [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n" ), ) .unwrap(); @@ -144,59 +94,13 @@ fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { } impl Mcp { - fn start(dir: &Path, toml: &Path) -> Self { - let run = dir.join("run"); - std::fs::create_dir_all(&run).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(toml) - // A settings file naming the usual key outside the books: the - // command line names it too, as books that move their settings - // in must (a test naming another key keeps only the file's). - .args({ - let key = outside(dir).join("vault.key"); - std::fs::read_to_string(toml) - .is_ok_and(|t| t.contains(&format!("key_file = \"{}\"", key.display()))) - .then(|| { - [ - std::ffi::OsString::from("--vault-key-file"), - key.into_os_string(), - ] - }) - .into_iter() - .flatten() - }) - .env("XDG_RUNTIME_DIR", &run) - // Never the user's real vault key (the default key file is - // under XDG_CONFIG_HOME); the tests name theirs anyway. - .env("XDG_CONFIG_HOME", outside(dir).join("config")) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::from( - std::fs::File::create(outside(dir).join("stderr.log")).unwrap(), - )) - .spawn() - .expect("spawn ledger-mcp"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let (tx, rx) = channel(); - std::thread::spawn(move || { - for line in stdout.lines() { - let Ok(line) = line else { break }; - if tx.send(line).is_err() { - break; - } - } - }); + 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, - stdin, - rx, - child, + wire, next_id: 1, - data: dir.join("books-data"), + data: dir.books_data(), validators: HashMap::new(), transcript: Vec::new(), }; @@ -244,24 +148,11 @@ impl Mcp { } fn send_line(&mut self, line: &str) { - writeln!(self.stdin, "{line}").unwrap(); - self.stdin.flush().unwrap(); + self.wire.send_line(line); } fn recv(&mut self) -> Option { - loop { - match self.rx.recv_timeout(TIMEOUT) { - Ok(line) => { - let v: Value = serde_json::from_str(&line) - .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); - if v.get("id").is_none() && v.get("method").is_some() { - continue; - } - return Some(v); - } - Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, - } - } + self.wire.recv(TIMEOUT) } fn rpc(&mut self, method: &str, params: Value) -> Value { @@ -456,57 +347,36 @@ fn unzip(bytes: &[u8]) -> HashMap> { out } -/// The bytes of a file a tool handed over (a `HandedFile`, D-29): over -/// stdio they come inline, with no download command, and match the size -/// and SHA-256 it states. -fn handed(file: &Value) -> Vec { - use base64::Engine as _; - assert!(file.get("download").is_none(), "{file}"); - assert!(file.get("expires_at").is_none(), "{file}"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(file["content_base64"].as_str().expect("content_base64")) - .unwrap(); - assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); - assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}"); - bytes -} - -/// `v` with every file's inline bytes (`content_base64`) taken out, each -/// pushed to `files` decoded, with its `file_name`: what a tool said, apart -/// from the files it handed over. -fn take_files(v: &Value, files: &mut Vec<(String, Vec)>) -> Value { - use base64::Engine as _; +/// Every file a tool handed over in `v` (a `HandedFile`: an object with +/// its `download`), with its `file_name`, fetched by its download. +fn take_files(m: &mut Mcp, v: &Value, files: &mut Vec<(String, Vec)>) { match v { Value::Object(o) => { - if let Some(b) = o.get("content_base64").and_then(Value::as_str) { + if o.get("download").is_some_and(Value::is_string) { let name = o["file_name"].as_str().unwrap_or_default().to_string(); - let bytes = base64::engine::general_purpose::STANDARD - .decode(b) - .unwrap_or_else(|e| panic!("{name}: {e}")); - files.push((name, bytes)); + files.push((name, m.handed(v))); + } + for v in o.values() { + take_files(m, v, files); } - Value::Object( - o.iter() - .filter(|(k, _)| k.as_str() != "content_base64") - .map(|(k, v)| (k.clone(), take_files(v, files))) - .collect(), - ) } - Value::Array(a) => Value::Array(a.iter().map(|v| take_files(v, files)).collect()), - other => other.clone(), + Value::Array(a) => { + for v in a { + take_files(m, v, files); + } + } + _ => {} } } -/// `v` without the bytes of the files it hands over. -fn said(v: &Value) -> String { - take_files(v, &mut Vec::new()).to_string() -} - -/// The paths of every file under `dir` but the vault's: what a tool -/// wrote, compared before and after. +/// The paths of every file under `dir` but the vault's, and but what +/// every call writes by design (common.rs `book_files`: the access log, +/// the viewer's side channel, the books' own backup, the files handed over +/// for their download): what a tool wrote, compared before and after. fn paths_outside_vault(m: &Mcp, dir: &Path) -> std::collections::BTreeSet { - files_under(dir) - .into_iter() + common::book_files(dir) + .into_keys() + .map(|f| dir.join(f)) .filter(|f| !f.starts_with(m.vault())) .collect() } @@ -544,17 +414,43 @@ fn csv_rows(bytes: &[u8]) -> Vec> { } impl Mcp { + /// The books' folder in the data root. fn books_dir(&self) -> PathBuf { - self.data.parent().unwrap().to_path_buf() + self.data.clone() } - fn outside(&self) -> PathBuf { - outside(&self.books_dir()) + /// Every file in the test's home outside the data root but the + /// server's log: what the server must never write. + fn outside_files(&self) -> Vec { + let home = self.books_dir.as_ref().expect("the place"); + let root = home.home.root(); + let log = home.join("server.log"); + files_under(home) + .into_iter() + .filter(|f| !f.starts_with(&root) && *f != log) + .collect() + } + /// The server's log (its stderr). + fn server_log(&self) -> Vec { + std::fs::read( + self.books_dir + .as_ref() + .expect("the place") + .join("server.log"), + ) + .unwrap_or_default() } fn vault(&self) -> PathBuf { self.books_dir().join("vault") } + /// The vault's key: in the data root, the book's own. fn key(&self) -> PathBuf { - self.outside().join("vault.key") + self.books_dir().join("keys").join("legacy.key") + } + /// The bytes of a file a tool handed over (a `HandedFile`, D-29): + /// fetched by its download, matching the size and SHA-256 it states. + fn handed(&mut self, file: &Value) -> Vec { + assert!(file["expires_at"].is_string(), "{file}"); + self.wire.fetch(file) } fn party_id(&mut self, name: &str) -> u64 { self.party(name, 2026)["party"]["party_id"] @@ -625,6 +521,41 @@ fn files_under(dir: &Path) -> Vec { out } +/// The files a test was handed, in the books' `exports/` (D-29): every +/// export id the session's answers named (`ex_` and 32 letters `a` to +/// `p`, in each handed file's `download`). Checks that nothing else sits +/// there but each one's bytes and its record (`.bin`, `.json`), and +/// returns the bytes' paths: what a scan of the books' folder may skip, +/// their contents having been checked through their downloads. The +/// records are scanned like any other file. +fn handed_exports(m: &Mcp) -> Vec { + let mut ids = std::collections::BTreeSet::new(); + for (_, resp) in &m.transcript { + let mut rest = resp.as_str(); + while let Some(at) = rest.find("ex_") { + let id: String = rest[at..].chars().take(35).collect(); + if ledger_store::exports::is_id(&id) { + ids.insert(id); + } + rest = &rest[at + 3..]; + } + } + assert!(!ids.is_empty(), "the test was handed no file"); + let dir = m.books_dir().join(ledger_store::exports::EXPORTS_DIR); + let mut bins = Vec::new(); + for f in files_under(&dir) { + let name = f.file_name().unwrap().to_string_lossy().into_owned(); + let handed = ids + .iter() + .find(|id| name == format!("{id}.bin") || name == format!("{id}.json")); + assert!(handed.is_some(), "{name} in exports/ wasn't handed over"); + if name.ends_with(".bin") { + bins.push(f); + } + } + bins +} + // ------------------------------------------------- books.set_w9 with a TIN /// The TIN goes to the vault (the key created on first use, owner-only), @@ -748,7 +679,7 @@ fn set_w9_refusals_write_nothing_anywhere() { json!({"w9_content_base64": long, "w9_sha256": sha(&big)}), "inline is at most", ), - (json!({"w9_upload_id": "u1"}), "takes files inline only"), + (json!({"w9_upload_id": "u1"}), "there is no upload \"u1\""), ( json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}), "not both", @@ -839,21 +770,19 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() { docs["receipts"].as_array().unwrap().is_empty(), "not a document: {docs}" ); - // Read back: the bytes, handed over (inline over stdio), named for + // Read back: the bytes, handed over by their download, named for // their type; nothing written anywhere, and the output shows no TIN // but in the file itself. - let (books_before, outside_before) = ( - paths_outside_vault(&m, &m.books_dir()), - files_under(&m.outside()), - ); + let (books_before, outside_before) = + (paths_outside_vault(&m, &m.books_dir()), m.outside_files()); let out = m.ok("books.get_w9", json!({"party": "Acme Design"})); assert_eq!(out["party_id"], json!(acme)); assert_eq!(out["file"]["file_name"], "w9.pdf"); assert_eq!(out["file"]["mime_type"], "application/pdf"); - assert_eq!(handed(&out["file"]), src); - assert!(!holds_tin(said(&out).as_bytes(), SSN_DIGITS), "{out}"); + assert_eq!(m.handed(&out["file"]), src); + assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS), "{out}"); assert_eq!(paths_outside_vault(&m, &m.books_dir()), books_before); - assert_eq!(files_under(&m.outside()), outside_before); + assert_eq!(m.outside_files(), outside_before); // A party with no W-9 file in the vault. m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); let why = m.refuse("books.get_w9", json!({"party": "Rent Co"})); @@ -896,7 +825,7 @@ fn every_w9_file_type_is_taken() { assert_eq!(p["w9"]["in_vault"], true, "{name}"); let out = m.ok("books.get_w9", json!({"party": party})); assert_eq!(out["file"]["file_name"], *name); - assert_eq!(handed(&out["file"]), *bytes); + assert_eq!(m.handed(&out["file"]), *bytes); } // Its own name, when given; given alone, a name is refused. m.add_party("Payee N", &["contractor"]); @@ -910,7 +839,7 @@ fn every_w9_file_type_is_taken() { }; let out = m.ok("books.get_w9", json!({"party": "Payee N"})); assert_eq!(out["file"]["file_name"], "payee-n-w9.pdf"); - assert_eq!(handed(&out["file"]), bytes); + assert_eq!(m.handed(&out["file"]), bytes); let why = m .w9( "Payee N", @@ -985,30 +914,6 @@ fn a_locked_vault_says_so_and_never_guesses() { assert_eq!(v["vendors"][0]["tin"], SSN); } -#[test] -fn a_key_file_inside_the_books_is_refused() { - let dir = test_dir("key-inside"); - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - format!( - "[company]\nname = \"Spec Co\"\n[books]\nreceipt_threshold = \"1000000.00\"\n\ - [vault]\nkey_file = \"{}\"\n", - dir.join("vault.key").display() - ), - ) - .unwrap(); - let mut m = Mcp::start(&dir, &toml); - m.accounts(CHART); - m.add_party("Acme Design", &["contractor"]); - let why = m - .w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})) - .err_text(); - assert!(why.contains("inside the books' folder"), "{why}"); - assert!(!dir.join("vault.key").exists()); - assert!(!dir.join("vault").exists()); -} - // ------------------------------------------------- books.vendor_1099 #[test] @@ -1070,7 +975,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() { ); assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv"); assert_eq!(out["csv"]["mime_type"], "text/csv"); - let text = String::from_utf8(handed(&out["csv"])).unwrap(); + let text = String::from_utf8(m.handed(&out["csv"])).unwrap(); assert!(text.contains(SSN), "{text}"); assert!(text.starts_with( "vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n" @@ -1085,7 +990,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() { // Without TINs: last fours only, and no TIN columns. let out = m.read_only("books.vendor_1099", json!({"year": 2026, "csv": true})); assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026.csv"); - let bytes = handed(&out["csv"]); + let bytes = m.handed(&out["csv"]); assert!(!holds_tin(&bytes, SSN_DIGITS)); let text = String::from_utf8(bytes).unwrap(); assert!( @@ -1293,7 +1198,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { "books.vendor_1099", json!({"year": 2026, "include_tins": true, "csv": true}), ); - let csv = handed(&csv["csv"]); + let csv = m.handed(&csv["csv"]); assert!(holds_tin(&csv, SSN_DIGITS) && holds_tin(&csv, EIN_DIGITS)); // The package, TINs included (the default). let pkg = m.read_only( @@ -1301,7 +1206,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); assert_eq!(pkg["tins_included"], true); - let pkg = unzip(&handed(&pkg["file"])); + let pkg = unzip(&m.handed(&pkg["file"])); let holds_any = |b: &[u8]| { holds_tin(b, SSN_DIGITS) || holds_tin(b, EIN_DIGITS) || holds_tin(b, MEMBER_SSN_DIGITS) }; @@ -1310,9 +1215,11 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { // (D-29): the W-9 file, the worksheet CSV with TINs, the package with // them (a zip's files read out of it). let (mut leaks, mut scanned): (Vec, Vec) = (Vec::new(), Vec::new()); - for (tool, resp) in &m.transcript { + for (tool, resp) in m.transcript.clone() { let mut files = Vec::new(); - let rest = take_files(&serde_json::from_str(resp).unwrap(), &mut files).to_string(); + let resp: Value = serde_json::from_str(&resp).unwrap(); + take_files(&mut m, &resp, &mut files); + let rest = resp.to_string(); let worksheet = tool == "books.vendor_1099" && rest.contains("\"warning\":\"This worksheet"); if !worksheet && holds_any(rest.as_bytes()) { @@ -1342,7 +1249,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { assert_eq!(scanned.len(), 4, "{scanned:?}"); assert!(m.transcript.len() > 40, "the session used the books"); // The server's log. - let log = std::fs::read(m.outside().join("stderr.log")).unwrap(); + let log = m.server_log(); assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS)); assert!(!holds_tin(&log, MEMBER_SSN_DIGITS)); // The package carries them all: the member's in the K-1 worksheet, @@ -1360,12 +1267,16 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { ); } } - // Every byte in the books dir outside the vault: the journal, the - // database (and its WAL), the stored files, the settings. + // Every byte in the books' folder outside the vault: the journal, the + // database (and its WAL), the stored files, the access log, the books' + // own backup, the handed files' records; but the handed files' bytes, + // kept for their download (D-29: the package and worksheet with TINs, + // for the CPA, by design) and scanned above through it. let books = m.books_dir(); + let handed = handed_exports(&m); let mut seen = Vec::new(); for f in files_under(&books) { - if f.starts_with(m.vault()) { + if f.starts_with(m.vault()) || handed.contains(&f) { continue; } let bytes = std::fs::read(&f).unwrap(); @@ -1396,32 +1307,6 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { } } -/// Books kept in git must ignore the vault before a TIN is stored. -#[test] -fn books_in_git_must_ignore_the_vault() { - let mut m = books("git"); - let books = m.books_dir(); - std::fs::create_dir_all(books.join(".git")).unwrap(); - std::fs::write(books.join(".gitignore"), "books-data/wanderbooks.db\n").unwrap(); - let why = m - .w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})) - .err_text(); - assert!( - why.contains("version history") && why.contains("Ask the human"), - "{why}" - ); - assert!(!m.vault().exists() && !m.key().exists()); - std::fs::write( - books.join(".gitignore"), - "books-data/wanderbooks.db\nvault/\n", - ) - .unwrap(); - assert!( - !m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})) - .refused() - ); -} - // ------------------------------------------------- books.set_tin, any role /// The TIN facts a party's last `party.tin_set` journaled. @@ -1722,9 +1607,9 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { .collect(); assert_eq!(gaps.len(), 1, "{gaps:?}"); assert!(gaps[0].contains("Mo Example") && gaps[0].contains("TIN not in the vault")); - assert!(!holds_tin(said(&out).as_bytes(), MEMBER_SSN_DIGITS)); - assert!(!holds_tin(said(&out).as_bytes(), EIN_DIGITS)); - let pkg = unzip(&handed(&out["file"])); + assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS)); + assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS)); + let pkg = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); assert_eq!(k1.len(), 3, "two members and their total"); let (tobin, mo) = (&k1[0], &k1[1]); @@ -1815,9 +1700,12 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { "{readme}" ); assert!(!holds_tin(readme.as_bytes(), MEMBER_SSN_DIGITS)); - // Nothing of it in the books dir. - for f in files_under(&m.books_dir()) { - if f.starts_with(m.vault()) { + // Nothing of it in the books' folder, but the file handed over, kept + // for its download (D-29), and nothing in exports/ but what was handed. + let books = m.books_dir(); + let handed = handed_exports(&m); + for f in files_under(&books) { + if f.starts_with(m.vault()) || handed.contains(&f) { continue; } let b = std::fs::read(&f).unwrap(); @@ -1837,12 +1725,12 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { assert_eq!(out["tins_included"], false); assert_eq!(out["note"], "The package, with last fours only."); assert!(out["tin_gaps"].as_array().unwrap().is_empty(), "{out}"); - let plain = unzip(&handed(&out["file"])); + let plain = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&plain["k1-members.csv"]); assert_eq!(k1[0]["tin_last4"], "4915"); assert!(!k1[0].contains_key("tin")); assert_eq!(k1[0]["ending_capital"], "950.00"); - // "50%" in the settings file prints by its value, at two places, as + // "50%" in the settings prints by its value, at two places, as // it would had the books kept it as "0.5". assert_eq!(k1[0]["share"], "0.50"); assert_eq!(k1[1]["share"], "0.50"); @@ -1852,15 +1740,15 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { } } -/// The package, with TINs or without, is handed over (inline over stdio, -/// D-29) and never written: no file appears in the books dir outside the -/// vault, or anywhere else. +/// The package, with TINs or without, is handed over (by its download, +/// D-29) and never written: no file appears in the books' folder outside +/// the vault (but the file handed over, kept for its download), or +/// anywhere else. #[test] fn a_package_is_handed_over_and_never_written() { let mut m = k1_books("k1-handed"); let books = m.books_dir(); - let (books_before, outside_before) = - (paths_outside_vault(&m, &books), files_under(&m.outside())); + let (books_before, outside_before) = (paths_outside_vault(&m, &books), m.outside_files()); for tins in [true, false] { let out = m.read_only( "books.export_cpa_package", @@ -1868,7 +1756,7 @@ fn a_package_is_handed_over_and_never_written() { ); assert_eq!(out["tins_included"], tins); assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip"); - let pkg = unzip(&handed(&out["file"])); + let pkg = unzip(&m.handed(&out["file"])); assert!(pkg.contains_key("README.txt"), "include_tins {tins}"); assert_eq!( holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS), @@ -1877,7 +1765,7 @@ fn a_package_is_handed_over_and_never_written() { ); } assert_eq!(paths_outside_vault(&m, &books), books_before); - assert_eq!(files_under(&m.outside()), outside_before); + assert_eq!(m.outside_files(), outside_before); } /// A locked vault never fails the package: every TIN row says why, and @@ -1898,7 +1786,7 @@ fn a_locked_vault_still_builds_the_package() { "{gaps:?}" ); assert!(!m.key().exists(), "reading never makes a key"); - let pkg = unzip(&handed(&out["file"])); + let pkg = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); let members: Vec<_> = k1 .iter() @@ -2011,7 +1899,7 @@ fn a_k1_row_never_carries_another_partys_tin() { ); let gaps = out["tin_gaps"].to_string(); assert!(gaps.contains("member Mo Example"), "{gaps}"); - let pkg = unzip(&handed(&out["file"])); + let pkg = unzip(&m.handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); let mo = k1.iter().find(|r| r["member"] == "Mo Example").unwrap(); assert_eq!(mo["tin"], "", "{mo:?}"); @@ -2035,7 +1923,7 @@ fn joint_filers_are_noted_in_the_package() { "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); - let pkg = unzip(&handed(&out["file"])); + let pkg = unzip(&m.handed(&out["file"])); let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap(); let note = "Tobin Example and Mo Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II)."; assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}"); @@ -2101,18 +1989,13 @@ fn idempotent_tools_change_nothing_when_repeated() { .as_str() .unwrap() .to_string(); - let settings_file = - std::fs::read_to_string(m.data.parent().unwrap().join("wanderbooks.toml")).unwrap(); m.ok( "books.add_instrument", json!({"kind": "member_card", "label": "Riley's Visa", "account": "1000"}), ); let calls: Vec<(&str, Value)> = vec![ ("books.verify", json!({"blobs": true})), - ( - "books.import_settings", - json!({"toml_text": settings_file, "decided_by": "Owner", "confirmed": true}), - ), + ("books.back_up_now", json!({})), ( "books.set_setting", json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}], diff --git a/crates/ledger-cli/tests/startup.rs b/crates/ledger-cli/tests/startup.rs new file mode 100644 index 0000000..66310fa --- /dev/null +++ b/crates/ledger-cli/tests/startup.rs @@ -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) { + 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 + ); +} diff --git a/crates/ledger-mcp/tests/tin_files.rs b/crates/ledger-cli/tests/tin_files.rs similarity index 79% rename from crates/ledger-mcp/tests/tin_files.rs rename to crates/ledger-cli/tests/tin_files.rs index e606fad..5fe84f6 100644 --- a/crates/ledger-mcp/tests/tin_files.rs +++ b/crates/ledger-cli/tests/tin_files.rs @@ -27,17 +27,12 @@ //! 2026-10-16: bank exports are filed for reconciliation). //! ``` -use rmcp::ServiceExt; -use rmcp::model::{CallToolRequestParams, ClientConfig}; -use rmcp::service::{RoleClient, RunningService}; -use rmcp::transport::StreamableHttpClientTransport; +use common::{Client, call}; use serde_json::{Value, json}; use std::io::Read; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; -type Client = RunningService; +mod common; const SSN: &str = "291-12-2846"; const MEMBER_SSN: &str = "830-83-5531"; @@ -51,123 +46,49 @@ const TIN_PROBE: &str = "more than two numbers shaped like a TIN"; /// The refusal for text holding a number printed as an SSN or EIN is. const TIN_PRINTED: &str = "printed the way an SSN or EIN is"; -struct Server(Child); -impl Drop for Server { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -/// The books dir, a folder for files on their way in, a folder outside -/// both, and the settings (a member, so the CPA package has a K-1 -/// worksheet). +/// 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, - toml: PathBuf, -} - -/// The books dir and the folders beside it go when the test ends, pass or -/// panic. -impl Drop for Books { - fn drop(&mut self) { - if let Some(base) = self.dir.parent() { - let _ = std::fs::remove_dir_all(base); - } - } } fn books(tag: &str) -> Books { - let base = - std::env::temp_dir().join(format!("ledger-mcp-tin-files-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - let dir = base.join("books"); - let inbox = base.join("inbox"); - let out = base.join("out"); - for d in [&dir, &inbox, &out, &base.join("config")] { + 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(); } - let toml = dir.join("wanderbooks.toml"); - std::fs::write( - &toml, - format!( - "[company]\nname = \"Tin Co\"\n[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n\ - [[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n\ - [vault]\nkey_file = \"{}\"\n", - base.join("config/vault.key").display() - ), - ) - .unwrap(); Books { - dir, + dir: served.dir(), + served, inbox, out, - toml, } } -fn free_port() -> u16 { - std::net::TcpListener::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() -} - -/// Start the server over HTTP on these books; every client is a session -/// of its own. -fn serve(b: &Books) -> (Server, String) { - let addr = format!("127.0.0.1:{}", free_port()); - let base = b.dir.parent().unwrap(); - let child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) - .arg("--data-dir") - .arg(b.dir.join("books-data")) - .arg("--books-toml") - .arg(&b.toml) - .arg("--http") - .arg(&addr) - .env("XDG_CONFIG_HOME", base.join("config")) - .env("XDG_RUNTIME_DIR", base.join("config")) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - (Server(child), format!("http://{addr}/mcp")) -} - -async fn connect(url: &str) -> Client { - let deadline = Instant::now() + Duration::from_secs(30); - loop { - let transport = StreamableHttpClientTransport::from_uri(url.to_string()); - match ClientConfig::default().serve(transport).await { - Ok(c) => return c, - Err(e) if Instant::now() > deadline => panic!("connect: {e}"), - Err(_) => tokio::time::sleep(Duration::from_millis(100)).await, - } - } -} - -/// Call a tool: Ok(structured result) or Err(the tool's error text). -async fn call(c: &Client, tool: &str, args: Value) -> Result { - let args = args.as_object().cloned().unwrap_or_default(); - let r = c - .call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)) - .await - .map_err(|e| format!("protocol: {e}"))?; - let text = r - .content - .iter() - .find_map(|c| c.as_text().map(|t| t.text.clone())) - .unwrap_or_default(); - if r.is_error == Some(true) { - Err(text) - } else { - Ok(r.structured_content - .unwrap_or_else(|| serde_json::from_str(&text).unwrap())) - } +/// 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 { @@ -185,22 +106,11 @@ fn inline(bytes: &[u8]) -> (String, String) { ) } -/// The bytes of a file a tool handed over (a `HandedFile`): inline, as on -/// books served from a --data-dir, matching the size and SHA-256 it -/// states. -fn handed(file: &Value) -> Vec { - use base64::Engine; - assert!(file.get("download").is_none(), "{file}"); - let bytes = base64::engine::general_purpose::STANDARD - .decode(file["content_base64"].as_str().expect("content_base64")) - .unwrap(); - assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); - assert_eq!( - file["sha256"], - json!(ledger_store::journal::sha256_hex(&bytes)), - "{file}" - ); - bytes +/// 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 { + 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 @@ -214,7 +124,7 @@ async fn refused_everywhere( ) -> Vec { let bytes = std::fs::read(path).unwrap(); let (content, sha) = inline(&bytes); - let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE); + let journal = b.dir.join(ledger_store::JOURNAL_FILE); let before = std::fs::read(&journal).unwrap(); let mut refusals = Vec::new(); for (tool, args) in [ @@ -257,8 +167,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() ) .unwrap(); let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap()); - let (server, url) = serve(&b); - let one = connect(&url).await; + let one = connect(&b).await; for (code, name, ty) in [ ("1000", "Cash", "asset"), ("3000", "Capital", "equity"), @@ -303,7 +212,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() // 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(©, handed(&got["file"])).unwrap(); + std::fs::write(©, handed(&b, &got["file"]).await).unwrap(); let csv = b.out.join("1099.csv"); let got = ok( &one, @@ -311,7 +220,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() json!({"year": 2026, "include_tins": true, "csv": true}), ) .await; - std::fs::write(&csv, handed(&got["csv"])).unwrap(); + std::fs::write(&csv, handed(&b, &got["csv"]).await).unwrap(); let zip = b.out.join("cpa.zip"); let got = ok( &one, @@ -320,7 +229,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() ) .await; assert_eq!(got["tins_included"], true, "{got}"); - std::fs::write(&zip, handed(&got["file"])).unwrap(); + 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(); @@ -341,7 +250,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() (k1.clone(), "k1-members.csv"), ]; // A second session refuses each. - let two = connect(&url).await; + let two = connect(&b).await; for (path, name) in &files { refused_everywhere(&two, &b, path, name, TIN_FILE).await; } @@ -372,11 +281,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() } accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await; drop((one, two)); - drop(server); + b.served.restart(); // A server started afresh on the same books refuses them all too. - let (_server, url) = serve(&b); - let three = connect(&url).await; + let three = connect(&b).await; for (path, name) in &files { refused_everywhere(&three, &b, path, name, TIN_FILE).await; } @@ -404,8 +312,7 @@ async fn a_refused_set_w9_never_marks_its_file() { std::fs::write(&w9, &w9_bytes).unwrap(); let notes = b.inbox.join("notes.txt"); std::fs::write(¬es, b"Acme sent their W-9 on the 3rd.\n").unwrap(); - let (_server, url) = serve(&b); - let c = connect(&url).await; + let c = connect(&b).await; for (code, name, ty) in [("1000", "Cash", "asset"), ("3000", "Capital", "equity")] { ok( &c, @@ -512,8 +419,7 @@ Date,Description,Amount,Balance,Reference #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not() { let b = books("text"); - let (server, url) = serve(&b); - let c = connect(&url).await; + let c = connect(&b).await; ok( &c, "books.add_party", @@ -549,14 +455,14 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_ } accepted_everywhere(&c, "bank.csv", BANK_CSV).await; drop(c); - drop(server); // The vault locked (its key gone): the printed shapes are still // refused, and the bank export still goes in; the vault's TIN written // bare is no longer recognized. - std::fs::remove_file(b.dir.parent().unwrap().join("config/vault.key")).unwrap(); - let (_server, url) = serve(&b); - let c = connect(&url).await; + 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"); @@ -577,18 +483,15 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_ #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn the_company_s_own_ein_goes_in_and_no_other() { let b = books("own-ein"); - let toml = std::fs::read_to_string(&b.toml).unwrap(); - std::fs::write( - &b.toml, - toml.replacen( - "name = \"Tin Co\"\n", - "name = \"Tin Co\"\nein = \"12-3456789\"\n", - 1, - ), + let c = connect(&b).await; + call( + &c, + "books.set_setting", + json!({"changes": [{"key": "company.ein", "value": "12-3456789"}], + "decided_by": "Test Owner"}), ) + .await .unwrap(); - let (_server, url) = serve(&b); - let c = connect(&url).await; accepted_everywhere( &c, "plan.md", @@ -616,8 +519,7 @@ async fn the_company_s_own_ein_goes_in_and_no_other() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn utf16_text_without_a_byte_order_mark_is_scanned_either_way_round() { let b = books("utf16"); - let (_server, url) = serve(&b); - let c = connect(&url).await; + let c = connect(&b).await; ok( &c, "books.add_party", @@ -662,8 +564,7 @@ async fn utf16_text_without_a_byte_order_mark_is_scanned_either_way_round() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() { let b = books("probe"); - let (server, url) = serve(&b); - let c = connect(&url).await; + let c = connect(&b).await; ok( &c, "books.add_party", @@ -719,12 +620,12 @@ async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() { accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await; accepted_everywhere(&c, "bank.csv", BANK_CSV).await; drop(c); - drop(server); // The vault locked: the same refusals, word for word. - std::fs::remove_file(b.dir.parent().unwrap().join("config/vault.key")).unwrap(); - let (_server, url) = serve(&b); - let c = connect(&url).await; + 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, diff --git a/crates/ledger-config/Cargo.toml b/crates/ledger-config/Cargo.toml index 067908b..08b9e0b 100644 --- a/crates/ledger-config/Cargo.toml +++ b/crates/ledger-config/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Company settings for wanderbooks: the books dir's wanderbooks.toml." +description = "Company settings for wanderbooks: an older books dir's wanderbooks.toml, read at adoption." [dependencies] ledger-core = { path = "../ledger-core" } @@ -16,7 +16,6 @@ toml = { version = "1", features = ["preserve_order"] } [dev-dependencies] proptest = "1" -tempfile = "3" [lints] workspace = true diff --git a/crates/ledger-config/src/codec.rs b/crates/ledger-config/src/codec.rs index 5905597..5824ad3 100644 --- a/crates/ledger-config/src/codec.rs +++ b/crates/ledger-config/src/codec.rs @@ -44,8 +44,9 @@ pub struct Import { pub notes: Vec, /// Entries the books don't know: not imported (INVARIANT 3). pub unknown: Vec, - /// `[vault] key_file`, if the file sets one: the server's to use - /// (`--vault-key-file`), not a books setting. + /// `[vault] key_file`, if the file sets one: not a books setting (a + /// book in the data root keeps its own key: `books adopt --vault-key` + /// brings it in). pub vault_key_file: Option, /// The file's comments, in order: a settings file's notes (where a /// value came from, what it waits on) are not settings, so they are @@ -151,7 +152,7 @@ pub fn import( ("vault", "key_file") => { out.vault_key_file = v.as_str().map(str::to_string); out.notes.push( - "[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(), + "[vault] key_file: not a books setting (a book in the data root keeps its own key: adopt with --vault-key naming that file); dropped".into(), ); } ("viewer", n) => out.notes.push(format!( @@ -332,7 +333,6 @@ pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings { investing_accounts: s.investing_accounts(), asset_tag_prefix: s.asset_tag_prefix().map(str::to_string), parties: s.parties(), - vault_key_file: None, timezone: s.timezone().map(str::to_string), } } @@ -395,7 +395,7 @@ pub fn export(s: &Settings, base_currency: &str) -> String { .unwrap_or_default() ); out.push_str( - "# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n", + "# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n", ); for table in ["company", "books"] { let _ = write!(out, "\n[{table}]\n"); diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index 1c743b6..8d69391 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -1,14 +1,17 @@ -//! Company settings from the books dir's `wanderbooks.toml`. +//! Company settings as an older books dir's `wanderbooks.toml` holds them. //! -//! An older books dir keeps that file next to `books-data/` until -//! `books.import_settings` moves it into the journal (books of the data -//! root never read one). The server reads the company name (report headers), -//! the fiscal year start (fiscal-year-to-date reports), the members of a -//! multi-member LLC (`[[company.members]]`: name, share, capital account — -//! for the statement of members' equity), `[books] accounting_basis` -//! (report footers), and `[books] receipt_threshold` (the receipt policy, -//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before -//! the next start, so the two can't disagree. +//! Nothing that serves or shows books reads that file (D-44): the books +//! keep their own settings in their journal. The file is read once, when +//! `wanderbooks books adopt` brings an older books dir in whole (`codec` +//! moves its settings, and its `config.json`'s receipt policy, into the +//! journal), and `books.export_settings` writes the books' settings out as +//! one. It holds the company name (report headers), the fiscal year start +//! (fiscal-year-to-date reports), the members of a multi-member LLC +//! (`[[company.members]]`: name, share, capital account — for the +//! statement of members' equity), `[books] accounting_basis` (report +//! footers), and `[books] receipt_threshold` (the receipt policy, unless +//! the books dir's `config.json` sets one). The fiscal year *end* is never +//! stored: it is the day before the next start, so the two can't disagree. pub mod codec; pub mod receipt_policy; @@ -17,9 +20,9 @@ pub use receipt_policy::ReceiptPolicy; use rust_decimal::Decimal; use serde::Deserialize; -use std::path::Path; -/// What the server takes from `wanderbooks.toml`. +/// What a `wanderbooks.toml` says, read (`parse`); and the books' own +/// settings in the same shape (`codec::books_settings`). #[derive(Clone, Debug, Default, PartialEq)] pub struct BooksSettings { /// `[company]`. @@ -52,9 +55,6 @@ pub struct BooksSettings { /// `[books] parties`: how hard the agent asks for each entry's /// counterparty. Unset: [`PartyPolicy::Suggest`]. pub parties: PartyPolicy, - /// `[vault] key_file`: the TIN vault's key file. Unset: the vault's - /// default (`~/.config/wanderbooks/vault.key`). - pub vault_key_file: Option, /// `[company] timezone`: the company's time zone (an IANA name), what /// "today" means for the books. Unset: the computer's. pub timezone: Option, @@ -210,32 +210,10 @@ fn codes(key: &str, list: Vec) -> Result, String> { .collect() } -/// The receipt policy in force: `config_json`'s `receipt_policy` when the -/// file sets one, else `[books] receipt_threshold`, else the default -/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and -/// the viewer both call this, so they enforce and show one rule. -pub fn effective_receipt_policy( - config_json: &Path, - settings: &BooksSettings, -) -> Result { - let raw = if config_json.exists() { - Some( - std::fs::read_to_string(config_json) - .map_err(|e| format!("{}: {e}", config_json.display()))?, - ) - } else { - None - }; - effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| { - e.replace( - "bad config", - &format!("bad config {}", config_json.display()), - ) - }) -} - -/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no -/// such file). +/// The receipt policy in force: `config_json`'s `receipt_policy` (the +/// text of an older books dir's `config.json`; `None`: no such file) when +/// it sets one, else `[books] receipt_threshold`, else the default +/// ($25.00); validated (crate::receipt_policy INVARIANT 4). pub fn effective_receipt_policy_text( config_json: Option<&str>, settings: &BooksSettings, @@ -448,6 +426,11 @@ pub fn parse(text: &str) -> Result { }) .collect(); } + // The key file is no books setting (`codec` notes it), but an empty + // one is still a broken file. + if raw.vault.key_file.is_some_and(|p| p.trim().is_empty()) { + return Err("[vault] key_file is empty".into()); + } let timezone = match raw.company.timezone { Some(z) => { let key = ledger_core::settings::key("company.timezone").expect("a known key"); @@ -471,16 +454,6 @@ pub fn parse(text: &str) -> Result { cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?, financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?, investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?, - vault_key_file: match raw.vault.key_file { - Some(p) if p.trim().is_empty() => { - return Err("[vault] key_file is empty".into()); - } - Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") { - Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()), - None => p, - })), - None => None, - }, asset_tag_prefix: match raw.books.asset_tag_prefix { Some(p) if !p.is_empty() @@ -513,12 +486,6 @@ fn toml_json(s: &str) -> serde_json::Value { serde_json::Value::String(s.to_string()) } -/// Load `wanderbooks.toml` from a path. -pub fn load(path: &Path) -> Result { - let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; - parse(&text).map_err(|e| format!("{}: {e}", path.display())) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-config/tests/effective_policy.rs b/crates/ledger-config/tests/effective_policy.rs index 97e2d53..b361a92 100644 --- a/crates/ledger-config/tests/effective_policy.rs +++ b/crates/ledger-config/tests/effective_policy.rs @@ -1,56 +1,28 @@ -//! The receipt policy in force when the settings come from files: -//! config.json's when it sets one, else the settings file's threshold. +//! The receipt policy an older books dir's files set, as adoption moves it +//! in: config.json's when it sets one, else the settings file's threshold. -use ledger_config::{BooksSettings, effective_receipt_policy}; - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn dir(tag: &str) -> TestDir { - let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - TestDir(d) -} +use ledger_config::{BooksSettings, effective_receipt_policy_text}; #[test] fn config_json_wins_else_the_settings_file() { - let d = dir("files"); - let json = d.join("config.json"); let settings = BooksSettings { receipt_threshold: Some("0.00".into()), ..BooksSettings::default() }; // No config.json: the settings file's threshold. - let p = effective_receipt_policy(&json, &settings).unwrap(); + let p = effective_receipt_policy_text(None, &settings).unwrap(); assert_eq!(p.require_receipts_above, "0.00"); // config.json's policy, whole. - std::fs::write( - &json, - r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#, + let p = effective_receipt_policy_text( + Some( + r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#, + ), + &settings, ) .unwrap(); - let p = effective_receipt_policy(&json, &settings).unwrap(); assert_eq!(p.require_receipts_above, "10.00"); assert_eq!(p.receipt_exempt_accounts, ["6999"]); - // A broken one is refused, by name. - std::fs::write(&json, "{ not json").unwrap(); - let err = effective_receipt_policy(&json, &settings).unwrap_err(); - assert!( - err.starts_with(&format!("bad config {}", json.display())), - "{err}" - ); + // A broken one is refused. + let err = effective_receipt_policy_text(Some("{ not json"), &settings).unwrap_err(); + assert!(err.starts_with("bad config"), "{err}"); } diff --git a/crates/ledger-config/tests/spec_config.rs b/crates/ledger-config/tests/spec_config.rs index 40ba111..ebed383 100644 --- a/crates/ledger-config/tests/spec_config.rs +++ b/crates/ledger-config/tests/spec_config.rs @@ -6,7 +6,7 @@ //! silent, nothing is asserted. use chrono::{Datelike, Months, NaiveDate}; -use ledger_config::{BooksSettings, load, parse}; +use ledger_config::{BooksSettings, parse}; use ledger_core::TaxForm; use proptest::prelude::*; use rust_decimal::Decimal; @@ -717,31 +717,6 @@ fn real_demo_file() { assert_eq!(dec(s.receipt_threshold.as_deref().unwrap()), dec("25")); } -// --------------------------------------------------------------------------- -// load. -// --------------------------------------------------------------------------- - -/// `load` on a missing path is an error. -#[test] -fn load_missing_path_errors() { - let dir = tempfile::tempdir().unwrap(); - assert!(load(&dir.path().join("nope/wanderbooks.toml")).is_err()); -} - -/// `load` reads a file the same as `parse` reads its text, refusals -/// included. -#[test] -fn load_matches_parse() { - let dir = tempfile::tempdir().unwrap(); - let p = dir.path().join("wanderbooks.toml"); - for text in [FERNHILL, DEMO, ""] { - std::fs::write(&p, text).unwrap(); - assert_eq!(load(&p), parse(text)); - } - std::fs::write(&p, two_members("50%", "49%")).unwrap(); - assert!(load(&p).is_err()); -} - // --------------------------------------------------------------------------- // Properties. // --------------------------------------------------------------------------- diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 3b29e33..1384b76 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -16,7 +16,7 @@ use ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement, use rust_decimal::Decimal; use serde_json::Value; use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::nav::Route; @@ -597,18 +597,8 @@ impl Books { let attachments = snap.attachments(None).map_err(|e| e.to_string())?; let journal = snap.journal().to_vec(); // The server's own rule, so the viewer flags exactly what the - // server would refuse: the books' settings; for an older books dir - // that keeps none, its config.json, else its settings file. - let policy = if ledger.settings().is_journaled() - || ledger_store::root::inside_a_root(snap.data_dir()) - { - ledger_config::codec::receipt_policy(ledger.settings()) - } else { - ledger_config::effective_receipt_policy( - &snap.data_dir().join("config.json"), - &settings, - )? - }; + // server would refuse: the books' settings. + let policy = ledger_config::codec::receipt_policy(ledger.settings()); let receipt_threshold = policy.threshold(); let cash_accounts = cash_accounts(&ledger, &settings); let members = settings.company.party_members(&ledger); @@ -1658,15 +1648,6 @@ impl Books { ledger_core::parties::activity_with(&self.ledger, party, span, &self.money, &self.members) } - /// The books dir: the folder holding `books-data/` (and `vault/`). - fn books_dir(&self) -> PathBuf { - let d = self - .data_dir - .canonicalize() - .unwrap_or_else(|_| self.data_dir.clone()); - d.parent().map(Path::to_path_buf).unwrap_or(d) - } - /// A party's full TIN from the encrypted vault, formatted /// ("123-45-6789" for an SSN or ITIN, "12-3456789" for an EIN), for the /// clipboard only: never drawn on screen. @@ -1675,12 +1656,8 @@ impl Books { /// INVARIANT 1: read only on demand (a click), never on load or paint. /// INVARIANT 2: never creates a key file; a missing key is a locked /// vault ("restore it from your password manager"). - /// INVARIANT 3: the vault and key are the server's: for a book in the - /// data root, its own `vault/` and `keys/legacy.key`; for older - /// books, `vault/` beside `books-data/` and the key file the one - /// given on the command line (--vault-key-file), else the settings' - /// [vault] key_file, else ~/.config/wanderbooks/vault.key, one - /// inside the books dir refused, as the server refuses it. + /// INVARIANT 3: the vault and key are the server's: the book's own + /// `vault/` and `keys/legacy.key` (a book of the data root, D-44). /// INVARIANT 4: an error never carries the digits (the vault's errors /// are redacted); a party with no record is "not in the vault". /// ``` @@ -1694,26 +1671,13 @@ impl Books { } /// Where the vault and its key are (INVARIANT 3 of [`Self::tin_from_vault`]). - fn vault_place(&self) -> Result<(PathBuf, PathBuf), String> { - if ledger_store::root::inside_a_root(&self.data_dir) { - return Ok(( - self.data_dir.join(ledger_store::root::VAULT_DIR), - self.data_dir - .join(ledger_store::root::KEYS_DIR) - .join(ledger_store::root::LEGACY_KEY_FILE), - )); - } - let key = crate::vault_key_file() - .or_else(|| self.settings.vault_key_file.clone()) - .unwrap_or_else(ledger_vault::default_key_file); - let books = self.books_dir(); - if resolved(&key).starts_with(resolved(&books)) { - return Err(format!( - "The vault key file {} is inside the books folder: move it out, and name its new place to the server and the viewer (--vault-key-file).", - key.display() - )); - } - Ok((books.join("vault"), key)) + fn vault_place(&self) -> (PathBuf, PathBuf) { + ( + self.data_dir.join(ledger_store::root::VAULT_DIR), + self.data_dir + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE), + ) } /// A party's signed W-9 file from the vault, on demand (a click). @@ -1727,7 +1691,7 @@ impl Books { /// The vault, opened read-only, never making a key (a missing one is a /// locked vault). pub fn open_vault(&self) -> Result { - let (dir, key) = self.vault_place()?; + let (dir, key) = self.vault_place(); ledger_vault::Vault::open(&dir, &key, false).map_err( |e| match e { ledger_vault::Error::NoKey(k) => format!( @@ -2287,28 +2251,3 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec { ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts) } - -/// `path` made absolute through its nearest existing ancestor (symlinks -/// resolved), so a path that doesn't exist yet can still be placed: the -/// server's rule for where the vault key may live. -fn resolved(path: &Path) -> PathBuf { - let abs = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir().unwrap_or_default().join(path) - }; - let mut base = abs.as_path(); - let mut rest = Vec::new(); - while !base.exists() { - match (base.file_name(), base.parent()) { - (Some(name), Some(up)) => { - rest.push(name.to_os_string()); - base = up; - } - _ => return abs, - } - } - let mut out = base.canonicalize().unwrap_or_else(|_| base.to_path_buf()); - out.extend(rest.into_iter().rev()); - out -} diff --git a/crates/ledger-gui/src/data.rs b/crates/ledger-gui/src/data.rs index bfb84f4..b7794ff 100644 --- a/crates/ledger-gui/src/data.rs +++ b/crates/ledger-gui/src/data.rs @@ -16,11 +16,9 @@ use std::sync::Arc; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; use std::time::Duration; -/// Where the books come from: their folder (a book of the data root, or -/// an older books dir's `books-data`). Their settings are their own, in -/// their journal (plan 3.6); only an older books dir whose journal keeps -/// none is read as its server reads it, from its settings file (see -/// `settings`). +/// Where the books come from: their folder, a book of the data root +/// (`wanderbooks gui ID`; D-44). Their settings are their own, in their +/// journal (plan 3.6), read as the server reads them. #[derive(Clone, Debug)] pub struct Source { /// The books' folder (journal and blobs). @@ -29,34 +27,6 @@ pub struct Source { pub today: Option, } -/// The settings file an older books dir's server reads when the books -/// keep no settings of their own: `wanderbooks.toml` beside `books-data`, -/// or the one named with `--books-toml`. Never for a book of the data -/// root. -pub fn legacy_toml(data_dir: &std::path::Path) -> Option { - legacy_toml_path(data_dir).filter(|p| p.is_file()) -} - -/// Where an older books dir's settings file is, whether or not it is -/// there yet (a file made later is followed, as the server follows it). -fn legacy_toml_path(data_dir: &std::path::Path) -> Option { - if ledger_store::root::inside_a_root(data_dir) { - return None; - } - if let Some(named) = LEGACY_TOML.get() { - return Some(named.clone()); - } - Some(data_dir.parent()?.join("wanderbooks.toml")) -} - -/// `--books-toml`, for an older books dir (once, at start). -static LEGACY_TOML: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Name an older books dir's settings file (`--books-toml`). -pub fn set_legacy_toml(path: PathBuf) { - let _ = LEGACY_TOML.set(path); -} - pub enum Update { Books(Arc), Error(String), @@ -109,7 +79,7 @@ pub fn load_once(source: &Source) -> Result { }) })?; let integrity = Integrity::check(&snap, &mut HashSet::new()); - let settings = settings(source, &snap)?; + let settings = settings(&snap); let today = today(source, &settings); Books::build(&snap, settings, today, None, integrity) } @@ -139,26 +109,13 @@ fn today(source: &Source, settings: &BooksSettings) -> NaiveDate { .unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref())) } -/// The settings, as the server reads them: the books' own, from their -/// journal; for an older books dir that keeps none, its settings file -/// (else the defaults). A book of the data root reads no file. -fn settings(source: &Source, snap: &Snapshot) -> Result { - settings_of(&source.data_dir, snap) -} - -/// `settings` for the books in `data_dir`, as `snap` holds them. -pub fn settings_of(data_dir: &std::path::Path, snap: &Snapshot) -> Result { - let books = snap.ledger().settings(); - if !books.is_journaled() - && let Some(toml) = legacy_toml(data_dir) - && toml.is_file() - { - return ledger_config::load(&toml); - } - Ok(ledger_config::codec::books_settings( - books, +/// The settings: the books' own, from their journal, as the server reads +/// them (the defaults until the first is set). +pub fn settings(snap: &Snapshot) -> BooksSettings { + ledger_config::codec::books_settings( + snap.ledger().settings(), snap.ledger().base_currency().code(), - )) + ) } fn run( @@ -191,30 +148,14 @@ fn run( let mut build = |snap: &Snapshot, prev: Option| -> (Update, ledger_store::DiskCheck) { let integrity = Integrity::check(snap, &mut verified); let disk = integrity.disk.clone(); - let update = match settings(&source, snap).and_then(|s| { - let today = today(&source, &s); - Books::build(snap, s, today, prev, integrity) - }) { + let s = settings(snap); + let today = today(&source, &s); + let update = match Books::build(snap, s, today, prev, integrity) { Ok(b) => Update::Books(Arc::new(b)), Err(e) => Update::Error(e), }; (update, disk) }; - // An older books dir's settings file, followed as its server follows - // it (only read while the books keep no settings of their own). - // And its config.json, whose receipt rule the server reads too. - let toml = legacy_toml_path(&source.data_dir); - let config = (!ledger_store::root::inside_a_root(&source.data_dir)) - .then(|| source.data_dir.join("config.json")); - let mtime = |t: &Option| { - t.as_ref() - .and_then(|p| std::fs::metadata(p).ok()) - .and_then(|m| m.modified().ok()) - }; - let toml_mtime = |t: &Option| (mtime(t), mtime(&config)); - // Taken before the first build, so a file written while it is being - // built is a change, never part of the starting state. - let mut toml_seen = toml_mtime(&toml); let (first, disk) = build(&snap, None); let mut last_disk = Some(disk); send(first); @@ -267,15 +208,8 @@ fn run( } }; look_at_ops(&mut watcher); - let day_now = |snap: &Snapshot| { - settings(&source, snap) - .map(|s| today(&source, &s)) - .unwrap_or_else(|_| today(&source, &BooksSettings::default())) - }; + let day_now = |snap: &Snapshot| today(&source, &settings(snap)); let mut day = day_now(&snap); - if let (Some(w), Some(dir)) = (watcher.as_mut(), toml.as_ref().and_then(|t| t.parent())) { - let _ = w.watch(dir, RecursiveMode::NonRecursive); - } loop { if gone() { return; @@ -302,7 +236,6 @@ fn run( } }; look_at_ops(&mut watcher); - let toml_now = toml_mtime(&toml); let new_day = day_now(&snap); // The writer journals first and updates its database right after: // a "behind" database usually catches up a moment later, so look @@ -311,9 +244,8 @@ fn run( && last_disk .as_ref() .is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d); - if grew || disk_changed || new_day != day || toml_now != toml_seen { + if grew || disk_changed || new_day != day { day = new_day; - toml_seen = toml_now; let (update, disk) = build(&snap, grew.then_some(before)); last_disk = Some(disk); send(update); diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 4cb7bfe..31c5d86 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -29,21 +29,6 @@ pub use data::Source; use std::path::PathBuf; -/// The vault key file named on the command line (`--vault-key-file`), -/// which wins over a settings file's `[vault] key_file`, as it does for -/// the server: books that keep their own settings never name it. -static VAULT_KEY_FILE: std::sync::OnceLock = std::sync::OnceLock::new(); - -/// Name the vault key file (once, at start). -pub fn set_vault_key_file(path: std::path::PathBuf) { - let _ = VAULT_KEY_FILE.set(path); -} - -/// The vault key file named on the command line, if one was. -pub fn vault_key_file() -> Option { - VAULT_KEY_FILE.get().cloned() -} - /// How [`run`] stops when it can't go on: the reason is already printed /// on stderr, and the number is the process's exit code (2: a bad argument; 1: the demo books couldn't be built). A /// library never ends the process itself, so the books it opened are @@ -64,14 +49,12 @@ impl std::error::Error for Exit {} /// `wanderbooks gui`). It opens the books read-only and returns when the /// window closes. An unknown argument returns [`Exit`] (code 2). /// -/// `ledger-gui [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]`: -/// a book of the data root (plan 3.6): `--root`, else the root `wanderbooks -/// setup` made, never the current directory or a settings file; `--book` -/// names one, else the root's only one (or the first, by id). -/// `--data-dir` opens an older books dir's `books-data` instead (its -/// settings file `--books-toml`, read while its journal keeps none, else -/// the `wanderbooks.toml` beside it; its vault key `--vault-key-file`); -/// `--demo` builds a demo company in a temp directory and opens that. +/// `ledger-gui [--root PATH] [--book ID] [--today YYYY-MM-DD] [--demo]`: +/// a book of the data root (plan 3.6), the only books it opens (D-44): +/// `--root`, else the root `wanderbooks setup` made, never the current +/// directory or a settings file; `--book` names one, else the root's only +/// one (or the first, by id). `--demo` builds a demo company in a temp +/// directory and opens that. pub fn run( prog: &str, args: impl IntoIterator, @@ -82,28 +65,11 @@ pub fn run( let mut today = None; let mut demo = false; let mut args = args.into_iter(); - let usage = || { - format!( - "usage: {prog} [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]" - ) - }; + let usage = || format!("usage: {prog} [--root PATH] [--book ID] [--today YYYY-MM-DD] [--demo]"); while let Some(a) = args.next() { match a.as_str() { "--root" => root = args.next().map(PathBuf::from), "--book" => book = args.next(), - "--data-dir" => data_dir = args.next().map(PathBuf::from), - // An older books dir's settings file, read only while its - // books keep no settings of their own (data::legacy_toml). - "--books-toml" => { - if let Some(t) = args.next() { - crate::data::set_legacy_toml(PathBuf::from(t)); - } - } - "--vault-key-file" => { - if let Some(k) = args.next() { - crate::set_vault_key_file(PathBuf::from(k)); - } - } "--today" => today = args.next().and_then(|d| d.parse().ok()), "--demo" => demo = true, "-h" | "--help" => { diff --git a/crates/ledger-gui/src/menu.rs b/crates/ledger-gui/src/menu.rs index 44fa281..df79997 100644 --- a/crates/ledger-gui/src/menu.rs +++ b/crates/ledger-gui/src/menu.rs @@ -151,9 +151,8 @@ impl Job { let snap = ledger_store::Snapshot::load(&books.data_dir).map_err(|e| e.to_string())?; let ledger = snap.ledger(); // The settings the rest of the viewer shows, read as the server - // reads them (data::settings: an older books dir that keeps none - // in its journal reads its settings file). - let st = &crate::data::settings_of(&books.data_dir, &snap)?; + // reads them (data::settings). + let st = &crate::data::settings(&snap); let settings = ledger_package::Settings { company: &st.company, cash_accounts: &st.cash_accounts, diff --git a/crates/ledger-gui/src/owner.rs b/crates/ledger-gui/src/owner.rs index 6e681ab..2238b1d 100644 --- a/crates/ledger-gui/src/owner.rs +++ b/crates/ledger-gui/src/owner.rs @@ -21,8 +21,8 @@ //! 2. Each runs off the UI thread; what came of it is shown, in words. //! 3. A restore is shown before it is done (the backup proven, what it //! would do), and done only for that backup, at that head. -//! 4. Without an owner (the viewer on its own, older books dirs, the -//! demo) the menu isn't there. +//! 4. Without an owner (the viewer on its own, books outside a data +//! root: the demo) the menu isn't there. //! 5. Books waiting for a format upgrade show a standing notice above //! every page, and the go-ahead is asked once each time the viewer //! opens them: the owner passphrase, typed here and handed to the @@ -109,7 +109,7 @@ fn owner() -> Option> { } /// The id of books of the data root (their folder's name), or None for -/// an older books dir (INVARIANT 4). +/// books outside one, the demo's (INVARIANT 4). pub fn book_id(books: &Books) -> Option { ledger_store::root::inside_a_root(&books.data_dir) .then(|| books.data_dir.file_name()?.to_str().map(str::to_string)) diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 5deaaa7..bc16196 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -169,19 +169,12 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { |_| {}, |ui| { let kept = b.ledger.settings(); - // Where the settings shown come from: a settings - // file only for an older books dir that keeps none - // in its journal and has one (data::settings). - let from_file = - !kept.is_journaled() && crate::data::legacy_toml(&b.data_dir).is_some(); // The currency is always known: facts are the rest. if c.profile.iter().all(|(k, _)| k == "base_currency") { ui.label( - RichText::new(if from_file { - "No company facts in the settings file yet: the agent records them as you give them." - } else { - "No company facts set yet: the agent records them as you give them." - }) + RichText::new( + "No company facts set yet: the agent records them as you give them.", + ) .color(p.muted), ); } @@ -204,11 +197,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { kv(ui, "Time zone", z); } ui.label( - RichText::new(if from_file { - "Read from the settings file: the agent can move it into the books." - } else { - "Kept in the books: the agent changes settings, with who decided and when." - }) + RichText::new( + "Kept in the books: the agent changes settings, with who decided and when.", + ) .font(theme::regular(12.0)) .color(p.muted), ); diff --git a/crates/ledger-gui/tests/command_line.rs b/crates/ledger-gui/tests/command_line.rs new file mode 100644 index 0000000..c285256 --- /dev/null +++ b/crates/ledger-gui/tests/command_line.rs @@ -0,0 +1,41 @@ +//! The viewer's command line: an unknown argument stops the viewer with +//! status 2, and it opens only books of the data root (D-44): no flag +//! names a loose books folder, its settings file or a vault key. + +use std::process::Command; + +/// The viewer, run in a home of the test's own with an empty environment +/// but PATH: if an argument it should refuse were ever taken, it would +/// find no books, no display and no preferences of this account's. +fn viewer(home: &std::path::Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_ledger-gui")); + c.env_clear().env("PATH", "/usr/bin:/bin").env("HOME", home); + c +} + +#[test] +fn an_unknown_argument_stops_the_viewer() { + let home = tempfile::Builder::new() + .prefix("wb-gui-args-") + .tempdir() + .unwrap(); + let out = viewer(home.path()).arg("--no-such-flag").output().unwrap(); + assert_eq!(out.status.code(), Some(2)); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let help = viewer(home.path()).arg("--help").output().unwrap(); + assert!(help.status.success()); + let help = String::from_utf8_lossy(&help.stdout); + assert!(help.contains("--book ID"), "{help}"); + for gone in ["--data-dir", "--books-toml", "--vault-key-file"] { + assert!(!help.contains(gone), "{help}"); + } + // A gone flag is an unknown argument like any other. + for gone in ["--data-dir", "--books-toml", "--vault-key-file"] { + let out = viewer(home.path()).args([gone, "x"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{gone}"); + } +} diff --git a/crates/ledger-gui/tests/exports.rs b/crates/ledger-gui/tests/exports.rs index 750c2f3..9d467c9 100644 --- a/crates/ledger-gui/tests/exports.rs +++ b/crates/ledger-gui/tests/exports.rs @@ -219,9 +219,6 @@ fn a_book_in_the_root_reads_its_own_vault() { ); let e = b.w9_file(f.member).unwrap_err(); assert!(e.contains("no W-9 file"), "{e}"); - // Its key is the book's own: one named on the command line is never - // used for it (none is named here, and none is needed). - assert!(ledger_gui::vault_key_file().is_none()); } #[test] @@ -465,12 +462,13 @@ fn the_menus_1099_worksheet_carries_a_payees_full_tin() { } /// The menu reads settings as the rest of the viewer and the server do: -/// an older books dir that keeps none in its journal, from its file. +/// the books' own, from their journal; a settings file beside them is +/// never read (D-44). #[test] -fn the_menu_reads_an_older_books_dirs_settings_file() { +fn the_menu_reads_the_books_own_settings() { downloads(); let dir = tempfile::Builder::new() - .prefix("wb-gui-older-") + .prefix("wb-gui-kept-") .tempdir() .unwrap(); let data = dir.path().join("books-data"); @@ -482,14 +480,33 @@ fn the_menu_reads_an_older_books_dirs_settings_file() { ] { s.add_account(code, name, ty, None, false).unwrap(); } + s.change_settings( + &[ + ledger_core::settings::Change { + key: "company.name".into(), + value: Some(serde_json::json!("Journal Kept Co")), + }, + ledger_core::settings::Change { + key: "books.cash_accounts".into(), + value: Some(serde_json::json!(["1000"])), + }, + ], + &ledger_core::settings::Source { + decided_by: "Tobin Harlow".into(), + decided_on: today(), + citation: None, + note: None, + }, + ) + .unwrap(); } std::fs::write( dir.path().join("wanderbooks.toml"), - "[company]\nname = \"File Kept Co\"\n[books]\ncash_accounts = [\"1000\"]\n", + "[company]\nname = \"File Beside Co\"\n", ) .unwrap(); let b = load(&data); - assert_eq!(b.settings.company.name, "File Kept Co"); + assert_eq!(b.settings.company.name, "Journal Kept Co"); let (path, _) = Job::Package.run(&b).unwrap(); let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); let mut readme = String::new(); @@ -497,7 +514,7 @@ fn the_menu_reads_an_older_books_dirs_settings_file() { .unwrap() .read_to_string(&mut readme) .unwrap(); - assert!(readme.starts_with("File Kept Co"), "{readme}"); + assert!(readme.starts_with("Journal Kept Co"), "{readme}"); } /// The worksheet says when a reportable payee's TIN isn't in it (the diff --git a/crates/ledger-gui/tests/format_upgrade.rs b/crates/ledger-gui/tests/format_upgrade.rs index 34e2608..ce0eb14 100644 --- a/crates/ledger-gui/tests/format_upgrade.rs +++ b/crates/ledger-gui/tests/format_upgrade.rs @@ -3,8 +3,8 @@ //! and the go-ahead is asked as the viewer opens them, with the owner //! passphrase, handed to the owner the program gives the viewer (the //! server checks it). "Not now" changes nothing; a refusal is said in the -//! dialog, which stays; books this version made, and older books dirs, -//! are never asked; the viewer writes nothing of the books. +//! dialog, which stays; books this version made, and books outside a +//! data root (the demo's), are never asked; the viewer writes nothing of the books. use chrono::NaiveDate; use egui_kittest::Harness; @@ -95,7 +95,7 @@ fn asked(f: &Fake) -> Vec { f.asked.lock().unwrap().clone() } -/// Books `acme`: in a data root (`root`) or an older books dir, in journal +/// Books `acme`: in a data root (`root`) or outside one, in journal /// format 1 (as an older version wrote them) or this version's (`old`). fn books(root: bool, old: bool) -> (tempfile::TempDir, PathBuf, Books) { let dir = tempfile::Builder::new() @@ -320,11 +320,11 @@ fn an_upgrade_that_ends_without_an_answer_never_holds_the_window() { assert!(h.query_by_label(ASKS).is_none(), "and closes"); } -/// Books this version made wait for nothing, and older books dirs (no -/// owner's server for them) are never asked: no notice, no dialog, no +/// Books this version made wait for nothing, and books outside a data +/// root (no owner's server for them) are never asked: no notice, no dialog, no /// menu item. #[test] -fn books_in_this_versions_format_and_older_books_dirs_are_never_asked() { +fn books_in_this_versions_format_and_books_outside_a_root_are_never_asked() { let _alone = alone(); let _ = fake(); for (root, old) in [(true, false), (false, true), (false, false)] { diff --git a/crates/ledger-gui/tests/live.rs b/crates/ledger-gui/tests/live.rs index fab2369..dd61eab 100644 --- a/crates/ledger-gui/tests/live.rs +++ b/crates/ledger-gui/tests/live.rs @@ -1,4 +1,4 @@ -//! Live updates: a change written to the books (by the MCP server, in +//! Live updates: a change written to the books (by the books server, in //! practice) reaches the GUI's view model on its own, without a restart, //! and the changed entry is marked fresh; a show request reaches it, and //! while it runs it holds the books' viewer presence. @@ -121,16 +121,21 @@ fn a_change_in_another_process_shows_up_live() { ); } -/// An older books dir that keeps no settings in its journal and has no -/// settings file: one written while the viewer runs is followed, as its -/// server follows it. +/// A setting the server journals while the viewer runs is followed (the +/// books' own settings, the only ones the viewer reads: D-44); a settings +/// file beside the books is never read. #[test] -fn a_settings_file_made_later_is_followed() { - let dir = std::env::temp_dir().join(format!("ledger-gui-live-toml-{}", std::process::id())); +fn a_setting_changed_while_it_runs_is_followed() { + let dir = std::env::temp_dir().join(format!("ledger-gui-live-set-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let _cleanup = TestDir(dir.clone()); let data = dir.join("books-data"); drop(Store::create(&data, Currency::USD).unwrap()); + std::fs::write( + dir.join("wanderbooks.toml"), + "[company]\nname = \"Beside Co\"\n", + ) + .unwrap(); let loader = Loader::spawn( Source { data_dir: data.clone(), @@ -139,23 +144,31 @@ fn a_settings_file_made_later_is_followed() { egui::Context::default(), ); match loader.wait(Duration::from_secs(10)) { - Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Made Later Co"), + Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Beside Co"), _ => panic!("no initial load"), } - std::fs::write( - dir.join("wanderbooks.toml"), - "[company]\nname = \"Made Later Co\"\n", - ) - .unwrap(); + let mut store = Store::open(&data).unwrap(); + store + .change_settings( + &[ledger_core::settings::Change { + key: "company.name".into(), + value: Some(serde_json::json!("Made Later Co")), + }], + &ledger_core::settings::Source { + decided_by: "Tobin Harlow".into(), + decided_on: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(), + citation: None, + note: None, + }, + ) + .unwrap(); + drop(store); let deadline = Instant::now() + Duration::from_secs(10); loop { - assert!( - Instant::now() < deadline, - "the new settings file was never read" - ); + assert!(Instant::now() < deadline, "the new setting was never read"); match loader.wait(Duration::from_millis(200)) { Some(Update::Books(b)) if b.settings.company.name == "Made Later Co" => break, - Some(Update::Error(e)) => panic!("the settings file didn't load: {e}"), + Some(Update::Error(e)) => panic!("the books didn't load: {e}"), _ => {} } } diff --git a/crates/ledger-gui/tests/old_books.rs b/crates/ledger-gui/tests/old_books.rs index 122a5ce..9569e8d 100644 --- a/crates/ledger-gui/tests/old_books.rs +++ b/crates/ledger-gui/tests/old_books.rs @@ -480,62 +480,32 @@ fn books_that_keep_their_settings_show_them() { } } -/// Books that keep no settings in their journal are read as their server -/// reads them (plan 3.6): an older books dir from its settings file; a -/// book of the data root never from a file, so with the defaults. +/// Books that keep no settings in their journal show the defaults, as +/// their server reads them (plan 3.6, D-44): no settings file or +/// `config.json` beside them is read, in a data root or not. #[test] -fn books_that_keep_no_settings_read_their_file_only_outside_a_root() { +fn books_that_keep_no_settings_read_no_file_beside_them() { let from = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../ledger-store/tests/fixtures/old-books") .join("shares_big"); - // An older books dir: its server reads wanderbooks.toml, so the viewer - // does too. - let t = temp("said-file"); - copy_books(&from, &t.0.join("books-data")); - std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap(); - let b = load(&t); - assert_eq!(b.settings.company.name, "Old Books Co"); - assert!(!b.settings.company.members.is_empty()); - // The same books in a data root: no file is read, whatever lies - // beside them; they show the defaults until their settings are in - // their journal. - let t = temp("said-defaults"); - std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap(); - copy_books(&from, &t.0.join("books-data")); - std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap(); - let b = load(&t); - assert_eq!( - b.settings, - ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD") - ); -} - -/// An older books dir with no settings file but a `config.json`: its -/// receipt rule is the server's, from that file (the regression round 2 -/// of slice 6's review found). -#[test] -fn an_older_books_dirs_config_json_sets_its_receipt_rule() { - let from = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../ledger-store/tests/fixtures/old-books") - .join("shares_big"); - let t = temp("said-config"); - copy_books(&from, &t.0.join("books-data")); - std::fs::write( - t.0.join("books-data/config.json"), - r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, - ) - .unwrap(); - assert_eq!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO); - // In a data root, no file: the books' own rule (the default). - let t = temp("said-config-root"); - std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap(); - copy_books(&from, &t.0.join("books-data")); - std::fs::write( - t.0.join("books-data/config.json"), - r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, - ) - .unwrap(); - assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO); + let defaults = + ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD"); + for (tag, in_root) in [("said-file", false), ("said-root", true)] { + let t = temp(tag); + if in_root { + std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap(); + } + copy_books(&from, &t.0.join("books-data")); + std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap(); + std::fs::write( + t.0.join("books-data/config.json"), + r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, + ) + .unwrap(); + let b = load(&t); + assert_eq!(b.settings, defaults, "{tag}"); + assert_ne!(b.receipt_threshold, rust_decimal::Decimal::ZERO, "{tag}"); + } } /// rules.md B-37: the classes an earlier version wrote read as projects diff --git a/crates/ledger-gui/tests/owner_menu.rs b/crates/ledger-gui/tests/owner_menu.rs index 48d0ca3..4e2bda0 100644 --- a/crates/ledger-gui/tests/owner_menu.rs +++ b/crates/ledger-gui/tests/owner_menu.rs @@ -1,8 +1,8 @@ //! The viewer's Books menu (owner.rs, plan P2 "Viewer menu", D-32): each //! item hands its work to the owner the program gives the viewer, off the //! UI thread, and says what came of it; a restore is previewed first; the -//! viewer itself writes nothing of the books; without an owner, or for an -//! older books dir, the menu isn't there. +//! viewer itself writes nothing of the books; without an owner, or for +//! books outside a data root (the demo's), the menu isn't there. use chrono::NaiveDate; use egui_kittest::Harness; @@ -102,7 +102,7 @@ fn today() -> NaiveDate { } /// Books in a data root (`root`: a folder holding the root's marker), or -/// an older books dir's. +/// outside one (as the demo's are). fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) { let dir = tempfile::Builder::new() .prefix("wb-gui-owner-") diff --git a/crates/ledger-gui/tests/p3_seams.rs b/crates/ledger-gui/tests/p3_seams.rs index ba99f63..c19c2f9 100644 --- a/crates/ledger-gui/tests/p3_seams.rs +++ b/crates/ledger-gui/tests/p3_seams.rs @@ -1,7 +1,8 @@ //! P3's seams in the viewer: on the seam review's books (a year of a //! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), the //! viewer's figures, read through its own [`Books`] from the books' folder -//! and settings file as it opens them, are the engine's (the server's +//! as it opens them (their settings journaled, as the books keep them: +//! D-44), are the engine's (the server's //! tools make theirs with the same calls and settings): each member's //! capital statement, party money, the cash flow statement, the fixed //! asset register and the 1099 worksheet, on several dates. @@ -9,7 +10,7 @@ #[path = "../../ledger-package/tests/seams/books.rs"] mod books; -use books::{books, d}; +use books::{SETTINGS_TOML, books, d}; use chrono::NaiveDate; use ledger_gui::books::Books as Viewer; use ledger_gui::{Source, data}; @@ -27,7 +28,23 @@ fn viewer(dir: &std::path::Path, today: NaiveDate) -> Viewer { #[test] fn the_viewers_figures_are_the_engines_on_every_date() { let dir = tempfile::tempdir().unwrap(); - let b = books(dir.path()); + let mut b = books(dir.path()); + // The settings in the books, where the viewer reads them (it reads no + // file beside them). + let import = + ledger_config::codec::import(SETTINGS_TOML, None, b.store.ledger().settings(), "USD") + .unwrap(); + b.store + .change_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: d(START), + citation: None, + note: None, + }, + ) + .unwrap(); let l = b.store.ledger(); let cash: Vec<_> = b .cash diff --git a/crates/ledger-gui/tests/tin_copy.rs b/crates/ledger-gui/tests/tin_copy.rs index 85a6c5e..cab0945 100644 --- a/crates/ledger-gui/tests/tin_copy.rs +++ b/crates/ledger-gui/tests/tin_copy.rs @@ -21,7 +21,7 @@ use ledger_gui::nav::Route; use ledger_gui::{App, Source, data}; use ledger_store::Store; use ledger_vault::{Tin, Vault}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; const SSN: &str = "123-45-6789"; @@ -38,17 +38,19 @@ impl Drop for Fixture { } } -/// Books with one member whose SSN is in the vault; the key lives outside -/// the books dir, as the server requires. +/// Books with one member whose SSN is in the vault, the vault and its key +/// where a book of the data root keeps them (its own `vault/` and +/// `keys/`). fn fixture(tag: &str) -> Fixture { let root = std::env::temp_dir().join(format!("ledger-gui-tin-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let books = root.join("books"); - let key = root.join("keys/vault.key"); - std::fs::create_dir_all(&books).unwrap(); - let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap(); + let key = books + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE); + let mut s = Store::create(&books, ledger_core::Currency::USD).unwrap(); let party = s.add_party("Pat Lee", &[Role::Member]).unwrap(); - let v = Vault::open(&books.join("vault"), &key, true).unwrap(); + let v = Vault::open(&books.join(ledger_store::root::VAULT_DIR), &key, true).unwrap(); let hash = v .put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap()) .unwrap(); @@ -70,14 +72,12 @@ fn fixture(tag: &str) -> Fixture { } } -fn load(f: &Fixture, key: Option<&Path>) -> Books { - let mut b = data::load_once(&Source { - data_dir: f.books.join("books-data"), +fn load(f: &Fixture) -> Books { + data::load_once(&Source { + data_dir: f.books.clone(), today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()), }) - .unwrap(); - b.settings.vault_key_file = key.map(Path::to_path_buf); - b + .unwrap() } fn copied(h: &Harness<'_, App>) -> Vec { @@ -102,7 +102,7 @@ fn no_digits(s: &str) { #[test] fn reads_the_vault_with_the_key_in_force() { let f = fixture("read"); - let b = load(&f, Some(&f.key)); + let b = load(&f); assert_eq!(b.tin_from_vault(f.party).unwrap(), SSN); let e = b.tin_from_vault(f.party + 1).unwrap_err(); assert!(e.contains("Not in the vault"), "{e}"); @@ -112,22 +112,12 @@ fn reads_the_vault_with_the_key_in_force() { #[test] fn a_missing_key_is_locked_and_never_created() { let f = fixture("locked"); - let gone = f.root.join("elsewhere/vault.key"); - let b = load(&f, Some(&gone)); + std::fs::remove_file(&f.key).unwrap(); + let b = load(&f); let e = b.tin_from_vault(f.party).unwrap_err(); assert!(e.contains("locked"), "{e}"); no_digits(&e); - assert!(!gone.exists(), "reading must never create a key"); -} - -/// INVARIANT 3: a key file inside the books dir is refused. -#[test] -fn a_key_inside_the_books_is_refused() { - let f = fixture("inside"); - let inside = f.books.join("vault.key"); - std::fs::copy(&f.key, &inside).unwrap(); - let e = load(&f, Some(&inside)).tin_from_vault(f.party).unwrap_err(); - assert!(e.contains("inside the books folder"), "{e}"); + assert!(!f.key.exists(), "reading must never create a key"); } /// INVARIANT 4: a tampered record is unreadable, and says nothing of it. @@ -139,7 +129,7 @@ fn a_bad_record_is_an_error_without_digits() { let last = raw.len() - 1; raw[last] ^= 1; std::fs::write(&rec, raw).unwrap(); - let e = load(&f, Some(&f.key)).tin_from_vault(f.party).unwrap_err(); + let e = load(&f).tin_from_vault(f.party).unwrap_err(); no_digits(&e); } @@ -156,7 +146,7 @@ fn party_page(b: Books, party: PartyId) -> Harness<'static, App> { fn clicking_the_masked_tin_copies_the_full_number() { let f = fixture("click"); let shown = "TIN on file ••••6789 (SSN)"; - let mut h = party_page(load(&f, Some(&f.key)), f.party); + let mut h = party_page(load(&f), f.party); assert!( h.query_by_label_contains("12345").is_none(), "the full number is never drawn" @@ -168,8 +158,8 @@ fn clicking_the_masked_tin_copies_the_full_number() { assert!(h.query_by_label_contains("12345").is_none()); // Locked: nothing copied; the reason shows beside it. - let gone = f.root.join("elsewhere/vault.key"); - let mut h = party_page(load(&f, Some(&gone)), f.party); + std::fs::remove_file(&f.key).unwrap(); + let mut h = party_page(load(&f), f.party); h.get_by_label(shown).click(); h.step(); assert!(copied(&h).is_empty()); diff --git a/crates/ledger-gui/tests/vault_key_flag.rs b/crates/ledger-gui/tests/vault_key_flag.rs deleted file mode 100644 index 4d4a751..0000000 --- a/crates/ledger-gui/tests/vault_key_flag.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! The viewer's command line: `--vault-key-file` names the vault key and -//! wins over a settings file's `[vault] key_file`, as it does for the -//! server; an unknown argument stops the viewer with status 2. (Its own -//! test binary: the key file is named once per process.) - -use ledger_core::{Role, TinFacts, TinType}; -use ledger_gui::{Source, data}; -use ledger_store::Store; -use ledger_vault::{Tin, Vault}; - -const SSN: &str = "123-45-6789"; - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -#[test] -fn the_key_named_on_the_command_line_wins() { - let root = std::env::temp_dir().join(format!("ledger-gui-key-flag-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - let _cleanup = TestDir(root.clone()); - let books = root.join("books"); - let key = root.join("keys/vault.key"); - std::fs::create_dir_all(&books).unwrap(); - let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap(); - let party = s.add_party("Pat Lee", &[Role::Member]).unwrap(); - let v = Vault::open(&books.join("vault"), &key, true).unwrap(); - let hash = v - .put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap()) - .unwrap(); - s.set_party_tin( - party, - Some(TinFacts { - tin_type: TinType::Ssn, - last4: "6789".into(), - vault_record: Some(hash), - purged: None, - }), - ) - .unwrap(); - drop(s); - - // Named first, so the default key location is never consulted. - assert_eq!(ledger_gui::vault_key_file(), None); - ledger_gui::set_vault_key_file(key.clone()); - assert_eq!(ledger_gui::vault_key_file(), Some(key.clone())); - // Named once: a second naming changes nothing. - ledger_gui::set_vault_key_file(root.join("elsewhere.key")); - assert_eq!(ledger_gui::vault_key_file(), Some(key.clone())); - - let mut b = data::load_once(&Source { - data_dir: books.join("books-data"), - today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()), - }) - .unwrap(); - // The settings name a key that isn't there; the command line wins. - b.settings.vault_key_file = Some(root.join("missing/vault.key")); - assert_eq!(b.tin_from_vault(party).unwrap(), SSN); -} - -#[test] -fn an_unknown_argument_stops_the_viewer() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui")) - .arg("--no-such-flag") - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(2)); - assert!( - String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"), - "{}", - String::from_utf8_lossy(&out.stderr) - ); - let help = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui")) - .arg("--help") - .output() - .unwrap(); - assert!(help.status.success()); - assert!(String::from_utf8_lossy(&help.stdout).contains("--vault-key-file PATH")); -} diff --git a/crates/ledger-host/src/bridge.rs b/crates/ledger-host/src/bridge.rs index a6220e5..a818b42 100644 --- a/crates/ledger-host/src/bridge.rs +++ b/crates/ledger-host/src/bridge.rs @@ -9,7 +9,10 @@ //! no data-dir flag); the plan's tests bridge_opens_closed_books, //! bridge_survives_server_restart, bridge_initializes_during_cold_start, //! no_local_agent_tcp, close_now_ends_every_lease (in -//! ledger-cli's open_while_held.rs, under names of their own). +//! ledger-cli's open_while_held.rs, under names of their own); +//! docs/spec/sources/jsonrpc-2.0.md (the agent's lines: a parse +//! error is -32700, an invalid request -32600, with its id when +//! it has one, null when not: INVARIANT 7). //! INVARIANTS: //! 1. It holds a lease for the whole session, and talks only to the //! root's lease and MCP sockets: never TCP, never the books' files. diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 36c2413..4f4495f 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -4,15 +4,11 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store." +description = "The books server's MCP tools for wanderbooks: the AI-first bookkeeping interface over ledger-store." [lib] path = "src/lib.rs" -[[bin]] -name = "ledger-mcp" -path = "src/main.rs" - [dependencies] ledger-core = { path = "../ledger-core" } ledger-store = { path = "../ledger-store" } @@ -25,9 +21,8 @@ ledger-package = { path = "../ledger-package" } base64 = "0.22" # A download's Repr-Digest (local.rs). sha2 = "0.10" -mime_guess = "2" rust_decimal = "1" -rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] } +rmcp = { version = "3", features = ["server", "schemars", "transport-streamable-http-server"] } axum = "0.8" http-body-util = "0.1" tower = "0.5" @@ -36,7 +31,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = "0.4" thiserror = "2" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync", "net", "signal", "time"] } tokio-util = { version = "0.7", features = ["rt", "io", "io-util"] } # A call that panics is recorded before the panic goes on (access.rs). futures-util = "0.3" @@ -54,12 +49,8 @@ libc = "0.2" workspace = true [dev-dependencies] -# The tests open the CPA package. -zip = "9.0.1" +# The unit tests drive the tools as a client would (the integration tests, +# through a real books server, are ledger-cli's: plan D-44). tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] } rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] } -jsonschema = { version = "0.58", default-features = false } -proptest = "1" tempfile = "3" -# read_only_tools_change_nothing compares the database by its rows (spec_mcp.rs). -rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/ledger-mcp/handbook/contract.md b/crates/ledger-mcp/handbook/contract.md index b10ee8b..374c9ff 100644 --- a/crates/ledger-mcp/handbook/contract.md +++ b/crates/ledger-mcp/handbook/contract.md @@ -4,7 +4,7 @@ You are the bookkeeping agent for these books. The engine enforces the accountin - These tools are the only way to the books. Never look for the books (their journal, receipts or vault) in local files. - Start every session with books.start: the rules in force, this company's conventions, what is open, and next steps. Follow the company's conventions (books.conventions). -- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). One exception, for books that still read a settings file (books.settings says source "file"): that file may be edited until books.import_settings moves it in. No other local file. +- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). No local file is yours to edit. - Money is exact decimal. Always pass amounts as decimal strings ("123.45"), never floats. Amounts in every response are decimal strings: treat them as exact, and echo them to the human as written. - Never post directly: draft, add lines, preview, show the human the preview, then post (the entries section). - Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else. diff --git a/crates/ledger-mcp/handbook/cpa-handoff.md b/crates/ledger-mcp/handbook/cpa-handoff.md index 3d701fe..2732580 100644 --- a/crates/ledger-mcp/handbook/cpa-handoff.md +++ b/crates/ledger-mcp/handbook/cpa-handoff.md @@ -1,6 +1,6 @@ # CPA handoff -- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package. +- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in, then tell them where it is. The viewer's "Export for CPA" makes the same package. - Never file the package, or a file from it, into the books. - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. - Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, a project's name as its QuickBooks class, the memos grouped); tell the human. Each line's project is its CLASS, the projects declared first as QuickBooks classes. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one. diff --git a/crates/ledger-mcp/handbook/files-in-out.md b/crates/ledger-mcp/handbook/files-in-out.md index c54308b..939fa93 100644 --- a/crates/ledger-mcp/handbook/files-in-out.md +++ b/crates/ledger-mcp/handbook/files-in-out.md @@ -1,8 +1,8 @@ # Files in and out - A file comes in one of two ways, named in the tool that files it (books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv, books.import_chart_csv): - - an upload, for any file up to 25 MiB (books served by `wanderbooks mcp`; on another connection only inline): books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it. + - an upload, for any file up to 25 MiB: books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it. - inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught. - If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy. -- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder (books served by `wanderbooks mcp`; elsewhere it comes back inline). They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself. -- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (only for books served by `wanderbooks mcp`). +- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself. +- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name. diff --git a/crates/ledger-mcp/handbook/settings-and-conventions.md b/crates/ledger-mcp/handbook/settings-and-conventions.md index 6b30d49..e30018f 100644 --- a/crates/ledger-mcp/handbook/settings-and-conventions.md +++ b/crates/ledger-mcp/handbook/settings-and-conventions.md @@ -11,17 +11,6 @@ The books keep their own record of how they are kept. Every agent, anywhere, rea - policy.require_tax_category is warn (unset: a revenue or expense account without a tax category is created, and the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; the accounts invoices, bills and payroll create are never refused, and their results name them). Before setting refuse, categorize with the human every account books.digest lists in uncategorized_accounts. - books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing. -## Books that still read a settings file - -If books.settings says source "file", these books predate settings in the books and still read the settings file books.settings names. Move it in once, with the human: - -1. Read that file's text, whole and unedited, and pass it to books.import_settings as toml_text (and, if books.settings shows config_json_present, the receipt policy as config_json_text: {"receipt_policy": {"require_receipts_above", "receipt_required_accounts", "receipt_exempt_accounts"}} from books.settings). The import is refused unless the text is the file the server reads. -2. Show the human the preview: every change, the notes (what isn't a books setting and where it goes), anything unknown, and the file's comments, which are not imported: record what matters in them (where a value came from, a discrepancy, something pending) as a decision, an open item or a convention. -3. Confirm with confirmed: true, decided_by the human. The import is refused if any setting would read differently afterwards. -4. If the file named the vault's key file ([vault] key_file), the server must already be started naming the same file, and the viewer too, or the vault won't open: tell the human before the import. - -From then on no file is read; books.set_setting changes settings. Until then, that file is the one local file you may edit, and books.set_setting is refused (it would drop the rest of the file). - ## Conventions How this company keeps its books, as the owners decided, is recorded as conventions (books.set_convention, read with books.conventions; books.start lists them): diff --git a/crates/ledger-mcp/src/access.rs b/crates/ledger-mcp/src/access.rs index f49eec7..fe0ded5 100644 --- a/crates/ledger-mcp/src/access.rs +++ b/crates/ledger-mcp/src/access.rs @@ -1,5 +1,5 @@ //! Who is calling, what their token allows, and the record of each call -//! (plan 3.3, I-9), for books served from the data root. +//! (plan 3.3, I-9), for every book the books server serves. //! //! ```text //! GOAL: a call reaches a book only with a token for that book and for @@ -9,8 +9,7 @@ //! logged, and logging fails closed"), section 7's record. //! INVARIANTS: //! 1. The HTTP door (local.rs) puts a Caller on every request it lets -//! through; a call that reaches a book with an access log and has no -//! Caller is refused. +//! through; a call that reaches a book with no Caller is refused. //! 2. A call runs only if its Caller's token has the scope it needs //! (scopes.rs); otherwise it is refused, unrun. //! 3. Every call, run or refused, gets one record, written before its diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index 190eb46..6f72777 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -19,11 +19,9 @@ //! ReceiptPolicy::rule), never a fixed one; the connect text states //! the parties rule in force too. //! 3. No connect text or handbook section names a filesystem path, a -//! helper command, a pull request, or where the books are; none -//! names the settings file, which is the one books.settings names. -//! Books that keep their own settings are told no local file is -//! theirs to edit; books that still read a settings file, that it is -//! the one they may edit, until it is imported. +//! helper command, a pull request, or where the books are; the books +//! keep their own settings, and the agent is told no local file is +//! its to edit. //! 4. The connect text's first paragraph stands alone: the entry //! point, the local-files rule, decimal strings, and the entry flow. //! 5. The company's name is shown as one line of plain text (no @@ -246,23 +244,9 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String { // (with the hint back), then the hint and the account codes the rule // lists (counted, with books.settings listing them), and last the // words that only help. - // Where settings live: in the books (changed through the tools), or, - // for books that still read a settings file, that one file. - let settings_rule = |why: bool| { - if settings.journaled { - " Settings and conventions live in the books: change them only with these tools." - .to_string() - } else { - format!( - " The only local file you may edit is the settings file books.settings names{}.", - if why { - " (until books.import_settings)" - } else { - "" - } - ) - } - }; + // Where settings live: in the books, changed through the tools. + let settings_rule = + " Settings and conventions live in the books: change them only with these tools."; let rest = |why: bool, codes: usize, trace: bool, terse: bool| { let rule = settings.policy.rule_listing(currency, codes); let because = |s: &'static str| if why { s } else { "" }; @@ -287,7 +271,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String { } else { "" }, - settings_rule(why), + settings_rule, ) }; let mut text = String::new(); @@ -358,13 +342,7 @@ mod tests { longest_rule(&long, PartyPolicy::Suggest), longest_rule(&long, PartyPolicy::Off), ]; - // Each as books still reading a settings file, and as books that - // keep their own settings. - let journaled = every.clone().map(|mut s| { - s.journaled = true; - s - }); - for s in every.into_iter().chain(journaled) { + for s in every { let text = connect_text(&s, "USD"); let chars = text.chars().count(); assert!(chars <= CONNECT_MAX, "{chars} chars: {text}"); @@ -391,8 +369,8 @@ mod tests { .any(|&n| text.contains(&s.policy.rule_listing("USD", n))), "{text}" ); - // What every entry must be, and where settings live: the one - // file the agent may edit, or none. + // What every entry must be, and where settings live: in the + // books. for must in [ "balances exactly", "real account from books.chart", @@ -401,14 +379,8 @@ mod tests { ] { assert!(text.contains(must), "lacks {must:?}: {text}"); } - assert_eq!( - text.contains("the settings file books.settings names"), - !s.journaled, - "{text}" - ); - assert_eq!( + assert!( text.contains("Settings and conventions live in the books"), - s.journaled, "{text}" ); // The parties rule in force (INVARIANT 2), and only it. @@ -611,22 +583,10 @@ mod tests { for why in [ "(their schedules are the evidence)", "(derived from the books)", - "(until books.import_settings)", - "(the parties setting requires it)", - ] { - assert!(text.contains(why), "{} chars: {text}", text.chars().count()); - } - // The same company once its settings are in the books. - s.journaled = true; - let text = connect_text(&s, "USD"); - assert!(text.chars().count() <= CONNECT_MAX); - for why in [ - "(their schedules are the evidence)", "(the parties setting requires it)", "Settings and conventions live in the books", ] { assert!(text.contains(why), "{} chars: {text}", text.chars().count()); } - assert!(!text.contains("local file you may edit"), "{text}"); } } diff --git a/crates/ledger-mcp/src/lib.rs b/crates/ledger-mcp/src/lib.rs index 642813e..49736a3 100644 --- a/crates/ledger-mcp/src/lib.rs +++ b/crates/ledger-mcp/src/lib.rs @@ -1,19 +1,9 @@ -//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio. -//! -//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks -//! dev-stdio` (one executable, plan D-22) both call it. -//! -//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]` -//! -//! The data dir holds `journal.jsonl` (the source of truth), -//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each -//! schema: ledger_store::db_file_for), `blobs/` (content-addressed receipts), -//! and optionally `config.json`. If no store exists, one is created with the -//! configured base currency. Company settings (name, fiscal year start, -//! receipt threshold) come from the books dir's `wanderbooks.toml`, by -//! default the one next to the data dir, and reload whenever it changes -//! (settings.rs). Files come in only as tool input (inline, at most 256 -//! KiB): uploads need the books server's socket (`wanderbooks mcp`). +//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way +//! (plan D-44): by the books server, `wanderbooks server` ([`local`]), +//! every book of the data root on the root's local sockets, which agents +//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as +//! tool input (inline, at most 256 KiB) or as uploads, and the files the +//! books make go out as downloads. pub mod access; pub mod admin; @@ -22,59 +12,20 @@ mod handbook; #[cfg(unix)] pub mod local; pub mod opening; -mod paths; mod policy; pub mod scopes; mod server; mod settings; mod tin_files; mod types; -mod wire; -use ledger_core::Currency; -use ledger_store::Store; -use policy::ReceiptPolicy; -use rmcp::ServiceExt; -use serde::Deserialize; -use server::BooksServer; pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE}; -use std::path::PathBuf; -/// Server configuration, JSON. All fields optional. -#[derive(Debug, Default, Deserialize)] -struct Config { - /// ISO 4217 base currency for a new store. Default "USD". - #[serde(default)] - base_currency: Option, - // `receipt_policy` is read by ledger_config::effective_receipt_policy. -} - -fn default_data_dir() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - PathBuf::from(home).join(".local/share/wanderbooks") -} - -fn print_usage(prog: &str) { - eprintln!( - "usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR]" - ); - eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)"); - eprintln!(" --config JSON config file (default /config.json, optional)"); - eprintln!( - " --books-toml company settings, for books that don't keep their own yet (default /../wanderbooks.toml, optional)" - ); - eprintln!( - " --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)" - ); - eprintln!( - " --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio" - ); -} - -/// How [`run`] stops when it can't go on: the reason is already printed -/// on stderr, and the number is the process's exit code (2: a bad argument; 1: books from a newer wanderbooks). A -/// library never ends the process itself, so the books it opened are -/// closed properly on the way out; the binaries turn this into the exit. +/// How a mode of the one executable stops when it can't go on: the +/// reason is already printed on stderr, and the number is the process's +/// exit code (2: a bad argument). A library never ends the process +/// itself, so the books it opened are closed properly on the way out; +/// the executable turns this into the exit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Exit(pub i32); @@ -86,211 +37,6 @@ impl std::fmt::Display for Exit { impl std::error::Error for Exit {} -/// Run the books server with `args` (the command line after the program -/// name). `prog` names the program in the usage text: `ledger-mcp`, or -/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad -/// argument or books from a newer wanderbooks return [`Exit`] after a -/// plain message. -pub fn run( - prog: &str, - args: impl IntoIterator, -) -> Result<(), Box> { - let mut data_dir = default_data_dir(); - let mut config_path: Option = None; - let mut books_toml: Option = None; - let mut vault_key_file: Option = None; - let mut http_addr: Option = None; - let mut args = args.into_iter(); - while let Some(arg) = args.next() { - match arg.as_str() { - "--data-dir" => { - data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?); - } - "--config" => { - config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?)); - } - "--books-toml" => { - books_toml = Some(PathBuf::from( - args.next().ok_or("--books-toml needs a path")?, - )); - } - "--vault-key-file" => { - vault_key_file = Some(PathBuf::from( - args.next().ok_or("--vault-key-file needs a path")?, - )); - } - "--http" => { - http_addr = Some(args.next().ok_or("--http needs an address")?); - } - "--help" | "-h" => { - print_usage(prog); - return Ok(()); - } - other => { - eprintln!("unknown argument: {other}"); - print_usage(prog); - return Err(Box::new(Exit(2))); - } - } - } - // Books in the data root are the books server's alone (plan 3.1): - // a second writer beside it is refused before anything is opened. - if ledger_store::root::inside_a_root(&data_dir) { - eprintln!( - "ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book `" - ); - return Err(Box::new(Exit(2))); - } - let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json")); - // config.json's base currency matters only to books being created: it - // is read then, and never otherwise (books that keep their own - // settings read no file). - let read_config = || -> Result> { - if config_path.exists() { - let raw = std::fs::read_to_string(&config_path)?; - Ok(serde_json::from_str(&raw) - .map_err(|e| format!("bad config {}: {e}", config_path.display()))?) - } else { - Ok(Config::default()) - } - }; - // Logs go to stderr: stdout is the MCP channel. - eprintln!("ledger-mcp: data dir {}", data_dir.display()); - // An explicit --books-toml must exist; the default (beside the data - // dir) is optional. Either is reloaded whenever it changes. - let source = settings::Source { - required: books_toml.is_some(), - books_toml: books_toml.unwrap_or_else(|| { - data_dir - .parent() - .map(|p| p.join("wanderbooks.toml")) - .unwrap_or_else(|| PathBuf::from("wanderbooks.toml")) - }), - config_json: config_path.clone(), - }; - - let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() { - match open_waiting_for_lock(&data_dir) { - Ok(store) => store, - // Books from a newer wanderbooks are fine; this server is old. - // Say so in plain words (the error's text), and leave them be. - Err(e @ ledger_store::StoreError::NewerJournal { .. }) => { - eprintln!("ledger-mcp: {e}"); - return Err(Box::new(Exit(1))); - } - Err(e) => return Err(e.into()), - } - } else { - let config = read_config()?; - let code = config.base_currency.as_deref().unwrap_or("USD"); - let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?; - eprintln!("ledger-mcp: creating new store (base currency {code})"); - Store::create(&data_dir, currency)? - }; - // The journal is the source of truth: verify the chain, the replay, and - // the whole projection before serving a single tool call, rebuilding - // the projection if it drifted. - let report = store - .verify_and_heal() - .map_err(|e| format!("store verify failed: {e}"))?; - if let Some(why) = &report.healed { - eprintln!("ledger-mcp: rebuilt the database from the journal ({why})"); - } - // The viewer's side channel, made as the books open (ledger_store::ops - // INVARIANT 2), so a viewer already open shows its presence before - // the first books.show. Not the books: a failure only says so. - if let Err(e) = ledger_store::ops::prepare(&data_dir) { - eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})"); - } - // Books that keep their own settings never read a settings file, not - // even to start (settings INVARIANT 1); others read it as before. - let mut live = if store.ledger().settings().is_journaled() { - settings::Live::journaled(source) - } else { - settings::Live::watching(source)? - } - .with_vault_key_file(vault_key_file); - live.sync(store.ledger().settings(), store.base_currency().code()); - eprintln!( - "ledger-mcp: company {:?} (settings from {})", - live.current().company.name, - if live.is_journaled() { - "the books" - } else { - "the settings file" - } - ); - let live = std::sync::Arc::new(std::sync::Mutex::new(live)); - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - if let Some(addr) = http_addr { - eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}"); - rt.block_on(serve_http(addr, store, live))?; - return Ok(()); - } - - eprintln!("ledger-mcp: store verified, serving over stdio"); - let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live); - rt.block_on(async { - // The wire answers what JSON-RPC requires that rmcp drops (wire.rs). - let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout()); - let running = server.serve((input, output)).await?; - let _ = running.waiting().await; - let _ = writer.await; - Ok::<(), Box>(()) - }) -} - -/// Serve MCP over Streamable HTTP (for network clients). -async fn serve_http( - addr: String, - store: Store, - live: std::sync::Arc>, -) -> Result<(), Box> { - use rmcp::transport::streamable_http_server::{ - StreamableHttpService, session::local::LocalSessionManager, - }; - use std::sync::Arc; - - // One store for every connection: the async mutex keeps it - // single-writer, and tool bodies run on blocking threads (server.rs). - let store = server::SharedStore::new(store); - let service = StreamableHttpService::new( - move || { - let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) - .with_live_settings(live.clone()); - Ok(server) - }, - Arc::new(LocalSessionManager::default()), - Default::default(), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind(&addr).await?; - eprintln!("ledger-mcp: listening on http://{addr}/mcp"); - axum::serve(listener, router).await?; - Ok(()) -} - -/// Open the books, waiting up to ten seconds for another writer to let go. -/// A restarted server (a client reconnecting) often starts while the old -/// process is still exiting with the lock; waiting turns that race into a -/// short delay. A writer that keeps the books longer is a real conflict, -/// and the refusal says so. -fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - loop { - match Store::open(data_dir) { - Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => { - std::thread::sleep(std::time::Duration::from_millis(100)); - } - other => return other, - } - } -} - #[cfg(test)] mod exit_tests { use super::Exit; diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index f3eedde..abfaa12 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -60,9 +60,7 @@ //! ``` use crate::admin::{self, Request, Response}; -use crate::policy::ReceiptPolicy; -use crate::server::{BooksServer, Layout, SharedStore}; -use crate::settings::Live; +use crate::server::{BooksServer, Served, SharedStore}; use ledger_store::Store; use ledger_store::access::{Access, AccessLog, Outcome, Principal}; use ledger_store::auth::{Principals, Scope, Token}; @@ -294,61 +292,30 @@ impl Books { self.hide(why.clone()) ); } - let mut live = Live::own(); - live.sync(store.ledger().settings(), store.base_currency().code()); let waiting = Arc::new(Mutex::new(store.journal_format().upgrade())); let database = (store.database_file(), store.other_databases()); - let layout = Layout::Root(self.root.path().to_path_buf()); - let access = Arc::new( - AccessLog::open(store.data_dir()) - .map_err(|e| format!("its access log can't be used: {e}"))?, - ); - let uploads = - ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp()) - .map_err(|e| self.hide(e.to_string()))?; - let server_uploads = uploads.clone(); - let exports = - ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp()) - .map_err(|e| self.hide(e.to_string()))?; - let server_exports = exports.clone(); - // The viewer's side channel, made by the server (ops.rs - // INVARIANT 2): a viewer can show its presence from the start. - ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?; - let live = Arc::new(Mutex::new(live)); - let book_dir = store.data_dir().to_path_buf(); - let book_id = book_dir - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let store = SharedStore::new(store); - let backups = Arc::new( - crate::backup::Backups::open(&book_id, &book_dir, store.clone()) - .map_err(|e| self.hide(e))?, - ); - let server_backups = backups.clone(); - let log = access.clone(); - let shared = store.clone(); + let served = Served::open(store).map_err(|e| self.hide(e))?; let config = StreamableHttpServerConfig::default() .with_json_response(true) .with_legacy_session_mode(true) .with_max_request_body_bytes(MAX_BODY) .enforce_origin_validation() .with_cancellation_token(self.sessions.child_token()); + let parts = served.clone(); + // Every session's server is made the one way (server.rs Served). let service = StreamableHttpService::new( - move || { - Ok( - BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) - .with_live_settings(live.clone()) - .with_layout(layout.clone()) - .with_access(log.clone()) - .with_uploads(server_uploads.clone()) - .with_exports(server_exports.clone()) - .with_backups(server_backups.clone()), - ) - }, + move || Ok(BooksServer::new(&parts)), Arc::new(sessions()), config, ); + let Served { + store: shared, + access, + uploads, + exports, + backups, + .. + } = served; Ok(Book::Open { service: Box::new(service), store: shared, @@ -884,9 +851,9 @@ impl Books { } } -/// The settings a settings file moves in at adoption, checked as -/// `books.import_settings` checks them: they parse, they apply, and the -/// vault key file it names (which the books don't keep) is the one given. +/// The settings a settings file moves in at adoption, checked: they +/// parse, they apply, and the vault key file it names (which the books +/// don't keep) is the one given. fn settings_to_move( toml: &str, config_json: Option<&str>, diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs deleted file mode 100644 index 8e0a346..0000000 --- a/crates/ledger-mcp/src/main.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! `ledger-mcp`: the books server over stdio (or `--http ADDR`). The same -//! server is `wanderbooks dev-stdio`; both call [`ledger_mcp::run`]. - -fn main() -> Result<(), Box> { - match ledger_mcp::run("ledger-mcp", std::env::args().skip(1)) { - Err(e) => match e.downcast_ref::() { - Some(exit) => std::process::exit(exit.0), - None => Err(e), - }, - done => done, - } -} diff --git a/crates/ledger-mcp/src/paths.rs b/crates/ledger-mcp/src/paths.rs deleted file mode 100644 index eb058a3..0000000 --- a/crates/ledger-mcp/src/paths.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Paths as the server judges them: where a path really leads. - -use std::path::{Component, Path, PathBuf}; - -/// `path` made absolute and resolved as the kernel would follow it, name -/// by name: every symlink followed (a dangling one too, to where it -/// points), `.` and `..` folded in order, and whatever doesn't exist kept -/// as written. For an existing path this is its canonical form; for a -/// missing one, where it would be. -pub fn resolved(path: &Path) -> PathBuf { - let abs = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from("/")) - .join(path) - }; - // The names still to walk, last first. - let mut todo: Vec = Vec::new(); - let mut out = PathBuf::new(); - // An absolute path starts the walk over at its root. - let push_all = |todo: &mut Vec, p: &Path, out: &mut PathBuf| { - *out = PathBuf::new(); - let mut parts = Vec::new(); - for c in p.components() { - match c { - Component::Prefix(_) | Component::RootDir => out.push(c.as_os_str()), - other => parts.push(other.as_os_str().to_os_string()), - } - } - todo.extend(parts.into_iter().rev()); - }; - push_all(&mut todo, &abs, &mut out); - // Links followed at most this many times (as the kernel's ELOOP). - let mut hops = 0; - while let Some(name) = todo.pop() { - if name == "." { - continue; - } - if name == ".." { - out.pop(); - continue; - } - let next = out.join(&name); - let link = std::fs::symlink_metadata(&next) - .is_ok_and(|m| m.file_type().is_symlink()) - .then(|| std::fs::read_link(&next).ok()) - .flatten(); - match link { - Some(target) if hops < 40 => { - hops += 1; - if target.is_absolute() { - push_all(&mut todo, &target, &mut out); - } else { - todo.extend( - target - .components() - .map(|c| c.as_os_str().to_os_string()) - .rev(), - ); - } - } - _ => out = next, - } - } - out -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - use std::os::unix::fs::symlink; - - /// `resolved` follows as many links as the kernel does (40, as - /// `canonicalize` does), and no more. - #[test] - fn resolved_follows_forty_links_and_no_more() { - let dir = tempfile::tempdir().unwrap(); - let b = dir.path().canonicalize().unwrap(); - let file = b.join("receipt.pdf"); - std::fs::write(&file, "x").unwrap(); - let link = |n: usize| b.join(format!("link-{n}")); - symlink(&file, link(1)).unwrap(); - for n in 2..=41 { - symlink(link(n - 1), link(n)).unwrap(); - } - assert_eq!(link(40).canonicalize().unwrap(), file); - assert_eq!(resolved(&link(40)), file); - assert!(link(41).canonicalize().is_err(), "the kernel stops at 40"); - assert_ne!(resolved(&link(41)), file); - } - - /// `resolved` places a path where the kernel would follow it: the - /// canonical form for one that exists, and through a dangling link to - /// where it points. - #[test] - fn resolved_follows_every_link_a_dangling_one_too() { - let dir = tempfile::tempdir().unwrap(); - let b = dir.path().canonicalize().unwrap(); - std::fs::create_dir_all(b.join("Downloads/sub")).unwrap(); - std::fs::create_dir_all(b.join("data")).unwrap(); - std::fs::write(b.join("Downloads/receipt.pdf"), "x").unwrap(); - std::fs::write(b.join("data/journal.jsonl"), "x").unwrap(); - symlink( - b.join("Downloads/receipt.pdf"), - b.join("Downloads/good-link"), - ) - .unwrap(); - symlink(b.join("data"), b.join("Downloads/data-link")).unwrap(); - for p in [ - b.join("Downloads/good-link"), - b.join("Downloads/data-link/journal.jsonl"), - b.join("Downloads/sub/../receipt.pdf"), - b.join("Downloads/./good-link"), - ] { - assert_eq!(resolved(&p), p.canonicalize().unwrap(), "{}", p.display()); - } - symlink("../elsewhere/gone.pdf", b.join("Downloads/rel-dangling")).unwrap(); - assert_eq!( - resolved(&b.join("Downloads/rel-dangling")), - b.join("elsewhere/gone.pdf") - ); - symlink(b.join("Downloads/loop-b"), b.join("Downloads/loop-a")).unwrap(); - symlink(b.join("Downloads/loop-a"), b.join("Downloads/loop-b")).unwrap(); - // A loop ends (the hop limit) inside the folder it loops in. - assert!(resolved(&b.join("Downloads/loop-a")).starts_with(b.join("Downloads"))); - assert_eq!( - resolved(&b.join("Downloads/missing/../../elsewhere/x")), - b.join("elsewhere/x") - ); - } -} diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 02ab1fe..af6c8d5 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -25,9 +25,8 @@ //! the one there) needs books:post (review of slice 4). //! 6. Where the books' backups go is books:admin's, as backing up is //! (a backup is the whole book, its vault and key included): -//! setting a `backup.*` key, or importing settings that hold one, -//! needs books:admin; other settings books:post as before (review of -//! slice 7). +//! setting a `backup.*` key needs books:admin; other settings +//! books:post as before (review of slice 7). //! ``` use ledger_store::auth::Scope; @@ -46,14 +45,9 @@ pub enum Needs { /// books:admin for a change to a `backup.*` setting, books:post for /// any other (INVARIANT 6). AdminForBackupSettings, - /// As PostWhenConfirmed, and books:admin when the settings imported - /// hold a `[backup]` table (INVARIANT 6). - ImportSettings, } -use Needs::{ - AdminForBackupSettings, Always, ImportSettings, PostForApprovalRules, PostWhenConfirmed, -}; +use Needs::{AdminForBackupSettings, Always, PostForApprovalRules, PostWhenConfirmed}; const R: Needs = Always(Scope::Read); const W: Needs = Always(Scope::Write); const P: Needs = Always(Scope::Post); @@ -62,12 +56,11 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 119] = [ +pub const TABLE: [(&str, Needs); 118] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. ("books.set_setting", AdminForBackupSettings), - ("books.import_settings", ImportSettings), ("books.export_settings", R), ("books.conventions", R), ("books.set_convention", K), @@ -220,20 +213,6 @@ pub fn needs( }); if backup { Scope::Admin } else { Scope::Post } } - ImportSettings => { - let backup = args - .and_then(|a| a.get("toml_text")) - .and_then(serde_json::Value::as_str) - .and_then(|t| t.parse::().ok()) - .is_some_and(|t| t.contains_key("backup")); - if backup { - Scope::Admin - } else if confirmed(args) { - Scope::Post - } else { - Scope::Write - } - } PostForApprovalRules => { let kind = args .and_then(|a| a.get("kind")) @@ -259,7 +238,7 @@ fn confirmed(args: Option<&serde_json::Map>) -> bool pub fn shown(tool: &str, scopes: &std::collections::BTreeSet) -> bool { match TABLE.iter().find(|(name, _)| *name == tool) { Some((_, Always(scope))) => scopes.contains(scope), - Some((_, PostWhenConfirmed | PostForApprovalRules | ImportSettings)) => { + Some((_, PostWhenConfirmed | PostForApprovalRules)) => { scopes.contains(&Scope::Write) || scopes.contains(&Scope::Post) } Some((_, AdminForBackupSettings)) => { @@ -333,25 +312,6 @@ mod tests { ); assert_eq!(set(&[" Backup.Folder"]), Some(Scope::Admin)); assert_eq!(needs("books.set_setting", None), Some(Scope::Post)); - let import = |text: &str, confirmed: bool| { - needs( - "books.import_settings", - args(json!({"toml_text": text, "confirmed": confirmed})).as_ref(), - ) - }; - assert_eq!( - import("[company]\nname = \"A\"\n", false), - Some(Scope::Write) - ); - assert_eq!(import("[company]\nname = \"A\"\n", true), Some(Scope::Post)); - assert_eq!( - import("[backup]\nfolder = \"/x\"\n", false), - Some(Scope::Admin) - ); - assert_eq!( - import("[backup]\nfolder = \"/x\"\n", true), - Some(Scope::Admin) - ); // Shown to whoever can set some setting. let post: std::collections::BTreeSet = [Scope::Post].into(); let write: std::collections::BTreeSet = [Scope::Write].into(); @@ -359,7 +319,6 @@ mod tests { assert!(shown("books.set_setting", &post)); assert!(shown("books.set_setting", &admin)); assert!(!shown("books.set_setting", &write)); - assert!(shown("books.import_settings", &write)); } #[test] @@ -372,7 +331,7 @@ mod tests { .and_then(|p| p.as_object()) .is_some_and(|p| p.contains_key("confirmed")); let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap(); - assert_eq!(takes, matches!(*needs, C | ImportSettings), "{}", tool.name); + assert_eq!(takes, *needs == C, "{}", tool.name); let takes_kind = tool .input_schema .get("properties") diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index adfbb29..9265c58 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -32,14 +32,11 @@ //! ``` use crate::handbook; -use crate::paths::resolved; use crate::policy::ReceiptPolicy; use crate::settings::{Live, Settings}; use crate::tin_files; use crate::types::*; use chrono::NaiveDate; -#[cfg(test)] -use ledger_config::Company; use ledger_core::{ AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry, Ledger, Money, NewAsset, PeriodStatus, Side, depreciation_schedule, reports, @@ -62,69 +59,78 @@ use std::sync::{Arc, Mutex}; /// is single-writer by construction. Tool bodies (synchronous disk and /// SQLite work) run on tokio's blocking threads, never on the async /// workers, so a long call can't stall the transport ([`run_blocking`]). +/// Built only from a book as the books server serves it ([`Served`]): the +/// one way books are served (plan D-44). #[derive(Clone)] pub struct BooksServer { store: Arc, settings: Arc>, - layout: Arc, - /// The book's access log, for books served from the data root: every - /// call is then checked against its caller's token and recorded - /// (access.rs). None for a books dir served over stdio. - access: Option>, - /// The book's uploads (books served from the data root; none over - /// stdio, where no bytes can be sent but inline). - uploads: Option, - /// The files the books made for agents, waiting to be fetched (books - /// served from the data root; over stdio a file comes back inline). - exports: Option, - /// The books' backups (books served from the data root; none over - /// stdio). - backups: Option>, + /// The book's access log: every call is checked against its caller's + /// token and recorded (access.rs). + access: Arc, + /// The book's uploads (`books.begin_upload`). + uploads: ledger_store::uploads::Uploads, + /// The files the books made for agents, waiting to be fetched. + exports: ledger_store::exports::Exports, + /// The books' backups (backup.rs). + backups: Arc, } -/// Where a book's own files live around its data dir. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Layout { - /// A books dir, as before P2: the data dir is `books-data/` in a - /// project folder (the books dir), which holds `vault/`, the settings - /// file and perhaps git; the vault's key lives outside it. - BooksDir, - /// A book in the data root (plan 3.4): the data dir is `books//`, - /// holding its own `vault/` and its v1 key in `keys/legacy.key`. The - /// root (the path given) plays the books dir's part: nothing in it is - /// ever read or written through a path a tool names. - Root(std::path::PathBuf), +/// One book as the books server serves it (local.rs), shared by every +/// session with it: its one store, the settings in force, its access +/// log, uploads, exports and backups. Each session's [`BooksServer`] is +/// made from it ([`BooksServer::new`]). +#[derive(Clone)] +pub(crate) struct Served { + pub(crate) store: Arc, + pub(crate) settings: Arc>, + pub(crate) access: Arc, + pub(crate) uploads: ledger_store::uploads::Uploads, + pub(crate) exports: ledger_store::exports::Exports, + pub(crate) backups: Arc, } -impl Layout { - /// What a tool never reads or writes through a path: the books dir, - /// or the whole data root. - fn books_dir(&self, store: &Store) -> std::path::PathBuf { - match self { - Layout::BooksDir => books_dir(store), - Layout::Root(root) => root.clone(), - } - } - - /// The TIN vault's folder. - fn vault_dir(&self, store: &Store) -> std::path::PathBuf { - match self { - Layout::BooksDir => books_dir(store).join("vault"), - Layout::Root(_) => store.data_dir().join("vault"), - } - } - - /// The vault's key file: in the data root always the book's own - /// `keys/legacy.key`, whatever a setting says; in a books dir the one - /// in force (`[vault] key_file`, else the default outside the books). - fn key_file(&self, store: &Store, settings: &Settings) -> std::path::PathBuf { - match self { - Layout::BooksDir => settings.key_file(), - Layout::Root(_) => store - .data_dir() - .join(ledger_store::root::KEYS_DIR) - .join(ledger_store::root::LEGACY_KEY_FILE), - } +impl Served { + /// What serving the books `store` holds needs, opened beside them: + /// the settings in force (the books' own), the access log, the uploads + /// and exports, the viewer's side channel, and the backups. The books + /// server opens every book this way (local.rs), the only way books are + /// served (plan D-44). + pub(crate) fn open(store: Store) -> Result { + let mut live = Live::own(); + live.sync(store.ledger().settings(), store.base_currency().code()); + let access = Arc::new( + ledger_store::access::AccessLog::open(store.data_dir()) + .map_err(|e| format!("its access log can't be used: {e}"))?, + ); + let uploads = + ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp()) + .map_err(|e| e.to_string())?; + let exports = + ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp()) + .map_err(|e| e.to_string())?; + // The viewer's side channel, made by the server (ops.rs INVARIANT 2): + // a viewer can show its presence from the start. + ledger_store::ops::prepare(store.data_dir()).map_err(|e| e.to_string())?; + let book_dir = store.data_dir().to_path_buf(); + let book_id = book_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let store = SharedStore::new(store); + let backups = Arc::new(crate::backup::Backups::open( + &book_id, + &book_dir, + store.clone(), + )?); + Ok(Self { + store, + settings: Arc::new(Mutex::new(live)), + access, + uploads, + exports, + backups, + }) } } @@ -329,13 +335,6 @@ fn named( /// INVARIANT 2). pub(crate) const STOPPED: &str = "the books server stopped taking requests after an internal error, to protect the books: ask the human to restart the books server (the books are checked again when it starts)"; -/// Why the vault won't open with a key file inside the books. -const KEY_IN_BOOKS: &str = "the TIN vault's key file is inside the books' folder, beside the records it unlocks: ask the human to move it out of the books and set [vault] key_file in the settings"; - -/// Why the vault won't store into books whose version history would keep -/// its records. -const VAULT_IN_HISTORY: &str = "the TIN vault can't store anything here yet: the books' folder is kept under version history that doesn't leave the vault out, so its records would be kept forever. Ask the human to leave the vault folder out of that history, then try again"; - /// The store, locked for one stretch of synchronous work. Not `Send`, so /// the compiler refuses any tool future that would hold it across an /// `.await` (tool futures must be `Send`): the lock is never held while a @@ -481,9 +480,6 @@ fn not_run_closing() -> rmcp::ErrorData { ) } -#[cfg(test)] -pub use crate::settings::CashRoles; - /// Likely counterparties for an entry, best first (at most three): parties /// named in its description, then in its receipts' names and /// descriptions, then the party of an earlier entry with the same @@ -595,51 +591,33 @@ fn vendor_1099_report( } impl BooksServer { - /// The settings that name account `code`, by key (or, for books - /// that still read a settings file, as the file spells them). + /// The settings that name account `code`, by key. fn config_mentions(&self, code: &str) -> Vec { let mut out = Vec::new(); let settings = self.settings(); - for (key, in_file, codes) in [ - ( - "books.cash_accounts", - "[books] cash_accounts", - &settings.roles.cash, - ), - ( - "books.financing_accounts", - "[books] financing_accounts", - &settings.roles.financing, - ), - ( - "books.investing_accounts", - "[books] investing_accounts", - &settings.roles.investing, - ), - ( - "books.close_sweep", - "[books] close_sweep", - &settings.company.close_sweep, - ), + for (key, codes) in [ + ("books.cash_accounts", &settings.roles.cash), + ("books.financing_accounts", &settings.roles.financing), + ("books.investing_accounts", &settings.roles.investing), + ("books.close_sweep", &settings.company.close_sweep), ( "policy.evidence.required_accounts", - "receipt_required_accounts", &settings.policy.receipt_required_accounts, ), ( "policy.evidence.exempt_accounts", - "receipt_exempt_accounts", &settings.policy.receipt_exempt_accounts, ), ] { if codes.iter().any(|c| c == code) { - out.push(self.setting_label(key, in_file)); + out.push(key.to_string()); } } if settings.company.close_into.as_deref() == Some(code) { - out.push(self.setting_label("books.close_into", "[books] close_into")); + out.push("books.close_into".to_string()); } - let members = self.setting_label("company.members", "[[company.members]]"); + let members = "company.members"; + for m in &settings.company.members { if m.capital_account == code { out.push(format!("{members} {} capital_account", m.name)); @@ -651,132 +629,24 @@ impl BooksServer { out } - /// Build the server around an open store. - pub fn new(store: Store, policy: ReceiptPolicy) -> Self { - Self::new_shared(SharedStore::new(store), policy) - } - - /// Build the server around a shared store (for HTTP: each connection - /// gets a handle to the same store). - pub fn new_shared(store: Arc, policy: ReceiptPolicy) -> Self { + /// A session's server for the book `served` serves. + pub(crate) fn new(served: &Served) -> Self { Self { - store, - settings: Arc::new(Mutex::new(Live::fixed(Settings { - policy, - ..Settings::default() - }))), - layout: Arc::new(Layout::BooksDir), - access: None, - uploads: None, - exports: None, - backups: None, + store: served.store.clone(), + settings: served.settings.clone(), + access: served.access.clone(), + uploads: served.uploads.clone(), + exports: served.exports.clone(), + backups: served.backups.clone(), } } - /// Take uploaded files from `uploads` (`books.begin_upload`). - pub fn with_uploads(mut self, uploads: ledger_store::uploads::Uploads) -> Self { - self.uploads = Some(uploads); - self - } - - /// Hand the files the books make to agents through `exports`. - pub fn with_exports(mut self, exports: ledger_store::exports::Exports) -> Self { - self.exports = Some(exports); - self - } - - /// Back the books up through `backups` (backup.rs). - pub fn with_backups(mut self, backups: Arc) -> Self { - self.backups = Some(backups); - self - } - - /// Check every call against its caller's token, and record it in - /// `log` (access.rs). - pub fn with_access(mut self, log: Arc) -> Self { - self.access = Some(log); - self - } - - /// Serve a book kept in the data root (`Layout::Root`) rather than - /// in a books dir. - pub fn with_layout(mut self, layout: Layout) -> Self { - self.layout = Arc::new(layout); - self - } - - /// Take the settings from files, reloading them whenever they change - /// (crate::settings), shared with the other sessions holding `live`. - pub fn with_live_settings(mut self, live: Arc>) -> Self { - self.settings = live; - self - } - - /// Set the company (name for report headers, fiscal year). - #[cfg(test)] - pub fn with_company(self, company: Company) -> Self { - self.live().edit(|s| s.company = company); - self - } - - /// Set the `[books]` cash flow account roles. - #[cfg(test)] - pub fn with_cash_roles(self, roles: CashRoles) -> Self { - self.live().edit(|s| s.roles = roles); - self - } - fn live(&self) -> std::sync::MutexGuard<'_, Live> { // A panic while holding it can't leave the settings half-written // (they're swapped whole), so a poisoned lock is still good. self.settings.lock().unwrap_or_else(|e| e.into_inner()) } - /// The settings in force as the books would keep them: the books' own, - /// or, for books that still read a settings file, that file's reading - /// (no source: the file is the source). - fn settings_as_kept(&self, store: &Store) -> ledger_core::settings::Settings { - let books = store.ledger().settings(); - if books.is_journaled() { - return books.clone(); - } - let Some((toml, json)) = self.live().file_texts() else { - return books.clone(); - }; - let empty = ledger_core::settings::Settings::default(); - match ledger_config::codec::import( - toml.as_deref().unwrap_or(""), - json.as_deref(), - &empty, - self.currency(), - ) { - Ok(i) => { - let mut s = empty; - let file = ledger_core::settings::Source { - decided_by: "the settings file".into(), - decided_on: today(), - citation: None, - note: None, - }; - match s.apply(&i.changes, &file, 0) { - Ok(()) => s, - Err(_) => books.clone(), - } - } - Err(_) => books.clone(), - } - } - - /// How a message names a setting: by its key when the books keep - /// their own settings, else as the settings file spells it. - fn setting_label(&self, key: &str, in_file: &str) -> String { - if self.settings().journaled { - key.to_string() - } else { - in_file.to_string() - } - } - /// Every tool the server serves. pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter { Self::core_router() @@ -788,8 +658,8 @@ impl BooksServer { + Self::projects_router() } - /// The settings in force for this request (reloaded at its start, in - /// `call_tool` and `read_resource`, if their files changed). + /// The settings in force for this request: the books' own, taken from + /// their journal whenever the store is held (settings.rs INVARIANT 1). fn settings(&self) -> Arc { self.live().current() } @@ -896,15 +766,15 @@ impl BooksServer { /// statement of members' equity. fn member_capitals(&self, ledger: &Ledger) -> Result, String> { if self.settings().company.members.is_empty() { - return Err(format!( - "no members configured: set {} (name, share, capital_account for each)", - self.setting_label("company.members", "[[company.members]] in the settings") - )); + return Err( + "no members configured: set company.members (name, share, capital_account for each)" + .to_string(), + ); } ledger_package::member_capitals(ledger, &self.settings().company.members) } - /// Where a period close goes, from wanderbooks.toml: members' capital + /// Where a period close goes, from the settings: members' capital /// accounts by share (with their distribution accounts swept in) for a /// partnership; else `close_into` (Retained Earnings when unset or /// `3999`) with `close_sweep` zeroed into it. @@ -1006,10 +876,18 @@ impl BooksServer { Ok(out) } - /// The TIN vault's directory: `vault/` in the books dir (git-ignored), - /// or in the book's own folder in the data root. + /// The TIN vault's directory: `vault/` in the book's own folder in + /// the data root. fn vault_dir(&self, store: &Store) -> std::path::PathBuf { - self.layout.vault_dir(store) + store.data_dir().join(ledger_store::root::VAULT_DIR) + } + + /// The vault's key file: the book's own `keys/legacy.key`. + fn key_file(&self, store: &Store) -> std::path::PathBuf { + store + .data_dir() + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE) } /// Refuse bytes on their way into the books (books.attach_receipt, @@ -1135,55 +1013,29 @@ impl BooksServer { tin_files::record(&self.tin_record_dir(store)?, bytes) } - /// Where records of files with TINs go: the vault's folder, in the - /// data root made private first, as the vault itself makes it. + /// Where records of files with TINs go: the vault's folder, made + /// private first, as the vault itself makes it. fn tin_record_dir(&self, store: &Store) -> Result { - if let Layout::Root(_) = *self.layout { - ledger_store::root::private_book_folder( - store.data_dir(), - ledger_store::root::VAULT_DIR, - ) + ledger_store::root::private_book_folder(store.data_dir(), ledger_store::root::VAULT_DIR) .map_err(|e| format!("the TIN vault can't be written: {e}"))?; - } Ok(self.vault_dir(store)) } - /// Open the TIN vault with the key file in force (`[vault] key_file`, - /// else `~/.config/wanderbooks/vault.key`), creating the key only when - /// `create` (storing a TIN); reading never creates one. A key file - /// inside the books dir is refused: git would keep it beside the - /// records it unlocks. Storing into books kept in git is refused - /// unless their `.gitignore` lists `vault/`. + /// Open the TIN vault with the book's own key file, creating the key + /// only when `create` (storing a TIN); reading never creates one. fn open_vault(&self, store: &Store, create: bool) -> Result { - let key = self.layout.key_file(store, &self.settings()); - // In the data root the key is the book's own `keys/legacy.key`; - // its folder and the vault's are made private when a TIN is first + let key = self.key_file(store); + // Its folder and the vault's are made private when a TIN is first // stored. - if let Layout::Root(_) = *self.layout { - if create { - for folder in [ledger_store::root::KEYS_DIR, "vault"] { - ledger_store::root::private_book_folder(store.data_dir(), folder) - .map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?; - } - } - } else if is_inside(&books_dir(store), &key) { - return Err(KEY_IN_BOOKS.to_string()); - } - // Writing: a git repository must ignore the vault, or its records - // would be committed (and could never be purged). - let books = self.layout.books_dir(store); - if create && books.join(".git").exists() { - let ignored = std::fs::read_to_string(books.join(".gitignore")) - .unwrap_or_default() - .lines() - .any(|l| matches!(l.trim(), "vault/" | "/vault/" | "vault" | "/vault")); - if !ignored { - return Err(VAULT_IN_HISTORY.to_string()); + if create { + for folder in [ledger_store::root::KEYS_DIR, ledger_store::root::VAULT_DIR] { + ledger_store::root::private_book_folder(store.data_dir(), folder) + .map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?; } } // No message names the key file or the books' folders. ledger_vault::Vault::open(&self.vault_dir(store), &key, create).map_err(|e| match e { - ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager, or set [vault] key_file in the settings)".to_string(), + ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager)".to_string(), ledger_vault::Error::BadKey(_) => "the TIN vault's key file is not a vault key (64 hex digits): restore it from the human's password manager".to_string(), ledger_vault::Error::Io(e) => format!("the TIN vault can't be read: {}", e.kind()), other => other.to_string(), @@ -1335,31 +1187,25 @@ fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> { fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { let mut out = Vec::new(); - // Named as the books keep them, or as the settings file spells them. - let label = - |key: &'static str, in_file: &'static str| if cfg.journaled { key } else { in_file }; - let how = if cfg.journaled { - " with books.set_setting" - } else { - " in the settings" - }; + // Named as the books keep them. + let how = " with books.set_setting"; // The cash flow roles: codes the chart lacks here; the rest is the // engine's own check, the one the statement refuses on (rules.md B-14 // rule 2), so the two never disagree. let lists = [ ( ledger_core::RoleList::Cash, - label("books.cash_accounts", "[books] cash_accounts"), + "books.cash_accounts", &cfg.roles.cash, ), ( ledger_core::RoleList::Financing, - label("books.financing_accounts", "[books] financing_accounts"), + "books.financing_accounts", &cfg.roles.financing, ), ( ledger_core::RoleList::Investing, - label("books.investing_accounts", "[books] investing_accounts"), + "books.investing_accounts", &cfg.roles.investing, ), ]; @@ -1402,39 +1248,23 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec { let c = &cfg.company; for m in &c.members { check( - &format!( - "{} {} capital_account", - label("company.members", "[[company.members]]"), - m.name - ), + &format!("{} {} capital_account", "company.members", m.name), &m.capital_account, &[Equity], ); if let Some(d) = &m.distribution_account { check( - &format!( - "{} {} distribution_account", - label("company.members", "[[company.members]]"), - m.name - ), + &format!("{} {} distribution_account", "company.members", m.name), d, &[Equity], ); } } if let Some(code) = &c.close_into { - check( - label("books.close_into", "[books] close_into"), - code, - &[Equity], - ); + check("books.close_into", code, &[Equity]); } for code in &c.close_sweep { - check( - label("books.close_sweep", "[books] close_sweep"), - code, - &[Equity], - ); + check("books.close_sweep", code, &[Equity]); } // Conventions naming an account no longer in the chart (renumbered or // never there): a template can't draft, a rule covers nothing. @@ -2172,14 +2002,6 @@ fn reconcile_line( } } -/// The books dir: the data dir's parent, where wanderbooks.toml, the git -/// repository, and `vault/` live. -fn books_dir(store: &Store) -> std::path::PathBuf { - let d = store.data_dir(); - let d = d.canonicalize().unwrap_or_else(|_| d.to_path_buf()); - d.parent().map(std::path::Path::to_path_buf).unwrap_or(d) -} - /// The last four digits of every TIN the books know of, from the journal /// (each party's TIN and W-9 facts), never the vault: the filing scan's /// probe bound (tin_files.rs INVARIANT 4). @@ -2198,11 +2020,6 @@ fn known_tin_last4(store: &Store) -> Vec { .collect() } -/// Whether `path` is `dir` or inside it. -fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool { - resolved(path).starts_with(resolved(dir)) -} - /// A TIN type from a tool: ssn, ein, or itin. fn parse_tin_type(s: &str) -> Result { match s.trim().to_lowercase().as_str() { @@ -3589,9 +3406,8 @@ impl BooksServer { return Err(serde_json::json!({ "code": "party_required", "message": format!( - "entry {} names no counterparty, and these books require one ({} = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}", + "entry {} names no counterparty, and these books require one (policy.parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}", p.entry_id, - self.setting_label("policy.parties", "[books] parties"), if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) } ), "entry_id": p.entry_id, @@ -4074,7 +3890,7 @@ impl BooksServer { #[tool( name = "books.get_receipt", title = "Get a receipt", - description = "A receipt or document: what it is and everything it is linked to. view bytes adds its bytes, base64 and hash-verified, for a file of at most 256 KiB; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (books served by `wanderbooks mcp`; elsewhere a larger file doesn't come back).", + description = "A receipt or document: what it is and everything it is linked to. view bytes adds its bytes, base64 and hash-verified, for a file of at most 256 KiB; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name.", annotations(read_only_hint = true, open_world_hint = false) )] async fn get_receipt( @@ -4111,23 +3927,14 @@ impl BooksServer { } else { None }; - // Too large to come back here: the command that saves a copy (books - // served from the data root; over stdio there is none). - let download = (bytes_wanted && !fits && self.uploads.is_some()).then(|| { + // Too large to come back here: the command that saves a copy. + let download = (bytes_wanted && !fits).then(|| { format!( "wanderbooks download --book {book}{} {} --to ", files::token_flag(), meta.id ) }); - if bytes_wanted && !fits && download.is_none() { - return Err(format!( - "attachment {} is {} bytes; at most {} come back on this connection (a larger file needs these books served by `wanderbooks mcp`)", - meta.id, - meta.size_bytes, - files::INLINE_MAX - )); - } Ok(Json(GetReceiptOut { download, links: links_out(&store, meta.id)?, @@ -4698,18 +4505,7 @@ impl BooksServer { totals_by_account: totals, stale_rules, journal_repairs, - settings_problems: { - let mut problems = settings_problems(store.ledger(), &self.settings()); - if let Some(e) = self.live().status().error { - problems.insert( - 0, - format!( - "the settings didn't load (the last good settings are in force): {e}" - ), - ); - } - problems - }, + settings_problems: settings_problems(store.ledger(), &self.settings()), })) } @@ -5468,7 +5264,7 @@ impl BooksServer { #[tool( name = "books.chart_template", title = "Starter chart of accounts", - description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the settings that go with it. A proposal: review it with the human, adjust, then books.import_chart_csv.", + description = "A starter chart of accounts for a kind of business (sole proprietorship, partnership or multi-member LLC, S corporation, C corporation): the right equity and owner-pay accounts (draws; per-partner capital and distributions plus guaranteed payments; officer compensation and distributions; dividends and income tax), common income and expense accounts with tax categories assigned, and the settings that go with it (changes for books.set_setting). A proposal: review it with the human, adjust, then books.import_chart_csv and books.set_setting.", annotations(read_only_hint = true, open_world_hint = false) )] async fn chart_template( @@ -5483,9 +5279,26 @@ impl BooksServer { ) })?; let t = ledger_import::chart_template(entity, &p.owners)?; + // The template's settings, as the changes that set them in new + // books (the one way settings change: books.set_setting). + let settings = ledger_config::codec::import( + &t.settings_toml, + None, + &ledger_core::settings::Settings::default(), + self.currency(), + )? + .changes + .into_iter() + .filter_map(|c| { + Some(TemplateSettingOut { + value: c.value?, + key: c.key, + }) + }) + .collect(); Ok(Json(ChartTemplateOut { csv: t.csv(), - settings_toml: t.settings_toml, + settings, notes: t.notes, })) } @@ -5926,18 +5739,16 @@ impl BooksServer { #[tool( name = "books.settings", title = "Settings in force", - description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it, where they come from (source: \"books\", changed with books.set_setting; or \"file\", a settings file still read, reloaded on change: reload_error says why an edit didn't load), and problems: settings or conventions that disagree with the chart, each saying what to change.", + description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it (change them with books.set_setting), and problems: settings or conventions that disagree with the chart, each saying what to change.", annotations(read_only_hint = true, open_world_hint = false) )] async fn settings_tool(&self) -> Result, String> { // The books first: their settings sync as they lock, so the - // status and the settings read after are one version. + // settings read after are theirs. let store = self.lock()?; - let status = self.live().status(); let cfg = self.settings(); let c = &cfg.company; Ok(Json(SettingsOut { - source: if status.journaled { "books" } else { "file" }.to_string(), timezone: cfg.timezone.clone(), today: today().to_string(), approval_rules: match store.ledger().settings().approval_rules() { @@ -5945,12 +5756,7 @@ impl BooksServer { ledger_core::settings::ApprovalMode::Enforce => "enforce", } .to_string(), - keys: kept::keys_out(&self.settings_as_kept(&store)), - books_toml: status.books_toml.map(|p| p.display().to_string()), - books_toml_exists: status.books_toml_exists, - config_json_present: status.config_json_present, - reload_error: status.error, - loads: status.loads, + keys: kept::keys_out(&store.ledger().settings().clone()), company: c.name.clone(), fiscal_year_start: format!("{:02}-{:02}", c.fiscal_year_start.0, c.fiscal_year_start.1), tax_form: c.tax_form.map(|f| f.label().to_string()), @@ -7727,10 +7533,11 @@ impl BooksServer { /// Treas. Reg. §301.6109-4 (truncated TINs); DECISION (owner, /// 2026-10-15): full TINs only in the encrypted vault. /// INVARIANTS: - /// 1. A full TIN goes only to the vault (`vault/` in the books dir, - /// key outside it, created on first use); the facts journaled - /// (`party.w9_set`) carry its last four and the vault record's - /// hash. The output never holds more than the last four. + /// 1. A full TIN goes only to the vault (`vault/` in the book's own + /// folder, its key in `keys/` beside it, created on first use); + /// the facts journaled (`party.w9_set`) carry its last four and + /// the vault record's hash. The output never holds more than the + /// last four. /// 2. The W-9 file goes only to the vault, never the attachment store. /// 3. The facts are checked (on a copy of the ledger) before the vault /// is written; a refusal journals nothing and writes no record. @@ -7748,7 +7555,7 @@ impl BooksServer { #[tool( name = "books.set_w9", title = "Record W-9 facts", - description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept outside the books, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload), or w9_content_base64 with w9_sha256 when at most 256 KiB, and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", + description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload), or w9_content_base64 with w9_sha256 when at most 256 KiB, and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", annotations( read_only_hint = false, destructive_hint = true, @@ -7912,14 +7719,10 @@ impl BooksServer { None => w9_hash, } } else if held_tin { - ledger_vault::Vault::open( - &vault_dir, - &self.layout.key_file(&store, &self.settings()), - false, - ) - .ok() - .and_then(|v| v.record_hash(id).ok().flatten()) - .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) + ledger_vault::Vault::open(&vault_dir, &self.key_file(&store), false) + .ok() + .and_then(|v| v.record_hash(id).ok().flatten()) + .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) } else if held_w9 { existing.as_ref().and_then(|e| e.vault_record.clone()) } else { @@ -8060,10 +7863,10 @@ impl BooksServer { /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; D-29. /// INVARIANTS: /// 1. The file is handed over (tools/files.rs INVARIANT 6: an export - /// for the caller's token, or inline over stdio), never written - /// to a path; a name kept before names were checked is made - /// plain, never refused. It is recorded (tin_files.rs) first, so - /// it is never filed back into the books. + /// for the caller's token), never written to a path; a name kept + /// before names were checked is made plain, never refused. It is + /// recorded (tin_files.rs) first, so it is never filed back into + /// the books. /// 2. Nothing is journaled. /// SCENARIOS: /// 1. IF the vault is locked or holds no W-9 file for the party: @@ -8072,7 +7875,7 @@ impl BooksServer { #[tool( name = "books.get_w9", title = "Write out a W-9", - description = "A party's signed W-9 file from the TIN vault, for the CPA or the human: run the download command it returns to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books (it shows the full TIN). Nothing is journaled.", + description = "A party's signed W-9 file from the TIN vault, for the CPA or the human: run the download command it returns to save it in a folder. Never file it into the books (it shows the full TIN). Nothing is journaled.", annotations( read_only_hint = false, destructive_hint = false, @@ -9120,10 +8923,10 @@ impl BooksServer { /// 2026-10-15): "it needs to be in the damn CPA package." /// INVARIANTS: /// 1. The zip is handed over (tools/files.rs INVARIANT 6: an export - /// the caller fetches, or inline over stdio), never written to a - /// path; with include_tins (the default) it and each file in it - /// holding a TIN are recorded (tin_files.rs), so none is filed back - /// in. include_tins false: last fours only. + /// the caller fetches), never written to a path; with include_tins + /// (the default) it and each file in it holding a TIN are recorded + /// (tin_files.rs), so none is filed back in. include_tins false: + /// last fours only. /// 2. With members, k1-members.csv has one row per [[company.members]] /// member: name, party, share, capital account, TIN and type, and /// the K-1 worksheet's capital figures (ledger_core::k1) for the @@ -9138,7 +8941,7 @@ impl BooksServer { #[tool( name = "books.export_cpa_package", title = "CPA handoff package", - description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder. Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", annotations( read_only_hint = false, destructive_hint = false, @@ -10011,10 +9814,7 @@ impl ServerHandler for BooksServer { ) -> Result { use crate::access::{self, Call}; use ledger_store::access::Outcome; - let mut call = self - .access - .as_ref() - .map(|log| Call::new(log, &context, "tool", &request.name)); + let mut call = Call::new(&self.access, &context, "tool", &request.name); let outcome_of = |out: &Result| match out { Ok(rmcp::model::CallToolResponse::Complete(r)) if r.is_error != Some(true) => { Outcome::Ok @@ -10032,65 +9832,59 @@ impl ServerHandler for BooksServer { // Closing: refused as a protocol error, so a client knows it // didn't run (SharedStore INVARIANT 3). if self.store.closing() { - if let Some(c) = &mut call { - c.digested(refused_digest(request.arguments.clone()).await); - } + call.digested(refused_digest(request.arguments.clone()).await); return self - .refuse(call.as_ref(), access::CLOSING, Err(not_run_closing())) + .refuse(&call, access::CLOSING, Err(not_run_closing())) .await; } if self.store.stopped() { - if let Some(c) = &mut call { - c.digested(refused_digest(request.arguments.clone()).await); - } + call.digested(refused_digest(request.arguments.clone()).await); return self - .refuse( - call.as_ref(), - access::STOPPED, - Ok(tool_error(STOPPED.to_string())), - ) + .refuse(&call, access::STOPPED, Ok(tool_error(STOPPED.to_string()))) .await; } let needs = crate::scopes::needs(&request.name, request.arguments.as_ref()); // access.rs INVARIANTS 1, 2. - if let Some(call) = &mut call { - let refusal = match (call.caller(), needs) { - (None, _) => Some(( - access::UNAUTHORIZED, + let refusal = match (call.caller(), needs) { + (None, _) => Some(( + access::UNAUTHORIZED, + Ok(tool_error( "this call came without a token for these books".to_string(), )), - // A tool no table names isn't run (fail closed). - (Some(_), None) => Some(( - "unknown_tool", + )), + // A tool no table names isn't run (fail closed): a protocol + // error, as for any unknown tool (mcp-tools.md). + (Some(_), None) => Some(( + "unknown_tool", + Err(rmcp::ErrorData::invalid_params( format!( "there is no tool {:?}", request.name.chars().take(64).collect::() ), + None, )), - (Some(caller), Some(scope)) if !caller.may(scope) => Some(( - access::INSUFFICIENT_SCOPE, - format!( - "this agent's token can't do that: {} needs {scope}, and its token ({:?}) has {}. Only the human can give a token more (`wanderbooks token create`).", - request.name, - caller.token.name, - caller - .token - .scopes - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ), - )), - _ => None, - }; - if let Some((code, text)) = refusal { - call.digested(refused_digest(request.arguments.clone()).await); - return self.refuse(Some(call), code, Ok(tool_error(text))).await; - } + )), + (Some(caller), Some(scope)) if !caller.may(scope) => Some(( + access::INSUFFICIENT_SCOPE, + Ok(tool_error(format!( + "this agent's token can't do that: {} needs {scope}, and its token ({:?}) has {}. Only the human can give a token more (`wanderbooks token create`).", + request.name, + caller.token.name, + caller + .token + .scopes + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ))), + )), + _ => None, + }; + if let Some((code, answer)) = refusal { + call.digested(refused_digest(request.arguments.clone()).await); + return self.refuse(&call, code, answer).await; } - // Pick up an edited settings file before the tool runs. - self.live().refresh(); let timezone = self.settings().timezone.clone(); let server = self.clone(); // The journal's head once a call that may change the books has @@ -10100,23 +9894,16 @@ impl ServerHandler for BooksServer { // INVARIANT 3). let running_call = call.clone(); // Kept for the record of a call the server stopped before running. - let request_args = if call.is_some() { - request.arguments.clone() - } else { - None - }; + let request_args = request.arguments.clone(); let result = run_blocking(&self.store, move || { Box::pin(async move { WROTE.with(|w| w.borrow_mut().take()); - let caller = running_call.as_ref().and_then(|c| { - c.caller() - .map(|c| (c.token.id.clone(), c.token.name.clone())) - }); + let caller = running_call + .caller() + .map(|c| (c.token.id.clone(), c.token.name.clone())); CALLER.with(|c| *c.borrow_mut() = caller); // Counted as running by now (run_blocking): hashed here. - let digest = running_call - .as_ref() - .and_then(|_| Call::digest_of(request.arguments.as_ref())); + let digest = Call::digest_of(request.arguments.as_ref()); let tcc = rmcp::handler::server::tool::ToolCallContext::new(&server, request, context); let router = Self::tool_router(); @@ -10137,25 +9924,22 @@ impl ServerHandler for BooksServer { // with what it wrote, while still counted as // running; then the panic goes on and stops the // server (SharedStore INVARIANT 1). - if let Some(mut c) = running_call { - c.digested(digest); - let _ = c.record_blocking(Outcome::Error, wrote); - } + let mut c = running_call; + c.digested(digest); + let _ = c.record_blocking(Outcome::Error, wrote); std::panic::resume_unwind(panic) } }; - let recorded = running_call.map(|mut c| { - c.digested(digest); - c.record_blocking(outcome_of(&out), wrote) - }); + let mut c = running_call; + c.digested(digest); + let recorded = c.record_blocking(outcome_of(&out), wrote); (out, recorded) }) }) .await; match result { - Ok((out, None)) => redact_refusal(out), - Ok((out, Some(Ok(())))) => redact_refusal(out), - Ok((_, Some(Err(())))) => { + Ok((out, Ok(()))) => redact_refusal(out), + Ok((_, Err(()))) => { // access.rs INVARIANT 3. self.store.stop(); Ok(tool_error(crate::access::RAN_NOT_RECORDED.to_string())) @@ -10164,19 +9948,13 @@ impl ServerHandler for BooksServer { // the client knows (SharedStore INVARIANT 3). (Its arguments // went with the request: no digest.) Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => { - self.refuse(call.as_ref(), access::CLOSING, Err(e)).await + self.refuse(&call, access::CLOSING, Err(e)).await } // Not run: the server had stopped by the time it would have. Err(e) if e.code.0 == NOT_RUN_STOPPED_CODE => { - if let Some(c) = &mut call { - c.digested(refused_digest(request_args.clone()).await); - } - self.refuse( - call.as_ref(), - access::STOPPED, - Ok(tool_error(STOPPED.to_string())), - ) - .await + call.digested(refused_digest(request_args.clone()).await); + self.refuse(&call, access::STOPPED, Ok(tool_error(STOPPED.to_string()))) + .await } // It panicked part way (recorded as it went): a plain refusal, // redacted like every other. @@ -10192,18 +9970,10 @@ impl ServerHandler for BooksServer { let mut tools = Self::tool_router().list_all(); // A token is shown what it can call (scopes.rs); the list then // differs by caller, so it may be cached only for this one. - if self.access.is_some() { - let scopes = crate::access::caller(&context) - .map(|c| c.token.scopes) - .unwrap_or_default(); - tools.retain(|t| crate::scopes::shown(&t.name, &scopes)); - } else { - // Books served over stdio keep no access log and aren't backed - // up here: not offered. - tools.retain(|t| { - !["books.access_log", "books.backup", "books.back_up_now"].contains(&&*t.name) - }); - } + let scopes = crate::access::caller(&context) + .map(|c| c.token.scopes) + .unwrap_or_default(); + tools.retain(|t| crate::scopes::shown(&t.name, &scopes)); let hints = context .protocol_version() .is_some_and(|v| v >= rmcp::model::ProtocolVersion::V_2026_07_28); @@ -10218,17 +9988,15 @@ impl ServerHandler for BooksServer { } fn get_info(&self) -> ServerConfig { - // A client connecting after a settings change sees the change: - // the books' own when they keep them (never waiting for the books - // here; a request holding them syncs them when it locks), else the - // files'. + // A client connecting after a settings change sees the change + // (never waiting for the books here; a request holding them syncs + // them when it locks). if let Ok(store) = self.store.store.try_lock() && let Some(store) = store.as_ref() { self.live() .sync(store.ledger().settings(), self.store.currency.code()); } - self.live().refresh(); ServerConfig::new(crate::opening::capabilities()) // The rules in brief, with the policy these books actually use; // the rest is in the handbook. @@ -10256,12 +10024,9 @@ impl ServerHandler for BooksServer { use ledger_store::access::Outcome; // Reading a resource is reading the books: books:read, recorded // (access.rs). - let call = self - .access - .as_ref() - .map(|log| Call::new(log, &context, "resource", &request.uri)); - let result = self.resource(request, call.as_ref()).await; - if let Some(call) = &call { + let call = Call::new(&self.access, &context, "resource", &request.uri); + let result = self.resource(request, &call).await; + { let outcome = match &result { Ok(_) => Outcome::Ok, Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => { @@ -10297,15 +10062,14 @@ impl BooksServer { /// INVARIANT 3). async fn refuse( &self, - call: Option<&crate::access::Call>, + call: &crate::access::Call, code: &str, answer: Result, ) -> Result { - if let Some(call) = call - && call - .record(ledger_store::access::Outcome::Refused(code.into()), None) - .await - .is_err() + if call + .record(ledger_store::access::Outcome::Refused(code.into()), None) + .await + .is_err() { self.store.stop(); // A call refused as not run stays so (the bridge sends it @@ -10326,26 +10090,23 @@ impl BooksServer { async fn resource( &self, request: ReadResourceRequestParams, - call: Option<&crate::access::Call>, + call: &crate::access::Call, ) -> Result { - if let Some(call) = call { - match call.caller() { - None => { - return Err(rmcp::ErrorData::invalid_request( - crate::access::UNAUTHORIZED, - None, - )); - } - Some(c) if !c.may(ledger_store::auth::Scope::Read) => { - return Err(rmcp::ErrorData::invalid_request( - crate::access::INSUFFICIENT_SCOPE, - None, - )); - } - Some(_) => {} + match call.caller() { + None => { + return Err(rmcp::ErrorData::invalid_request( + crate::access::UNAUTHORIZED, + None, + )); } + Some(c) if !c.may(ledger_store::auth::Scope::Read) => { + return Err(rmcp::ErrorData::invalid_request( + crate::access::INSUFFICIENT_SCOPE, + None, + )); + } + Some(_) => {} } - self.live().refresh(); let timezone = self.settings().timezone.clone(); let uri = request.uri.clone(); let server = self.clone(); @@ -10380,7 +10141,6 @@ impl BooksServer { #[cfg(test)] mod tests { use super::*; - use ledger_config::Member; use std::str::FromStr; fn d(y: i32, m: u32, day: u32) -> NaiveDate { @@ -10391,20 +10151,24 @@ mod tests { Money::new(Decimal::from_str(s).unwrap(), Currency::USD) } - /// A test's books dir, removed when the test ends, pass or panic. - struct TestDir(std::path::PathBuf); + /// A test's books' folder (`.0`) in a data root of its own, under a + /// home (`.1`) removed when the test ends, pass or panic. + struct TestDir(std::path::PathBuf, std::path::PathBuf); impl Drop for TestDir { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + let _ = std::fs::remove_dir_all(&self.1); } } fn unit_dir(tag: &str) -> TestDir { - let dir = + let home = std::env::temp_dir().join(format!("ledger-mcp-unit-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - TestDir(dir) + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + let (root, _) = + ledger_store::root::Root::setup(&home, Some(&home.join("root")), None).unwrap(); + TestDir(root.path().join("books").join("acme"), home) } /// New books in `dir`, which outlives them. @@ -10412,6 +10176,13 @@ mod tests { Store::create(&dir.0, Currency::USD).unwrap() } + /// A session's server for the books `store` holds, made as the books + /// server makes every one (Served::open): driven here without a + /// transport. + fn served(store: Store) -> BooksServer { + BooksServer::new(&Served::open(store).expect("the books are served")) + } + /// lines_settled: posted, or a draft with lines whose debits equal its /// credits; a draft whose sides are past exact arithmetic (one an /// earlier version journaled, rules.md B-28) is not settled. @@ -10466,26 +10237,38 @@ mod tests { e } - fn members() -> Company { - Company { - members: vec![ - Member { - name: "Ada".into(), - share: Decimal::new(5, 1), - capital_account: "3000".into(), - distribution_account: Some("3100".into()), - files_jointly_with: None, - }, - Member { - name: "Bo".into(), - share: Decimal::new(5, 1), - capital_account: "3001".into(), - distribution_account: None, - files_jointly_with: None, - }, - ], - ..Company::default() - } + /// `store` with `changes` journaled, as `books.set_setting` journals + /// them: a session's server reads its settings only from there. + fn settled(mut store: Store, changes: &[(&str, serde_json::Value)]) -> Store { + let changes: Vec<_> = changes + .iter() + .map(|(key, value)| ledger_core::settings::Change { + key: (*key).to_string(), + value: Some(value.clone()), + }) + .collect(); + let source = ledger_core::settings::Source { + decided_by: "Morgan Ellis".into(), + decided_on: d(2026, 1, 1), + citation: None, + note: None, + }; + store + .change_settings(&changes, &source) + .expect("the settings are taken"); + store + } + + /// Two members, Ada (with a distributions account) and Bo. + fn members() -> (&'static str, serde_json::Value) { + ( + "company.members", + serde_json::json!([ + {"name": "Ada", "share": "0.5", "capital_account": "3000", + "distribution_account": "3100"}, + {"name": "Bo", "share": "0.5", "capital_account": "3001"}, + ]), + ) } /// An upload's limit, 25 MiB exactly; inline, 256 KiB (plan 2.1, 4.7). @@ -10540,50 +10323,44 @@ mod tests { #[test] fn config_mentions_finds_every_setting_naming_a_code() { let books = unit_dir("mentions"); - let server = BooksServer::new(store(&books), ReceiptPolicy::default()) - .with_company(members()) - .with_cash_roles(CashRoles { - cash: vec!["1000".into()], - financing: vec!["2100".into()], - investing: vec!["1400".into()], - }); - assert_eq!(server.config_mentions("1000"), ["[books] cash_accounts"]); - assert_eq!( - server.config_mentions("2100"), - ["[books] financing_accounts"] - ); - assert_eq!( - server.config_mentions("1400"), - ["[books] investing_accounts"] - ); + let server = served(settled( + store(&books), + &[ + members(), + ("books.cash_accounts", serde_json::json!(["1000"])), + ("books.financing_accounts", serde_json::json!(["2100"])), + ("books.investing_accounts", serde_json::json!(["1400"])), + ], + )); + assert_eq!(server.config_mentions("1000"), ["books.cash_accounts"]); + assert_eq!(server.config_mentions("2100"), ["books.financing_accounts"]); + assert_eq!(server.config_mentions("1400"), ["books.investing_accounts"]); assert_eq!( server.config_mentions("3000"), - ["[[company.members]] Ada capital_account"] + ["company.members Ada capital_account"] ); assert_eq!( server.config_mentions("3001"), - ["[[company.members]] Bo capital_account"] + ["company.members Bo capital_account"] ); assert_eq!( server.config_mentions("3100"), - ["[[company.members]] Ada distribution_account"] + ["company.members Ada distribution_account"] ); assert!(server.config_mentions("6000").is_empty()); let books = unit_dir("mentions-solo"); - let solo = - BooksServer::new(store(&books), ReceiptPolicy::default()).with_company(Company { - close_into: Some("3000".into()), - ..Company::default() - }); - assert_eq!(solo.config_mentions("3000"), ["[books] close_into"]); + let solo = served(settled( + store(&books), + &[("books.close_into", serde_json::json!("3000"))], + )); + assert_eq!(solo.config_mentions("3000"), ["books.close_into"]); } /// Reports name each member with their accounts, in order. #[test] fn report_members_carry_names_and_accounts() { let books = unit_dir("members"); - let server = - BooksServer::new(store(&books), ReceiptPolicy::default()).with_company(members()); + let server = served(settled(store(&books), &[members()])); let m = server.report_members(); assert_eq!(m.len(), 2); assert_eq!( @@ -10619,7 +10396,7 @@ mod tests { post(&mut s, d(2026, 1, 20), rent, cash, "300.00"); post(&mut s, d(2026, 2, 5), cash, sales, "50.00"); post(&mut s, d(2026, 2, 6), rent, cash, "20.00"); - let server = BooksServer::new(s, ReceiptPolicy::default()); + let server = served(s); let as_of = d(2026, 2, 28); { let store = server.lock().unwrap(); @@ -10666,7 +10443,7 @@ mod tests { post(&mut s, d(2026, 1, 10), cash, sales, "1000.00"); s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)) .unwrap(); - let server = BooksServer::new(s, ReceiptPolicy::default()); + let server = served(s); let as_of = d(2026, 2, 28); let thousand = Decimal::from_str("1000.00").unwrap(); { @@ -10704,11 +10481,10 @@ mod tests { s.add_line(draft, cash, Side::Credit, usd("10.00")).unwrap(); let posted = post(&mut s, d(2026, 3, 2), rent, cash, "10.00"); let reversal = s.reverse_entry(posted, d(2026, 3, 3), "undo").unwrap(); - let every = ReceiptPolicy { - require_receipts_above: "0.00".into(), - ..ReceiptPolicy::default() - }; - let server = BooksServer::new(s, every); + let server = served(settled( + s, + &[("policy.evidence.threshold", serde_json::json!("0.00"))], + )); let store = server.lock().unwrap(); assert!( server.ensure_receipt_not_required(&store, draft).is_ok(), @@ -10745,11 +10521,10 @@ mod tests { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 119] = [ + let table: [(&str, Hints); 118] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), - ("books.import_settings", DI), ("books.export_settings", R), ("books.conventions", R), ("books.set_convention", W), @@ -10904,8 +10679,8 @@ mod tests { "{name}: openWorldHint" ); } - // docs/mcp-interface.md names the 29 destructive tools. - assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29); + // docs/mcp-interface.md names the 28 destructive tools. + assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 28); } /// Every refusal an agent can be given whose text is fixed here, or @@ -11008,8 +10783,6 @@ mod tests { )); for (what, text) in [ ("STOPPED", STOPPED), - ("KEY_IN_BOOKS", KEY_IN_BOOKS), - ("VAULT_IN_HISTORY", VAULT_IN_HISTORY), ("tin_files::TIN_FILE", tin_files::TIN_FILE), ("tin_files::TIN_HELD", tin_files::TIN_HELD), ("tin_files::TIN_PROBE", tin_files::TIN_PROBE), @@ -11281,7 +11054,10 @@ mod tests { .map(|t| t.name.to_string()) .collect(); let mut unknown: Vec = Vec::new(); - // (Settings keys, `books.cash_accounts`, are named like tools.) + // Settings keys (`books.cash_accounts`) are named like tools: a + // name that is one is a setting, not a tool. + let keys: std::collections::HashSet<&str> = + ledger_core::settings::KEYS.iter().map(|k| k.name).collect(); for (what, text) in texts .iter() .filter(|(w, _)| !w.contains("settings literal")) @@ -11302,7 +11078,7 @@ mod tests { if before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '/') { continue; } - if !tools.contains(name) { + if !tools.contains(name) && !keys.contains(name) { unknown.push(format!("{what}: {name}")); } } @@ -11719,7 +11495,8 @@ mod tests { ]; for (how, body) in bodies { let books = unit_dir("stops"); - let server = BooksServer::new(store(&books), ReceiptPolicy::default()); + let parts = Served::open(store(&books)).unwrap(); + let server = BooksServer::new(&parts); rt.block_on(async { let seq: Body = |s| s.lock().map(|st| st.journal_seq()); // New books: store.created, then their journal format. @@ -11729,8 +11506,8 @@ mod tests { // Every later request is refused, before it runs. let after = run(&server, seq).await.unwrap_err(); assert_eq!(after.message, STOPPED, "{how}"); - // So is any session sharing the store (HTTP). - let other = BooksServer::new_shared(server.store.clone(), ReceiptPolicy::default()); + // So is any other session with the books. + let other = BooksServer::new(&parts); assert_eq!(run(&other, seq).await.unwrap_err().message, STOPPED); }); // And the lock itself, from any thread. @@ -11743,7 +11520,7 @@ mod tests { // A guard dropped by a panic on a thread of its own stops it too. let books = unit_dir("stops-thread"); - let server = BooksServer::new(store(&books), ReceiptPolicy::default()); + let server = served(store(&books)); let s = server.clone(); let died = std::thread::spawn(move || { let _store = s.lock().unwrap(); @@ -11799,47 +11576,39 @@ mod tests { } /// A client that connects after the settings changed is told the rule - /// now in force (plan P0, "get_info calls refresh()"): the connect text - /// is composed from the settings as they are at initialize, not as - /// they were when the server started. + /// now in force (plan P0): the connect text is composed from the + /// books' settings as they are at initialize, not as they were when + /// the server started. #[test] fn a_new_session_is_told_the_settings_in_force() { - let dir = std::env::temp_dir().join(format!( - "ledger-mcp-unit-{}-info-settings", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let _cleanup = TestDir(dir.clone()); - let toml = dir.join("wanderbooks.toml"); - let threshold = |t: &str| { - std::fs::write( - &toml, - format!("[company]\nname = \"Info Co\"\n[books]\nreceipt_threshold = \"{t}\"\n"), - ) - .unwrap(); - }; - threshold("25.00"); - let live = crate::settings::Live::watching(crate::settings::Source { - books_toml: toml.clone(), - required: true, - config_json: dir.join("config.json"), - }) - .unwrap(); let books = unit_dir("info"); - let server = BooksServer::new(store(&books), ReceiptPolicy::default()) - .with_live_settings(Arc::new(Mutex::new(live))); + let server = served(store(&books)); let told = |s: &BooksServer| rmcp::ServerHandler::get_info(s).instructions.unwrap(); let before = told(&server); assert!(before.contains("more than 25.00 USD in debits"), "{before}"); - threshold("0.00"); + { + let mut store = server.lock().unwrap(); + store + .change_settings( + &[ledger_core::settings::Change { + key: "policy.evidence.threshold".into(), + value: Some(serde_json::json!("0.00")), + }], + &ledger_core::settings::Source { + decided_by: "Test Owner".into(), + decided_on: d(2026, 10, 23), + citation: None, + note: None, + }, + ) + .unwrap(); + } let after = told(&server); assert!( after.contains("every entry needs at least one receipt"), "{after}" ); assert!(!after.contains("25.00"), "{after}"); - let _ = std::fs::remove_dir_all(&dir); } /// A bare books://digest, or one with an empty since=, covers this @@ -11881,7 +11650,7 @@ mod tests { async fn session(tag: &str) -> Session { use rmcp::ServiceExt; let books = unit_dir(&format!("{tag}-peer")); - let server = BooksServer::new(store(&books), ReceiptPolicy::default()); + let server = served(store(&books)); let (a, b) = tokio::io::duplex(1 << 16); let served = tokio::spawn(async move { server.serve(a).await }); let client = ().serve(b).await.unwrap(); @@ -11924,13 +11693,11 @@ mod tests { ledger_store::auth::Scope::Admin, ]; - /// Books served from the data root: calls are checked against their - /// caller's token and recorded in the access log beside them. + /// Books as the books server serves them: calls are checked against + /// their caller's token and recorded in the access log beside them. fn logged(tag: &str) -> (BooksServer, TestDir) { let books = unit_dir(tag); - let store = store(&books); - let log = ledger_store::access::AccessLog::open(&books.0).unwrap(); - let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log)); + let server = served(store(&books)); (server, books) } @@ -11953,9 +11720,8 @@ mod tests { drop(std::fs::OpenOptions::new().write(true).open(&path).unwrap()); drop(std::fs::File::open(&path).unwrap()); }); - let log = ledger_store::access::AccessLog::open(&books.0).unwrap(); + let server = served(store); other_end.join().unwrap(); - let server = BooksServer::new(store, ReceiptPolicy::default()).with_access(Arc::new(log)); (server, books) } @@ -12027,11 +11793,14 @@ mod tests { answer(server.call_tool(add(), context(&s.peer, None)).await).unwrap(); assert!(refusal, "{text}"); assert!(text.contains("without a token"), "{text}"); + // An unknown tool is a protocol error (mcp-tools.md), never run. let unknown = tool_call("books.no_such_tool", serde_json::json!({})); - let (refusal, text) = - answer(server.call_tool(unknown, context(&s.peer, Some(ALL))).await).unwrap(); - assert!(refusal, "{text}"); - assert!(text.contains("there is no tool"), "{text}"); + let err = server + .call_tool(unknown, context(&s.peer, Some(ALL))) + .await + .unwrap_err(); + assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS, "{err:?}"); + assert!(err.message.contains("there is no tool"), "{err:?}"); // Neither ran: the account isn't there. let chart = tool_call("books.chart", serde_json::json!({})); let (refusal, text) = @@ -12223,14 +11992,14 @@ mod tests { // Any other answer refused unrun becomes the words alone. let (server, _books) = unwritable("unrecorded-refusal"); let call = crate::access::Call::new( - server.access.as_ref().unwrap(), + &server.access, &context(&s.peer, Some(ALL)), "tool", "books.chart", ); let out = server .refuse( - Some(&call), + &call, crate::access::STOPPED, Err(rmcp::ErrorData::internal_error("not run", None)), ) @@ -12301,12 +12070,12 @@ mod tests { rt().block_on(async { let s = session("hints").await; let books = unit_dir("hints"); - let server = BooksServer::new(store(&books), ReceiptPolicy::default()); + let server = served(store(&books)); for (version, hinted) in [ (rmcp::model::ProtocolVersion::V_2026_07_28, true), (rmcp::model::ProtocolVersion::V_2025_11_25, false), ] { - let mut cx = context(&s.peer, None); + let mut cx = context(&s.peer, Some(ALL)); cx.meta.set_protocol_version(version.clone()); let list = server.list_tools(None, cx).await.unwrap(); assert!(!list.tools.is_empty()); diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs index df0267d..56fb796 100644 --- a/crates/ledger-mcp/src/settings.rs +++ b/crates/ledger-mcp/src/settings.rs @@ -1,91 +1,56 @@ //! The server's settings: the books' own (journaled, ledger_core:: -//! settings), or, for books that hold none yet, the settings files -//! (`wanderbooks.toml`, and the receipt policy in `books-data/config.json`) -//! reloaded whenever they change. +//! settings), the defaults until the first is set. //! //! ```text //! GOAL: every request sees the settings in force, one version for the -//! whole request; books that keep their own settings never read a -//! file; a broken file edit never takes the server down. +//! whole request, and they are only ever the books' own. //! SOURCE: plan/served.md I-3 (the journal holds all books state; the -//! server never reads a settings file for books that hold their -//! settings), 2.1 ("dev-stdio keeps legacy_files mode until a book -//! is adopted"); DECISION (owner, 2026-10-15): file settings are -//! always auto-reloaded. +//! server never reads a settings file), 3.4 (a book in the data +//! root keeps its own settings), D-44 (one way to serve books: no +//! settings file is read by any of them). //! INVARIANTS: -//! 1. Books whose journal holds settings (any settings.set) use those, -//! and from then on no file is read: sync takes them whenever the -//! journal's last settings.set changes. -//! 2. Otherwise every request (tool call, resource read) sees the files' -//! contents as of its start: a request after an edit sees the edit -//! (compared by content, not by timestamp, so two edits in one second -//! are both seen). -//! 3. A file that fails to load leaves the last good settings in force, -//! and the failure is reported (books.settings, stderr) until a later -//! edit loads. -//! 4. A settings file that doesn't exist and wasn't asked for (the -//! default wanderbooks.toml beside the books) means the defaults; one -//! named with --books-toml that goes missing is a failure (3). -//! 5. Only the settings change while serving: the data dir, the books' -//! base currency, and a vault key file given on the command line are -//! fixed when the server starts (the key file given wins over any -//! file's [vault] key_file). -//! 6. A book in the data root (plan 3.4) has no settings file at all: -//! its settings are its own, the defaults until the first is set, -//! and no file is ever read for it. +//! 1. The settings in force are the books' own: the defaults until +//! their journal holds any settings.set, then those; sync takes them +//! whenever the journal's last settings.set changes, and only then. +//! 2. No settings file is ever read. //! ``` use crate::policy::ReceiptPolicy; use ledger_config::Company; -use std::path::PathBuf; use std::sync::Arc; /// `[books]` account roles for the cash flow statement, used when a call /// doesn't pass its own: cash, financing, and investing account codes. #[derive(Clone, Debug, Default)] pub struct CashRoles { - /// `[books] cash_accounts`. + /// `books.cash_accounts`. pub cash: Vec, - /// `[books] financing_accounts`. + /// `books.financing_accounts`. pub financing: Vec, - /// `[books] investing_accounts`. + /// `books.investing_accounts`. pub investing: Vec, } -/// What the settings files say, as last loaded. +/// The settings in force, as the server uses them. #[derive(Clone, Debug, Default)] pub struct Settings { /// Which entries need a receipt. pub policy: ReceiptPolicy, - /// `[company]`. + /// The company (`company.*`). pub company: Company, - /// `[books]` cash flow roles. + /// The cash flow roles. pub roles: CashRoles, - /// The prefix of generated asset tags (`[books] asset_tag_prefix`, or + /// The prefix of generated asset tags (`books.asset_tag_prefix`, or /// the company's initials). pub asset_tag_prefix: String, - /// `[vault] key_file`: the TIN vault's key file (unset: the vault's - /// default, `ledger_vault::default_key_file`). - pub vault_key_file: Option, - /// `[books] parties`: suggest, require, or never mention each entry's + /// `policy.parties`: suggest, require, or never mention each entry's /// counterparty. pub parties: ledger_config::PartyPolicy, /// The company's time zone (an IANA name), if set: what "today" means. pub timezone: Option, - /// Whether these are the books' own settings (journaled) rather than a - /// file's. - pub journaled: bool, } impl Settings { - /// The vault key file in force: `[vault] key_file`, else the default - /// outside the books (`~/.config/wanderbooks/vault.key`). - pub fn key_file(&self) -> PathBuf { - self.vault_key_file - .clone() - .unwrap_or_else(ledger_vault::default_key_file) - } - /// The settings the books keep (`ledger_core::settings`), as the /// server uses them. pub fn from_books(books: &ledger_core::settings::Settings, currency: &str) -> Self { @@ -93,7 +58,6 @@ impl Settings { Settings { policy: ledger_config::codec::receipt_policy(books), asset_tag_prefix: b.asset_tag_prefix(), - vault_key_file: None, parties: b.parties, timezone: b.timezone.clone(), roles: CashRoles { @@ -102,7 +66,6 @@ impl Settings { investing: b.investing_accounts, }, company: b.company, - journaled: true, } } @@ -116,179 +79,24 @@ impl Settings { } } -/// Where the settings come from. -#[derive(Clone, Debug)] -pub struct Source { - /// The books' wanderbooks.toml. - pub books_toml: PathBuf, - /// Whether it must exist (named with --books-toml) or may be absent - /// (the default beside the books: then the defaults apply). - pub required: bool, - /// `config.json`, for its receipt policy (optional). - pub config_json: PathBuf, -} - -/// Both files' bytes (`None`: absent), to tell when either changed. -type Seen = (Option>, Option>); - -impl Source { - fn read(&self) -> Seen { - ( - std::fs::read(&self.books_toml).ok(), - std::fs::read(&self.config_json).ok(), - ) - } - - /// Load the settings now. - pub fn load(&self) -> Result { - let books = if self.required || self.books_toml.exists() { - ledger_config::load(&self.books_toml)? - } else { - ledger_config::BooksSettings::default() - }; - // config.json sits in the data dir, which no message names. - let policy = ledger_config::effective_receipt_policy(&self.config_json, &books) - .map_err(|e| e.replace(&self.config_json.display().to_string(), "config.json"))?; - Ok(Settings { - asset_tag_prefix: books.asset_tag_prefix(), - vault_key_file: books.vault_key_file.clone(), - parties: books.parties, - timezone: books.timezone.clone(), - journaled: false, - policy, - roles: CashRoles { - cash: books.cash_accounts, - financing: books.financing_accounts, - investing: books.investing_accounts, - }, - company: books.company, - }) - } -} - -/// The settings in force, and how they got there. +/// The settings in force, and the books' settings.set they came from. #[derive(Debug)] pub struct Live { current: Arc, - source: Option, - seen: Seen, - error: Option, - loads: u64, /// The seq of the books' last settings.set taken (INVARIANT 1). journal_seq: Option, - /// A vault key file given on the command line (INVARIANT 5). - vault_key_file: Option, - /// Whether the settings files may be read: false once the books keep - /// their own (INVARIANT 1). - read_files: bool, - /// A book in the data root: its settings are only ever its own - /// (INVARIANT 6). - own: bool, -} - -/// What books.settings reports about the files. -#[derive(Clone, Debug)] -pub struct Status { - /// The wanderbooks.toml read, if the settings come from files. - pub books_toml: Option, - /// Whether that file exists now. - pub books_toml_exists: bool, - /// Whether a config.json is read (it sits in the data dir, which is - /// never named). - pub config_json_present: bool, - /// Why the last change failed to load (the last good settings stay in - /// force), or `None`. - pub error: Option, - /// How many times the settings have loaded, the first included. - pub loads: u64, - /// Whether the settings are the books' own (journaled): then no file - /// is read. - pub journaled: bool, } impl Live { - /// Settings that never reload (tests, and servers built in code). - pub fn fixed(settings: Settings) -> Self { - Self { - current: Arc::new(settings), - source: None, - seen: (None, None), - error: None, - loads: 1, - journal_seq: None, - vault_key_file: None, - read_files: false, - own: false, - } - } - - /// For a book in the data root (INVARIANT 6): no settings file, now - /// or later; `sync` brings the books' settings in once they hold any. + /// For a book (INVARIANT 1): the defaults; `sync` brings the books' + /// settings in once they hold any. pub fn own() -> Self { Self { - current: Arc::new(Settings { - journaled: true, - ..Settings::default() - }), - source: None, - seen: (None, None), - error: None, - loads: 0, + current: Arc::new(Settings::default()), journal_seq: None, - vault_key_file: None, - read_files: false, - own: true, } } - /// Load from `source`, and reload from it whenever it changes. Fails - /// only on this first load: a server doesn't start on broken settings. - pub fn watching(source: Source) -> Result { - let seen = source.read(); - let settings = source.load()?; - Ok(Self { - current: Arc::new(settings), - source: Some(source), - seen, - error: None, - loads: 1, - journal_seq: None, - vault_key_file: None, - read_files: true, - own: false, - }) - } - - /// For books that keep their own settings: nothing is read from - /// `source` (INVARIANT 1), not even at start, so a broken settings - /// file beside them can't stop the server; `sync` brings the books' - /// settings in. - pub fn journaled(source: Source) -> Self { - Self { - current: Arc::new(Settings { - journaled: true, - ..Settings::default() - }), - source: Some(source), - seen: (None, None), - error: None, - loads: 0, - journal_seq: None, - vault_key_file: None, - read_files: false, - own: false, - } - } - - /// Use this vault key file whatever the settings say (INVARIANT 5). - pub fn with_vault_key_file(mut self, path: Option) -> Self { - self.vault_key_file = path; - if let Some(p) = &self.vault_key_file { - Arc::make_mut(&mut self.current).vault_key_file = Some(p.clone()); - } - self - } - /// Take the books' own settings when they hold any and they changed /// since the last look (INVARIANT 1). The server calls this whenever /// it holds the books. @@ -296,230 +104,34 @@ impl Live { if !books.is_journaled() || books.last_seq() == self.journal_seq { return; } - let mut s = Settings::from_books(books, currency); - s.vault_key_file = self.vault_key_file.clone(); - self.current = Arc::new(s); + self.current = Arc::new(Settings::from_books(books, currency)); self.journal_seq = books.last_seq(); - self.read_files = false; - self.error = None; - self.loads += 1; - } - - /// The settings files' texts as the server reads them now - /// (`wanderbooks.toml`, `config.json`; `None` for one absent), when the - /// settings come from files; `None` when the books keep their own or - /// the settings were built in code. - pub fn file_texts(&self) -> Option<(Option, Option)> { - let source = self.source.as_ref().filter(|_| self.read_files)?; - Some(( - std::fs::read_to_string(&source.books_toml).ok(), - std::fs::read_to_string(&source.config_json).ok(), - )) - } - - /// The vault key file given on the command line, if any. - pub fn vault_key_override(&self) -> Option { - self.vault_key_file.clone() - } - - /// Whether the settings in force are the books' own. - pub fn is_journaled(&self) -> bool { - self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files) } /// The settings in force. pub fn current(&self) -> Arc { self.current.clone() } - - /// Reload if a file changed since the last look: the server calls this - /// once at the start of every request, so the whole request sees one - /// version. - pub fn refresh(&mut self) { - if !self.read_files { - // The books keep their own settings (or the settings were - // built in code): no file is read (INVARIANT 1). - return; - } - if let Some(source) = &self.source { - let now = source.read(); - if now != self.seen { - self.seen = now; - match source.load() { - Ok(mut s) => { - if let Some(p) = &self.vault_key_file { - s.vault_key_file = Some(p.clone()); - } - self.current = Arc::new(s); - self.error = None; - self.loads += 1; - eprintln!("ledger-mcp: reloaded {}", source.books_toml.display()); - } - Err(e) => { - eprintln!( - "ledger-mcp: settings not reloaded (keeping the last good ones): {e}" - ); - self.error = Some(e); - } - } - } - } - } - - /// Change the settings in force (builders; never reloads them away - /// unless a watched file changes). - #[cfg(test)] - pub fn edit(&mut self, f: impl FnOnce(&mut Settings)) { - f(Arc::make_mut(&mut self.current)); - } - - /// Where the settings come from and whether the last change loaded. - pub fn status(&mut self) -> Status { - self.refresh(); - let journaled = self.is_journaled(); - Status { - books_toml: self - .source - .as_ref() - .filter(|_| !journaled) - .map(|s| s.books_toml.clone()), - books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()), - config_json_present: self.source.as_ref().is_some_and(|s| s.config_json.exists()), - error: self.error.clone(), - loads: self.loads, - journaled, - } - } } #[cfg(test)] mod tests { use super::*; - /// A test's dir, removed when the test ends, pass or panic. - struct TestDir(std::path::PathBuf); - - impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } - } - - impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - fn dir(tag: &str) -> TestDir { - let d = - std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - TestDir(d) - } - - fn source(d: &std::path::Path, required: bool) -> Source { - Source { - books_toml: d.join("wanderbooks.toml"), - required, - config_json: d.join("config.json"), - } - } - - /// INVARIANT 3: an absent default means the defaults, and the file - /// loads once it appears; an unchanged file is not reloaded. + /// INVARIANT 1: books that hold no settings keep the defaults; their + /// settings come in once per change, and an unchanged journal leaves + /// the settings in force as they are. #[test] - fn an_absent_default_is_the_defaults_until_it_appears() { - let d = dir("absent"); - let mut live = Live::watching(source(&d, false)).unwrap(); + fn books_take_their_own_settings_once_per_change() { + let mut live = Live::own(); assert_eq!(live.current().company.name, Company::default().name); - live.refresh(); - assert_eq!(live.status().loads, 1, "nothing changed"); - std::fs::write( - d.join("wanderbooks.toml"), - "[company]\nname = \"Late Co\"\n", - ) - .unwrap(); - live.refresh(); - assert_eq!(live.current().company.name, "Late Co"); - assert_eq!(live.status().loads, 2); - assert!(live.status().books_toml_exists); - } - - /// INVARIANT 3: a named file must exist to start; INVARIANT 2: the - /// first load fails on broken settings, a later one keeps the last. - #[test] - fn a_named_file_must_load_to_start() { - let d = dir("named"); - assert!(Live::watching(source(&d, true)).is_err()); - std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap(); - assert!(Live::watching(source(&d, true)).is_err()); - std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = \"One\"\n").unwrap(); - let mut live = Live::watching(source(&d, true)).unwrap(); - std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = 5\n").unwrap(); - live.refresh(); - assert_eq!(live.current().company.name, "One"); - assert!(live.status().error.is_some()); - } - - /// config.json sits in the data dir, which no tool result names: a - /// broken one is reported by name, never by its path. - #[test] - fn a_broken_config_json_never_names_the_data_dir() { - let d = dir("json-broken"); - std::fs::write(d.join("config.json"), "{ not json").unwrap(); - let err = Live::watching(source(&d, false)).unwrap_err(); - assert!(err.contains("config.json"), "{err}"); - assert!(!err.contains(d.to_str().unwrap()), "{err}"); - let status = Live::fixed(Settings::default()).status(); - assert!(!status.config_json_present); - } - - /// The receipt policy in config.json is watched too. - #[test] - fn the_config_json_policy_reloads() { - let d = dir("json"); - std::fs::write( - d.join("wanderbooks.toml"), - "[books]\nreceipt_threshold = \"25.00\"\n", - ) - .unwrap(); - let mut live = Live::watching(source(&d, true)).unwrap(); - let before = live.current().policy.rule("USD"); - std::fs::write( - d.join("config.json"), - r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, - ) - .unwrap(); - live.refresh(); - assert_ne!(live.current().policy.rule("USD"), before); - assert!(live.status().error.is_none(), "{:?}", live.status().error); - } - - /// Settings built in code never reload. - #[test] - fn fixed_settings_stay() { - let mut live = Live::fixed(Settings::default()); - live.refresh(); - let s = live.status(); - assert_eq!((s.loads, s.books_toml), (1, None)); - } - - /// Books that keep their own settings: no file is read, even a broken - /// one; their settings come in once per change (INVARIANT 1). - #[test] - fn journaled_books_take_their_own_settings_once_per_change() { - let d = dir("journaled"); - std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap(); - let mut live = Live::journaled(source(&d, true)); - assert!(live.current().journaled); - assert!(live.is_journaled()); - assert!(live.file_texts().is_none()); let mut books = ledger_core::settings::Settings::default(); + let before = live.current(); live.sync(&books, "USD"); - assert_eq!(live.status().loads, 0, "books without settings"); + assert!( + Arc::ptr_eq(&before, &live.current()), + "books without settings" + ); let src = ledger_core::settings::Source { decided_by: "Owner".into(), decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(), @@ -533,54 +145,14 @@ mod tests { books.apply(&[change("Kept Co")], &src, 7).unwrap(); live.sync(&books, "USD"); assert_eq!(live.current().company.name, "Kept Co"); - assert_eq!(live.status().loads, 1); + let taken = live.current(); live.sync(&books, "USD"); - assert_eq!(live.status().loads, 1, "unchanged: not taken again"); + assert!( + Arc::ptr_eq(&taken, &live.current()), + "unchanged: not taken again" + ); books.apply(&[change("Kept Co 2")], &src, 9).unwrap(); live.sync(&books, "USD"); assert_eq!(live.current().company.name, "Kept Co 2"); - assert_eq!(live.status().loads, 2); - assert!(live.current().journaled && live.is_journaled()); - } - - /// Settings built in code are the books' own once synced. - #[test] - fn fixed_settings_synced_are_journaled() { - let mut live = Live::fixed(Settings::default()); - assert!(!live.is_journaled()); - let mut books = ledger_core::settings::Settings::default(); - let src = ledger_core::settings::Source { - decided_by: "Owner".into(), - decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(), - citation: None, - note: None, - }; - books - .apply( - &[ledger_core::settings::Change { - key: "company.name".into(), - value: Some(serde_json::json!("Kept Co")), - }], - &src, - 3, - ) - .unwrap(); - live.sync(&books, "USD"); - assert!(live.is_journaled()); - } - - /// Books in the data root keep their own settings from the start - /// (INVARIANT 6): before they hold any, the settings in force say so - /// too, so no message sends anyone to a settings file; none is read. - #[test] - fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() { - let mut live = Live::own(); - assert!(live.current().journaled); - assert!(live.is_journaled()); - assert!(live.file_texts().is_none()); - live.sync(&ledger_core::settings::Settings::default(), "USD"); - assert!(live.current().journaled); - let s = live.status(); - assert!(s.journaled && s.books_toml.is_none(), "{s:?}"); } } diff --git a/crates/ledger-mcp/src/tools/access.rs b/crates/ledger-mcp/src/tools/access.rs index 2be2ddb..9389a95 100644 --- a/crates/ledger-mcp/src/tools/access.rs +++ b/crates/ledger-mcp/src/tools/access.rs @@ -66,9 +66,6 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; - if self.access.is_none() { - return Err("these books keep no access log: only books served from the books' private folder do".into()); - } let limit = p.limit.unwrap_or(DEFAULT_RECORDS); if limit == 0 || limit > MAX_RECORDS { return Err(format!("limit is 1 to {MAX_RECORDS}")); diff --git a/crates/ledger-mcp/src/tools/backup.rs b/crates/ledger-mcp/src/tools/backup.rs index ef5cb18..d4bce09 100644 --- a/crates/ledger-mcp/src/tools/backup.rs +++ b/crates/ledger-mcp/src/tools/backup.rs @@ -7,9 +7,7 @@ //! SOURCE: plan 3.7 ("books.backup {status|run} reports it": two tools, //! one read-only), D-30. //! INVARIANTS: -//! 1. Only books served from the data root are backed up: over stdio -//! both tools are hidden, and refuse. -//! 2. A status reads the backup record only; a run makes and pushes a +//! 1. A status reads the backup record only; a run makes and pushes a //! backup (backup.rs) and answers the status after it. //! ``` @@ -86,14 +84,6 @@ impl From for BackupOut { } } -impl BooksServer { - fn backups(&self) -> Result<&std::sync::Arc, String> { - self.backups.as_ref().ok_or_else(|| { - "these books aren't backed up by this server: only books kept in the books' own private folder are".to_string() - }) - } -} - #[tool_router(router = backup_router, vis = "pub(super)")] impl BooksServer { #[tool( @@ -103,7 +93,7 @@ impl BooksServer { annotations(read_only_hint = true, open_world_hint = false) )] async fn backup(&self, _params: Parameters) -> Result, String> { - Ok(Json(self.backups()?.status().await.into())) + Ok(Json(self.backups.status().await.into())) } #[tool( @@ -121,7 +111,11 @@ impl BooksServer { &self, params: Parameters, ) -> Result, String> { - let b = self.backups()?; - Ok(Json(b.run_with("asked", params.0.new_disk).await?.into())) + Ok(Json( + self.backups + .run_with("asked", params.0.new_disk) + .await? + .into(), + )) } } diff --git a/crates/ledger-mcp/src/tools/files.rs b/crates/ledger-mcp/src/tools/files.rs index 88baf72..45f203a 100644 --- a/crates/ledger-mcp/src/tools/files.rs +++ b/crates/ledger-mcp/src/tools/files.rs @@ -17,17 +17,17 @@ //! 2. begin_upload makes nothing for a receipt or document whose bytes //! are stored already: it names the attachment (not withdrawn) that //! holds them. -//! 3. Uploads exist only for books served from the data root (the -//! server's socket carries the bytes); over stdio, only inline. +//! 3. An upload is kept for the token that began it, and so is an +//! export: a call with no token (never one the books server lets +//! through) begins and takes none. //! 4. An upload is filed only by a tool of the purpose it was begun for //! (a receipt or document, a CSV import, a W-9); another tool's //! refusal leaves it for the right one. //! 5. Inline text longer than INLINE_MAX bytes' base64 is refused //! before it is decoded. //! 6. A file the books make for an agent (D-29) is handed over as an -//! export the caller's token fetches with `wanderbooks download` -//! (books served from the data root), or over stdio inline; never -//! by a path. +//! export the caller's token fetches with `wanderbooks download`; +//! never inline, never by a path. //! ``` use super::*; @@ -37,8 +37,10 @@ use serde::{Deserialize, Serialize}; /// The most bytes given inline (plan 2.1: 256 KiB). pub(crate) const INLINE_MAX: usize = 256 * 1024; -/// Why an upload is refused on a connection that has none (INVARIANT 3). -const STDIO_ONLY_INLINE: &str = "this connection takes files inline only (content_base64 with its sha256, at most 256 KiB): a larger one needs these books served by `wanderbooks mcp`; tell the human"; +/// Why a call with no token begins, takes or is handed no file +/// (INVARIANT 3). +const NO_CALLER: &str = + "this call came without a token for these books: a file is kept only for the token that asked"; /// What the bytes of a file a tool files came as (INVARIANT 1), and the /// upload purposes the tool takes (INVARIANT 4). @@ -146,7 +148,7 @@ fn plain_file_name(name: &str) -> String { impl BooksServer { /// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export - /// for the caller's token, or inline over stdio. + /// for the caller's token. pub(crate) fn hand_over( &self, book: &str, @@ -160,23 +162,9 @@ impl BooksServer { None => file_name.to_string(), Some(_) => plain_file_name(file_name), }; - let mut out = HandedFile { - file_name: file_name.clone(), - mime_type: mime.to_string(), - size_bytes: bytes.len() as u64, - sha256: ledger_store::journal::sha256_hex(&bytes), - download: None, - expires_at: None, - content_base64: None, - }; - let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else { - out.content_base64 = Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &bytes, - )); - return Ok(out); - }; - let kept = exports + let token = self.caller_token().ok_or(NO_CALLER)?; + let kept = self + .exports .put( &token, &file_name, @@ -185,14 +173,22 @@ impl BooksServer { chrono::Utc::now().timestamp(), ) .map_err(|e| e.to_string())?; - out.download = Some(format!( - "wanderbooks download --book {book}{} {} --to ", - token_flag(), - kept.id - )); - out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0) - .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); - Ok(out) + // Always a real time: a kept export expires EXPORT_TTL from now. + let expires_at = chrono::DateTime::from_timestamp(kept.expires, 0) + .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .ok_or("the file's expiry is past the calendar")?; + Ok(HandedFile { + download: format!( + "wanderbooks download --book {book}{} {} --to ", + token_flag(), + kept.id + ), + expires_at, + size_bytes: bytes.len() as u64, + sha256: ledger_store::journal::sha256_hex(&bytes), + file_name, + mime_type: mime.to_string(), + }) } /// The token of the call running on this thread (its id), if any. @@ -210,10 +206,9 @@ impl BooksServer { ), (Some(id), None) => { // INVARIANT 3. - let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else { - return Err(STDIO_ONLY_INLINE.into()); - }; - let got = uploads + let token = self.caller_token().ok_or(NO_CALLER)?; + let got = self + .uploads .take(id.trim(), &token, chrono::Utc::now().timestamp()) .map_err(|e| e.to_string())?; // INVARIANT 4: dropped, the claim gives it back. @@ -268,7 +263,7 @@ impl BooksServer { #[tool( name = "books.begin_upload", title = "Begin an upload", - description = "Send a file of up to 25 MiB (books served by `wanderbooks mcp`; elsewhere only inline) without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.", + description = "Send a file of up to 25 MiB without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.", annotations( read_only_hint = false, destructive_hint = false, @@ -313,11 +308,10 @@ impl BooksServer { existing_attachment_id: Some(id), })); } - let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else { - return Err(STDIO_ONLY_INLINE.into()); - }; + let token = self.caller_token().ok_or(NO_CALLER)?; let now = chrono::Utc::now(); - let grant = uploads + let grant = self + .uploads .grant( &token, &sha, @@ -328,9 +322,10 @@ impl BooksServer { now.timestamp(), ) .map_err(|e| e.to_string())?; + // Always a real time: a grant lapses UPLOAD_TTL from now. let expires = chrono::DateTime::from_timestamp(grant.expires, 0) .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) - .unwrap_or_default(); + .ok_or("the upload's expiry is past the calendar")?; Ok(Json(BeginUploadOut { command: Some(format!( "wanderbooks upload --book {book}{} {} -- ", diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index 5432447..7d64fbf 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -1,7 +1,7 @@ //! How the books are kept, through the server: `books.start`, settings -//! (`books.settings`, `books.set_setting`, `books.import_settings`, -//! `books.export_settings`), conventions, decisions, open items, drafts -//! from posting templates, and approvals (plan/served.md P1, 4.2, 4.9). +//! (`books.settings`, `books.set_setting`, `books.export_settings`), +//! conventions, decisions, open items, drafts from posting templates, +//! and approvals (plan/served.md P1, 4.2, 4.9). //! //! ```text //! GOAL: an agent anywhere learns how these books are kept from the books @@ -12,17 +12,14 @@ //! INVARIANTS: //! 1. Every change names who decided it and when (decided_by; //! decided_on defaults to today in the company's time zone). -//! 2. A settings file is never half moved in: books.set_setting refuses -//! while the books still read a settings file (import it first), and -//! books.import_settings is refused unless, after it, the server -//! reads every setting as it does now (company, receipt rule, cash -//! flow roles, asset tag prefix, parties, time zone, vault key). +//! 2. The books keep their own settings, and only books.set_setting +//! changes them (old books' settings file is read once, by +//! `wanderbooks books adopt`). //! 3. Identity values (the company's EIN) leave the server masked. //! 4. books.start is read-only and at most START_MAX bytes. //! ``` use super::*; -use crate::settings::Settings as Live; use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem}; use ledger_core::settings::{self as core_settings, Change, KEYS, Sensitivity, Source}; use schemars::JsonSchema; @@ -242,10 +239,6 @@ pub struct StartOut { pub head: HeadOut, /// needs_onboarding (no chart of accounts yet) or active. pub state: String, - /// Where the settings come from: "books" (journaled, the books' own) - /// or "file" (these books still read a settings file: import it with - /// books.import_settings). - pub settings_source: String, /// The rules in force. pub rules: RulesOut, /// Every setting set, with who set it. @@ -322,54 +315,12 @@ pub struct SetSettingOut { pub problems: Vec, } -/// Parameters for `books.import_settings`. -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ImportSettingsParams { - /// The settings file's text (wanderbooks.toml), as written. - pub toml_text: String, - /// The receipt policy file's text ({"receipt_policy": {...}}), when - /// books.settings says the server reads one (config_json_present): - /// rebuild it from books.settings' receipt rule and account lists. - #[serde(default)] - pub config_json_text: Option, - /// Without true: a preview of every change, note and unknown entry; - /// nothing is recorded. - #[serde(default)] - pub confirmed: bool, - /// Import even though the file holds entries the books don't know - /// (listed in the preview as unknown; they are not imported). - #[serde(default)] - pub skip_unknown: bool, - /// Who decided to move the settings in, and when. - #[serde(flatten)] - pub source: SourceIn, -} - -/// Result of `books.import_settings`. -#[derive(Debug, Serialize, JsonSchema)] -pub struct ImportSettingsOut { - /// "preview" or "imported". - pub status: String, - /// The changes that make the books' settings what the file says. - pub changes: Vec, - /// What the file says that isn't a books setting, and where it goes. - pub notes: Vec, - /// Entries the books don't know: not imported (confirm with - /// skip_unknown, or record them as conventions first). - pub unknown: Vec, - /// The file's comments, in order. They are not settings and are not - /// imported: record what matters in them (a value's source, a - /// discrepancy, something pending) as a decision, an open item, a - /// convention, or a setting's note, before or after confirming. - pub comments: Vec, -} - /// Result of `books.export_settings`. #[derive(Debug, Serialize, JsonSchema)] pub struct ExportSettingsOut { /// The settings as a settings file (TOML), each with who decided it /// as a comment; identity values masked. A copy: editing it changes - /// nothing until imported. + /// nothing. pub toml_text: String, } @@ -740,51 +691,12 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut { } } -/// Where two server readings of the settings differ (INVARIANT 2). -fn reads_differently(a: &Live, b: &Live) -> Vec<&'static str> { - let threshold = |s: &Live| ledger_config::decimal(s.policy.require_receipts_above.trim()); - [ - ("company", a.company.name == b.company.name), - ( - "fiscal year", - a.company.fiscal_year_start == b.company.fiscal_year_start, - ), - ("members", a.company.members == b.company.members), - ("basis", a.company.basis == b.company.basis), - ("tax form", a.company.tax_form == b.company.tax_form), - ("close target", a.company.close_into == b.company.close_into), - ( - "close sweep", - a.company.close_sweep == b.company.close_sweep, - ), - ("receipt threshold", threshold(a) == threshold(b)), - ( - "receipt required accounts", - a.policy.receipt_required_accounts == b.policy.receipt_required_accounts, - ), - ( - "receipt exempt accounts", - a.policy.receipt_exempt_accounts == b.policy.receipt_exempt_accounts, - ), - ("cash accounts", a.roles.cash == b.roles.cash), - ("financing accounts", a.roles.financing == b.roles.financing), - ("investing accounts", a.roles.investing == b.roles.investing), - ("asset tag prefix", a.tag_prefix() == b.tag_prefix()), - ("parties", a.parties == b.parties), - ("time zone", a.timezone == b.timezone), - ] - .into_iter() - .filter(|(_, same)| !same) - .map(|(n, _)| n) - .collect() -} - impl BooksServer { /// The books' `books.start` (INVARIANT 4). fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut { let cfg = self.settings(); let ledger = store.ledger(); - let kept = self.settings_as_kept(store); + let kept = store.ledger().settings().clone(); let books = &kept; let today = today(); let (fy_start, fy_end) = cfg.company.fiscal_year_containing(today); @@ -843,7 +755,6 @@ impl BooksServer { projects.sort_by_key(|a| (a.name.to_lowercase(), a.id)); let mut p_cut = projects.len().saturating_sub(START_PROJECTS); projects.truncate(START_PROJECTS); - let journaled = cfg.journaled; let focus = focus.unwrap_or("daily"); let step = |tool: &str, args: Option, why: &str| NextStepOut { tool: tool.into(), @@ -862,15 +773,6 @@ impl BooksServer { "Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.", )); } - // Books that read a settings file move it in before anything else - // changes a setting (books.set_setting refuses until then). - if !journaled { - next_steps.push(step( - "books.import_settings", - None, - "These books still read a settings file. Move it into the books (preview, show the human, confirm), so every agent sees one record and no file is read.", - )); - } if onboarding || focus == "onboarding" { next_steps.push(step( "books.handbook", @@ -893,8 +795,8 @@ impl BooksServer { } // The books' backups (backup.rs): none set up, or one behind or // failing. - if let Some(b) = &self.backups { - let st = b.standing(store); + { + let st = self.backups.standing(store); if st.note.is_some() { next_steps.push(step( "books.backup", @@ -958,12 +860,10 @@ impl BooksServer { "active" } .into(), - settings_source: if journaled { "books" } else { "file" }.into(), rules: RulesOut { evidence: cfg.policy.rule(self.currency()), parties: cfg.parties.key().into(), - // What the server enforces: the books' own setting (a - // settings file's is never enforced). + // What the server enforces: the books' own setting. approval_rules: match ledger.settings().approval_rules() { core_settings::ApprovalMode::Off => "off", core_settings::ApprovalMode::Enforce => "enforce", @@ -1047,19 +947,6 @@ impl BooksServer { out } - /// Refuse a settings change while the books still read a settings - /// file that sets something (INVARIANT 2): it would drop the rest. - fn ensure_settings_in_books(&self) -> Result<(), String> { - let status = self.live().status(); - if !status.journaled && (status.books_toml_exists || status.config_json_present) { - return Err( - "these books still read their settings file: move it in with books.import_settings first (preview, then confirm), so no setting is lost; then change settings here" - .into(), - ); - } - Ok(()) - } - /// The settings in force after a settings change: take the books'. fn sync_settings(&self, store: &Store) { self.live().sync(store.ledger().settings(), self.currency()); @@ -1090,7 +977,7 @@ impl BooksServer { #[tool( name = "books.set_setting", title = "Change settings", - description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing. Refused while the books still read a settings file: import it first (books.import_settings).", + description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing.", annotations( read_only_hint = false, destructive_hint = true, @@ -1104,7 +991,6 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let source = p.source.source()?; - self.ensure_settings_in_books()?; if p.changes.is_empty() { return Err("changes is empty: pass at least one {key, value}".into()); } @@ -1133,173 +1019,16 @@ impl BooksServer { })) } - #[tool( - name = "books.import_settings", - title = "Move a settings file into the books", - description = "Move a settings file (its TOML text, and the receipt policy file's text if the server reads one) into the books, after which no file is read. Without confirmed: a preview of every change, what the file says that isn't a books setting (notes), and entries the books don't know (unknown). Show it to the human; confirm with confirmed: true. Refused if afterwards any setting would read differently than now.", - annotations( - read_only_hint = false, - destructive_hint = true, - idempotent_hint = true, - open_world_hint = false - ) - )] - async fn import_settings( - &self, - params: Parameters, - ) -> Result, String> { - let p = params.0; - let source = p.source.source()?; - let mut store = self.lock()?; - let status = self.live().status(); - if let Some(e) = &status.error { - return Err(format!( - "the settings file doesn't load ({e}): fix it first, so the import moves in the settings in force" - )); - } - let current = store.ledger().settings().clone(); - let import = ledger_config::codec::import( - &p.toml_text, - p.config_json_text.as_deref(), - ¤t, - self.currency(), - )?; - let differing = |theirs: &[Change]| -> Vec { - let mut keys: Vec = import - .changes - .iter() - .filter(|c| !theirs.contains(c)) - .chain(theirs.iter().filter(|c| !import.changes.contains(c))) - .map(|c| c.key.clone()) - .collect(); - keys.sort(); - keys.dedup(); - keys - }; - if status.journaled { - // The books keep their own settings: an import may only confirm - // them (INVARIANT 2); changes go through books.set_setting. - let differs = differing(&[]); - if !differs.is_empty() { - return Err(format!( - "these books keep their own settings, and this text differs from them in {}: change settings with books.set_setting (the books record who decided each change)", - differs.join(", ") - )); - } - } else if let Some((toml, json)) = self.live().file_texts() { - // INVARIANT 2: the text is the file the server reads, exactly: - // its import is the server's own reading of its files. - let own = ledger_config::codec::import( - toml.as_deref().unwrap_or(""), - json.as_deref(), - ¤t, - self.currency(), - )?; - let differs = differing(&own.changes); - if !differs.is_empty() { - return Err(format!( - "this text isn't the settings the server reads now: it differs in {}. Pass the settings file books.settings names, whole, as toml_text{}", - differs.join(", "), - if json.is_some() { - ", and the receipt policy as config_json_text: {\"receipt_policy\": {...}} with books.settings' threshold and its required and exempt account lists" - } else { - "" - } - )); - } - } - // And, after it, the server reads every setting as now (the - // company, the receipt rule, the cash flow roles, the asset tag - // prefix, parties, the time zone, approvals). - let mut after = current.clone(); - after - .apply(&import.changes, &source, u64::MAX) - .map_err(bad)?; - let now = self.settings(); - let then = Live::from_books(&after, self.currency()); - let differs = reads_differently(&now, &then); - if !differs.is_empty() { - return Err(format!( - "after this import the server would read these settings differently than it does now: {}", - differs.join(", ") - )); - } - if !status.journaled && after.approval_rules() != ledger_core::settings::ApprovalMode::Off { - return Err( - "the server doesn't enforce approval rules for books that read a settings file: take approval_rules out of the file, import it, then set policy.approval_rules with books.set_setting" - .into(), - ); - } - // The vault key the server uses now, and after: the books never - // keep its path, so a file's [vault] key_file has to come from the - // command line from then on (settings INVARIANT 5). - let key_now = now.key_file(); - let key_then = self - .live() - .vault_key_override() - .unwrap_or_else(ledger_vault::default_key_file); - if key_now != key_then { - return Err( - "the settings file names the vault's key file ([vault] key_file), which the books don't keep: start the server with --vault-key-file naming that same file before moving the settings in, or the vault would stop opening" - .into(), - ); - } - let changes = |list: &[Change]| -> Vec { - list.iter() - .map(|c| ChangeOut { - key: c.key.clone(), - was: shown(&c.key, current.get(&c.key).map(|r| &r.value)), - value: shown(&c.key, c.value.as_ref()), - }) - .collect() - }; - // The preview refuses what the import would: the same checks. - store - .ledger() - .check_settings_adopting(&import.changes, &source, true) - .map_err(bad)?; - let mut out = ImportSettingsOut { - status: "preview".into(), - changes: changes(&import.changes), - notes: import.notes.clone(), - unknown: import.unknown.clone(), - comments: import.comments.clone(), - }; - if !p.confirmed { - return Ok(Json(out)); - } - if !import.unknown.is_empty() && !p.skip_unknown { - return Err(format!( - "the file holds entries the books don't know ({}): they would not be imported. Show them to the human; record any that matter as conventions (books.set_convention), then confirm with skip_unknown: true", - import.unknown.join(", ") - )); - } - let made = store - .adopt_settings(&import.changes, &source) - .map_err(bad)?; - self.sync_settings(&store); - out.status = "imported".into(); - out.changes = changes(&made); - Ok(Json(out)) - } - #[tool( name = "books.export_settings", title = "Settings as a file", - description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading or for other books: editing it changes nothing here until imported.", + description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading: editing it changes nothing here.", annotations(read_only_hint = true, open_world_hint = false) )] async fn export_settings(&self) -> Result, String> { let store = self.lock()?; + // The books' own: the defaults until the first is set. let s = store.ledger().settings(); - // Books in the data root read no file: theirs are the defaults - // until the first is set. - if !s.is_journaled() && !self.live().status().journaled { - return Err( - "these books still read their settings file, which is the copy: move it in with books.import_settings first" - .into(), - ); - } Ok(Json(ExportSettingsOut { toml_text: ledger_config::codec::export(s, self.currency()), })) @@ -1655,8 +1384,7 @@ pub(super) fn keys_out(books: &core_settings::Settings) -> Vec { identity: k.sensitivity == Sensitivity::Identity, value: r.map(|r| core_settings::display(k, &r.value, false)), set_by: r.map(|r| source_out(&r.source)), - // Seq 0: read from the settings file, not journaled. - seq: r.map(|r| r.seq).filter(|q| *q > 0), + seq: r.map(|r| r.seq), } }) .collect() diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 6a30cb7..89dc119 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -536,13 +536,13 @@ pub struct Vendor1099Params { #[serde(default)] pub csv: bool, /// The cash account codes payments are counted out of, e.g. - /// `["1000"]`. Defaults to `[books] cash_accounts`, as the CPA + /// `["1000"]`. Defaults to `books.cash_accounts`, as the CPA /// package's do: one or the other is required. #[serde(default)] pub cash_accounts: Vec, /// The financing liability codes (what the members advanced, loans) - /// payments are counted out of. Defaults to `[books] - /// financing_accounts`, as the CPA package's do. + /// payments are counted out of. Defaults to + /// `books.financing_accounts`, as the CPA package's do. #[serde(default)] pub financing_accounts: Vec, /// Count only the payments dated on or before this day of `year` @@ -782,13 +782,23 @@ pub struct ChartTemplateOut { /// Chart CSV (code,name,type,parent_code,tax_category) for /// `books.import_chart_csv`, after review. pub csv: String, - /// The settings that go with it, as settings-file TOML for - /// `books.import_settings`: tax form, owners, where period closes go. - pub settings_toml: String, + /// The settings that go with it, as changes for `books.set_setting` + /// (after review, with who decided): tax form, owners, where period + /// closes go. + pub settings: Vec, /// What to decide or know. pub notes: Vec, } +/// One setting a chart template goes with. +#[derive(Debug, Serialize, JsonSchema)] +pub struct TemplateSettingOut { + /// The key. + pub key: String, + /// Its value, as `books.set_setting` takes it. + pub value: serde_json::Value, +} + /// Parameters for `books.renumber_account`. #[derive(Debug, Deserialize, JsonSchema)] pub struct RenumberAccountParams { @@ -807,10 +817,9 @@ pub struct RenumberAccountOut { pub previous_code: String, /// The code it has now. pub code: String, - /// Settings that still name the old code (as books.set_setting keys, - /// or the settings file's labels for books whose settings are in a - /// file), and conventions whose template or approval rule names it: - /// change each (books.set_setting, books.set_convention). + /// Settings that still name the old code (as books.set_setting keys), + /// and conventions whose template or approval rule names it: change + /// each (books.set_setting, books.set_convention). pub config_mentions: Vec, } @@ -832,10 +841,6 @@ pub struct MemberSettingOut { /// Result of `books.settings`. #[derive(Debug, Serialize, JsonSchema)] pub struct SettingsOut { - /// Where the settings come from: "books" (the books keep them; change - /// them with books.set_setting) or "file" (a settings file these books - /// still read: move it in with books.import_settings). - pub source: String, /// The company's time zone (IANA), or null: the computer's. pub timezone: Option, /// Today, in that time zone. @@ -845,27 +850,14 @@ pub struct SettingsOut { /// Every setting the books know: what it takes, its value (null: /// unset, its default applies), and who set it. pub keys: Vec, - /// The wanderbooks.toml the server reads (null: the books keep their - /// own settings, or settings built in). - pub books_toml: Option, - /// Whether that file exists (absent: the defaults apply). - pub books_toml_exists: bool, - /// Whether the server reads a receipt policy from a configuration - /// file of its own. - pub config_json_present: bool, - /// Why the last edit failed to load, or null. While set, the settings - /// below are the last ones that loaded: fix the file. - pub reload_error: Option, - /// How many times the settings have loaded (the first included); it - /// goes up each time an edit loads. - pub loads: u64, - /// `[company] name`. + /// `company.name`. pub company: String, - /// `[company] fiscal_year_start`, "MM-DD". + /// `company.fiscal_year_start`, "MM-DD". pub fiscal_year_start: String, - /// The return filed (`tax_form`, or from `tax_classification`), or null. + /// The return filed (`company.tax_form`, or from + /// `company.tax_classification`), or null. pub tax_form: Option, - /// `[books] accounting_basis`. + /// `books.accounting_basis`. pub basis: String, /// The receipt rule in force (it shows a few account codes of each /// kind, shortened; the two lists below hold them all). @@ -877,22 +869,22 @@ pub struct SettingsOut { /// every line is in one needs none): the policy's whole list, as /// written. pub receipt_exempt_accounts: Vec, - /// `[books] parties`: "off", "suggest", or "require". + /// `policy.parties`: "off", "suggest", or "require". pub parties: String, - /// `[[company.members]]`. + /// `company.members`. pub members: Vec, - /// `[books] cash_accounts`. + /// `books.cash_accounts`. pub cash_accounts: Vec, - /// `[books] financing_accounts`. + /// `books.financing_accounts`. pub financing_accounts: Vec, - /// `[books] investing_accounts`. + /// `books.investing_accounts`. pub investing_accounts: Vec, - /// `[books] close_into`. + /// `books.close_into`. pub close_into: Option, - /// `[books] close_sweep`. + /// `books.close_sweep`. pub close_sweep: Vec, - /// Settings that disagree with the books, each saying what to change in - /// wanderbooks.toml (or the chart). Empty: nothing to fix. + /// Settings that disagree with the books, each saying what to change + /// (with books.set_setting, or in the chart). Empty: nothing to fix. pub problems: Vec, } @@ -1415,7 +1407,7 @@ pub struct DraftEntryParams { /// `vendor`, both must name the same vendor. pub party: Option, /// Why the entry has no counterparty (a transfer between the company's - /// own accounts, an accrual), when `[books] parties = "require"` and + /// own accounts, an accrual), when `policy.parties = "require"` and /// none applies. Not with `party`, `parties`, or `vendor`. pub no_party_reason: Option, /// Several parties, each with their share (as `books.set_entry_party` @@ -1605,7 +1597,7 @@ pub struct PostEntryOut { /// receipt threshold (same date, description, and debit accounts). /// Empty when no split is detected. pub split_detected: Vec, - /// `[books] parties`: "off", "suggest", or "require". + /// `policy.parties`: "off", "suggest", or "require". pub party_policy: String, /// The entry's counterparty, when it names exactly one. pub party_id: Option, @@ -2089,11 +2081,11 @@ pub struct DigestOut { /// lost; tell the human, and if they had asked for a change moments before, /// check it is there and redo it if not. pub journal_repairs: Vec, - /// Settings in wanderbooks.toml that need changing: an edit that - /// didn't load, or a setting that disagrees with the chart. Fix each - /// with the human, then check books.settings. + /// Settings that need changing: a setting that disagrees with the + /// chart. Fix each with the human (books.set_setting), then check + /// books.settings. pub settings_problems: Vec, - /// Cash and card accounts (`[books] cash_accounts`, and liabilities + /// Cash and card accounts (`books.cash_accounts`, and liabilities /// with a recorded statement) with activity not reconciled through the /// end of last month: record each statement and reconcile it. pub unreconciled_accounts: Vec, @@ -2167,17 +2159,17 @@ pub struct ExportParams { #[schemars(description = export_as_of_doc())] pub as_of: Option, /// Cash account codes for cash_flow, and for iif its BANK accounts. - /// Defaults to `[books] cash_accounts`; one or the other is required + /// Defaults to `books.cash_accounts`; one or the other is required /// for cash_flow. #[serde(default)] pub cash_accounts: Vec, /// Liability account codes treated as financing for cash_flow. - /// Defaults to `[books] financing_accounts`, else empty. + /// Defaults to `books.financing_accounts`, else empty. #[serde(default)] pub financing_accounts: Vec, /// Asset account codes treated as investing for cash_flow: loans the /// company makes, investments it buys (ASC 230-10-20). Defaults to - /// `[books] investing_accounts`. + /// `books.investing_accounts`. #[serde(default)] pub investing_accounts: Vec, } @@ -2261,8 +2253,8 @@ pub struct RegisterAssetParams { /// Depreciation expense account, id or code. pub expense_account: String, /// The asset tag for its label (letters, digits, - _ . /; at most 32). - /// Omit to generate the next one ("FA-0001", from `[books] - /// asset_tag_prefix` or the company's initials). + /// Omit to generate the next one ("FA-0001", from + /// `books.asset_tag_prefix` or the company's initials). pub tag: Option, /// The manufacturer's serial number, if known. pub serial: Option, @@ -2750,7 +2742,7 @@ pub struct DisposeAssetParams { /// receipt policy applies. pub receipt_attachment_ids: Option>, /// The buyer (party id or name), named on the disposal entry. Under - /// `[books] parties = "require"` a sale with proceeds needs one; a + /// `policy.parties = "require"` a sale with proceeds needs one; a /// retirement with no proceeds and no buyer is marked as having no /// counterparty. #[serde(default)] @@ -3266,7 +3258,7 @@ pub struct RemitPayrollTaxesParams { /// Cash account id or code the remittance comes from. pub cash_account: String, /// The tax agency paid (party id or name, e.g. the IRS), named on the - /// remittance entry. Required under `[books] parties = "require"`. + /// remittance entry. Required under `policy.parties = "require"`. #[serde(default)] pub agency: Option, } @@ -3640,17 +3632,17 @@ pub struct ExportCpaPackageParams { #[schemars(description = AS_OF_DOC)] pub as_of: Option, /// Cash account codes the cash flow statement reconciles to, e.g. - /// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other + /// `["1000"]`. Defaults to `books.cash_accounts`; one or the other /// is required. #[serde(default)] pub cash_accounts: Vec, - /// Liability account codes treated as financing. Defaults to `[books] - /// financing_accounts`, else empty. + /// Liability account codes treated as financing. Defaults to + /// `books.financing_accounts`, else empty. #[serde(default)] pub financing_accounts: Vec, /// Asset account codes treated as investing for cash_flow: loans the /// company makes, investments it buys (ASC 230-10-20). Defaults to - /// `[books] investing_accounts`. + /// `books.investing_accounts`. #[serde(default)] pub investing_accounts: Vec, /// Include every receipt for the year's entries (every current file @@ -3895,16 +3887,11 @@ pub struct HandedFile { pub size_bytes: u64, /// Its SHA-256, hex. pub sha256: String, - /// The command that saves it in a folder you name (run it; books - /// served by `wanderbooks mcp`). Kept 15 minutes. - #[serde(skip_serializing_if = "Option::is_none")] - pub download: Option, - /// When it lapses (UTC), with `download`. - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - /// Over stdio: its bytes, base64. - #[serde(skip_serializing_if = "Option::is_none")] - pub content_base64: Option, + /// The command that saves it in a folder you name (run it). Kept 15 + /// minutes. + pub download: String, + /// When it lapses (UTC). + pub expires_at: String, } /// Result of `books.get_w9`: the W-9 file, handed over. @@ -4026,7 +4013,7 @@ pub struct SetEntryPartyParams { /// shares that don't add up to what it moved. pub parties: Option>, /// With `party` null: mark the entry as having no counterparty, with - /// why (journaled as `entry.no_party`). Satisfies `[books] parties = + /// why (journaled as `entry.no_party`). Satisfies `policy.parties = /// "require"`. pub no_party_reason: Option, } @@ -4159,12 +4146,11 @@ pub struct ListPartiesOut { /// reversal counts on its original's side and nets it to zero. (Not the /// books' accounting method.) /// -/// Money is what left or reached the `[books] cash_accounts` (every asset +/// Money is what left or reached the `books.cash_accounts` (every asset /// account but receivables when none are set), the cards (the /// business-card payment methods' liabilities, `books.instruments`; while /// none is recorded, liabilities whose names say "card", not "gift -/// card"), and the `[books] -/// financing_accounts` (what the members +/// card"), and the `books.financing_accounts` (what the members /// advanced, loans): a card charge or a member paying for the company is /// paid to the payee then; cash that pays one of these down is paid to /// its creditor (a reimbursement to the members, a card payment to the diff --git a/crates/ledger-mcp/src/wire.rs b/crates/ledger-mcp/src/wire.rs deleted file mode 100644 index fc7377a..0000000 --- a/crates/ledger-mcp/src/wire.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the -//! transport doesn't do. -//! -//! ```text -//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says -//! it gets, even one rmcp would drop. -//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request, -//! id echoed, or null when it can't be detected; 5.1: -32700 Parse -//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches. -//! INVARIANTS: -//! 1. A line that isn't JSON gets {"code": -32700} with id null. -//! 2. JSON that isn't a request, notification, or response object (an -//! array, a scalar, an object without "jsonrpc": "2.0" and a string -//! "method" or a result/error) gets {"code": -32600}, with the -//! line's id when it has a valid one (string, number, null), else -//! null. -//! 3. Everything else goes to rmcp unchanged; a blank line is ignored. -//! 4. Output lines are written whole: replies made here never split a -//! line rmcp writes. -//! ``` - -use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; -use tokio::sync::mpsc; - -/// What to do with one line from the client. -#[derive(Debug, PartialEq)] -pub enum Line { - /// Hand it to rmcp. - Forward, - /// Ignore it (blank). - Skip, - /// Answer it here with this JSON-RPC response. - Reply(Value), -} - -/// Classify one line (INVARIANTS 1-3). -pub fn classify(line: &str) -> Line { - if line.trim().is_empty() { - return Line::Skip; - } - let v: Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")), - }; - let Some(obj) = v.as_object() else { - return Line::Reply(error(Value::Null, -32600, "Invalid Request")); - }; - let id = match obj.get("id") { - Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(), - _ => Value::Null, - }; - let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0"); - let id_ok = match obj.get("id") { - None => true, - Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null), - }; - let request = obj.get("method").is_some_and(Value::is_string); - let response = - obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error")); - if version_ok && id_ok && (request || response) { - Line::Forward - } else { - Line::Reply(error(id, -32600, "Invalid Request")) - } -} - -fn error(id: Value, code: i64, message: &str) -> Value { - json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}}) -} - -/// Run the wire between the real `input`/`output` and rmcp's ends: -/// returns (what rmcp reads, what rmcp writes) and the task that must be -/// awaited after rmcp finishes, so the last lines are flushed. -pub fn spawn( - input: R, - mut output: W, -) -> ( - tokio::io::DuplexStream, - tokio::io::DuplexStream, - tokio::task::JoinHandle<()>, -) -where - R: AsyncRead + Unpin + Send + 'static, - W: AsyncWrite + Unpin + Send + 'static, -{ - const BUF: usize = 1 << 20; - let (rmcp_in, mut feed) = tokio::io::duplex(BUF); - let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF); - let (tx, mut rx) = mpsc::unbounded_channel::(); - let replies = tx.clone(); - tokio::spawn(async move { - let mut lines = BufReader::new(input).lines(); - while let Ok(Some(line)) = lines.next_line().await { - match classify(&line) { - Line::Skip => {} - Line::Forward => { - if feed - .write_all(format!("{line}\n").as_bytes()) - .await - .is_err() - { - break; - } - } - Line::Reply(v) => { - let _ = replies.send(v.to_string()); - } - } - } - // Dropping `feed` ends rmcp's input. - }); - tokio::spawn(async move { - let mut lines = BufReader::new(from_rmcp).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if tx.send(line).is_err() { - break; - } - } - }); - let writer = tokio::spawn(async move { - while let Some(line) = rx.recv().await { - if output - .write_all(format!("{line}\n").as_bytes()) - .await - .is_err() - || output.flush().await.is_err() - { - break; - } - } - }); - (rmcp_in, rmcp_out, writer) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn code(l: Line) -> (i64, Value) { - match l { - Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()), - other => panic!("{other:?}"), - } - } - - #[test] - fn classifies_by_the_jsonrpc_spec() { - assert_eq!( - code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)), - (-32700, Value::Null) - ); - assert_eq!(code(classify("not json")), (-32700, Value::Null)); - assert_eq!(code(classify("[]")), (-32600, Value::Null)); - assert_eq!(code(classify("[1]")), (-32600, Value::Null)); - assert_eq!(code(classify("5")), (-32600, Value::Null)); - assert_eq!( - code(classify(r#"{"jsonrpc":"2.0","id":98}"#)), - (-32600, json!(98)) - ); - assert_eq!( - code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)), - (-32600, json!("a")) - ); - assert_eq!( - code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)), - (-32600, Value::Null) - ); - assert_eq!( - code(classify(r#"{"jsonrpc":"2.0","method":5}"#)), - (-32600, Value::Null) - ); - assert_eq!( - classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#), - Line::Forward - ); - assert_eq!( - classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), - Line::Forward - ); - assert_eq!( - classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#), - Line::Forward - ); - assert_eq!( - classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#), - Line::Forward - ); - assert_eq!( - code(classify( - r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"# - )), - (-32600, json!(7)) - ); - assert_eq!(classify(" "), Line::Skip); - } - - /// Counts the lines written to it; every flush fails. - struct FailingFlush(std::sync::Arc); - - impl AsyncWrite for FailingFlush { - fn poll_write( - self: std::pin::Pin<&mut Self>, - _: &mut std::task::Context<'_>, - buf: &[u8], - ) -> std::task::Poll> { - let lines = buf.iter().filter(|&&b| b == b'\n').count(); - self.0.fetch_add(lines, std::sync::atomic::Ordering::SeqCst); - std::task::Poll::Ready(Ok(buf.len())) - } - fn poll_flush( - self: std::pin::Pin<&mut Self>, - _: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Err(std::io::Error::other("closed"))) - } - fn poll_shutdown( - self: std::pin::Pin<&mut Self>, - _: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) - } - } - - /// A client that can't take a flushed reply is gone: the writer stops - /// after the first, rather than writing on into a dead channel. - #[tokio::test] - async fn a_failed_flush_stops_the_writer() { - let written = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let input = std::io::Cursor::new(b"not json\nnor this\n".to_vec()); - let (rmcp_in, rmcp_out, writer) = spawn(input, FailingFlush(written.clone())); - drop((rmcp_in, rmcp_out)); - writer.await.unwrap(); - assert_eq!(written.load(std::sync::atomic::Ordering::SeqCst), 1); - } -} diff --git a/crates/ledger-mcp/tests/startup.rs b/crates/ledger-mcp/tests/startup.rs deleted file mode 100644 index 4fc016a..0000000 --- a/crates/ledger-mcp/tests/startup.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! Starting the server: arguments, where the books and settings are found, -//! and what happens when another writer has the books. - -use std::io::{BufRead, BufReader, Write}; -use std::process::{Command, Stdio}; -use std::time::{Duration, Instant}; - -const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp"); - -/// A test's dir, removed when the test ends, pass or panic. -struct TestDir(std::path::PathBuf); - -impl std::ops::Deref for TestDir { - type Target = std::path::Path; - fn deref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::path::Path { - &self.0 - } -} - -impl AsRef for TestDir { - fn as_ref(&self) -> &std::ffi::OsStr { - self.0.as_os_str() - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn temp(tag: &str) -> TestDir { - let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - TestDir(d) -} - -/// Start the server, initialize, call one tool, and return its text -/// result (or the error text), then end the session. -fn one_call(cmd: &mut Command, tool: &str, args: serde_json::Value) -> String { - let mut child = cmd - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let mut stdin = child.stdin.take().unwrap(); - let mut out = BufReader::new(child.stdout.take().unwrap()); - let mut send = |v: serde_json::Value| writeln!(stdin, "{v}").unwrap(); - send( - serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ - "protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}), - ); - let mut line = String::new(); - out.read_line(&mut line).unwrap(); - send(serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}})); - send( - serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":args}}), - ); - line.clear(); - out.read_line(&mut line).unwrap(); - drop(stdin); - let _ = child.wait(); - line -} - -#[test] -fn help_names_the_options() { - let out = Command::new(BIN).arg("--help").output().unwrap(); - assert!(out.status.success()); - let text = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stderr); - for opt in ["--data-dir", "--books-toml", "--config", "--http"] { - assert!(text.contains(opt), "--help lacks {opt}: {text}"); - } -} - -/// With no --data-dir the books live in ~/.local/share/wanderbooks. -#[test] -fn the_default_data_dir_is_under_home() { - let home = temp("home"); - one_call( - Command::new(BIN).env("HOME", &home), - "books.chart", - serde_json::json!({}), - ); - assert!( - home.join(".local/share/wanderbooks/journal.jsonl").exists(), - "no books under {}", - home.display() - ); -} - -/// wanderbooks.toml beside the data folder is read without --books-toml: -/// its receipt threshold (0.00) refuses a 1.00 entry with no receipt, -/// which the default ($25.00) would post. -#[test] -fn the_settings_beside_the_books_are_read() { - let dir = temp("beside"); - std::fs::write( - dir.join("wanderbooks.toml"), - "[books]\nreceipt_threshold = \"0.00\"\n", - ) - .unwrap(); - let data = dir.join("books-data"); - let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap(); - let cash = store - .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) - .unwrap(); - let rent = store - .add_account( - "6000", - "Rent", - ledger_core::AccountType::Expense, - None, - false, - ) - .unwrap(); - let e = store - .create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "x") - .unwrap(); - let m = ledger_core::Money::new(rust_decimal::Decimal::ONE, ledger_core::Currency::USD); - store - .add_line(e, rent, ledger_core::Side::Debit, m) - .unwrap(); - store - .add_line(e, cash, ledger_core::Side::Credit, m) - .unwrap(); - drop(store); - let reply = one_call( - Command::new(BIN).arg("--data-dir").arg(&data), - "books.post_entry", - serde_json::json!({"entry_id": e, "confirmed": true}), - ); - assert!( - reply.contains("receipt_required"), - "posted without a receipt: {reply}" - ); -} - -/// The cash flow roles in wanderbooks.toml beside the data folder are -/// read without --books-toml: its cash account lets cash_flow run with no -/// cash_accounts of its own. -#[test] -fn the_cash_roles_beside_the_books_are_read() { - let dir = temp("roles"); - std::fs::write( - dir.join("wanderbooks.toml"), - "[books]\ncash_accounts = [\"1000\"]\n", - ) - .unwrap(); - let data = dir.join("books-data"); - let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap(); - store - .add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false) - .unwrap(); - drop(store); - let reply = one_call( - Command::new(BIN).arg("--data-dir").arg(&data), - "books.cash_flow", - serde_json::json!({"start": "2026-01-01", "end": "2026-03-31"}), - ); - assert!(reply.contains("\"isError\":false"), "{reply}"); -} - -/// A writer that keeps the books is a conflict: the server gives up with -/// an error (after its ten-second wait), never hangs. -#[test] -fn a_held_lock_is_refused_not_waited_on_forever() { - let dir = temp("locked"); - let _holder = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap(); - let start = Instant::now(); - let mut child = Command::new(BIN) - .arg("--data-dir") - .arg(&dir) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap(); - let status = loop { - if let Some(s) = child.try_wait().unwrap() { - break s; - } - if start.elapsed() > Duration::from_secs(60) { - let _ = child.kill(); - panic!("the server waited on a held lock for a minute"); - } - std::thread::sleep(Duration::from_millis(100)); - }; - assert!(!status.success()); - assert!( - start.elapsed() >= Duration::from_secs(9), - "gave up before waiting" - ); -} - -/// Books written by a newer wanderbooks: the server won't start on them, -/// and says to update it, not that the books are corrupt (rules.md B-27). -#[test] -fn newer_books_are_refused_as_newer_not_corrupt() { - let dir = temp("newer"); - drop(ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap()); - let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap(); - w.append( - "journal.format_set", - serde_json::json!({"format": 3, "features": []}), - ) - .unwrap(); - // The next line chains onto it, so its bytes are checked (B-27). - w.append( - "fx.rate_set", - serde_json::json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}), - ) - .unwrap(); - let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(); - let out = Command::new(BIN) - .arg("--data-dir") - .arg(&dir) - .stdin(Stdio::null()) - .output() - .unwrap(); - assert!(!out.status.success()); - let err = String::from_utf8_lossy(&out.stderr); - assert!( - err.contains("written by a newer version of wanderbooks"), - "{err}" - ); - assert!(err.contains("Update wanderbooks"), "{err}"); - assert!(!err.contains("corrupt"), "{err}"); - assert_eq!( - std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(), - journal - ); -} - -/// `--import-root` is gone (files come in as tool input, P2 slice 5): a -/// server started with it stops at once, as for any unknown argument, -/// and never serves as if it had read from that folder. -#[test] -fn the_import_root_flag_is_refused_as_unknown() { - let dir = temp("import-root"); - let out = Command::new(BIN) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--import-root") - .arg(dir.join("Downloads")) - .env("XDG_CONFIG_HOME", dir.join("config")) - .env("XDG_RUNTIME_DIR", dir.join("config")) - .stdin(Stdio::null()) - .output() - .unwrap(); - assert_eq!(out.status.code(), Some(2)); - let err = String::from_utf8_lossy(&out.stderr); - assert!(err.contains("unknown argument: --import-root"), "{err}"); - assert!(!err.contains("serving"), "{err}"); - assert!(!dir.join("books-data").exists(), "opened the books"); -} diff --git a/crates/ledger-package/tests/seams/books.rs b/crates/ledger-package/tests/seams/books.rs index bb33835..74606a9 100644 --- a/crates/ledger-package/tests/seams/books.rs +++ b/crates/ledger-package/tests/seams/books.rs @@ -1,6 +1,6 @@ //! The P3 seam review's books (shared by `ledger-package`'s //! `tests/p3_seams.rs`, `ledger-gui`'s `tests/p3_seams.rs` and -//! `ledger-mcp`'s `tests/p3_seams.rs`, which include this file): one year +//! `ledger-cli`'s `tests/p3_seams.rs`, which include this file): one year //! of fictional books for a two-member LLC, Ana Example and Ben Example, //! 50/50, built through the real [`Store`], with the settings the server //! and the viewer read written beside them (`wanderbooks.toml`). Every @@ -124,9 +124,10 @@ fn both(s: &mut Store, e: u64, ana: PartyId, ben: PartyId, each: &str) -> u64 { done(s, e) } -/// The settings the server and the viewer read for these books (beside -/// `books-data`, as an older books dir keeps them): [`company`] and the -/// account roles, the receipt threshold high so no entry needs a receipt. +/// The settings of these books, written beside `books-data` as an older +/// books dir keeps them (adoption reads them once; a test that opens the +/// books itself journals them): [`company`] and the account roles, the +/// receipt threshold high so no entry needs a receipt. pub const SETTINGS_TOML: &str = "[company] name = \"Example Trails LLC\" basis = \"Cash\" diff --git a/crates/ledger-store/src/root.rs b/crates/ledger-store/src/root.rs index 2d97a4e..0d0d9f9 100644 --- a/crates/ledger-store/src/root.rs +++ b/crates/ledger-store/src/root.rs @@ -272,7 +272,7 @@ pub fn default_root(home: &Path) -> PathBuf { /// Whether `path` is a data root or inside one (any folder above it, or /// it, holds the marker `setup` leaves). Books there belong to the books -/// server alone (plan 3.1: `dev-stdio` refuses them). +/// server alone (plan 3.1); the viewer reads no settings file beside them. pub fn inside_a_root(path: &Path) -> bool { // From here (a path that is already absolute stays as it is). let path = std::env::current_dir() diff --git a/docs/architecture.md b/docs/architecture.md index bb3df67..ed9baeb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,12 +114,13 @@ engine keeps them beside the ledger (`ledger_core::settings::Settings`, its replay and the viewer's `Snapshot` share one reading; `Ledger:: differs_from` and `verify` compare them, and the projection has a table for each (schema v12). `ledger-config` is the codec between them and a -`wanderbooks.toml` (`codec::import`, `codec::export`) and still reads the -file for books that predate P1 (the "file" source, until -`books.import_settings`). The server (`settings::Live`) takes the books' -settings whenever their last `settings.set` changes (on every lock of the -store) and then never reads a file; "today" is the company's time zone's, -set per request. Rule: `docs/spec/rules.md` B-29. +`wanderbooks.toml` (`codec::import`, `codec::export`): adoption (`wanderbooks +books adopt`) moves an older books dir's file in through it, once, and +`books.export_settings` writes the books' settings out as one. The books +are their settings' only source (D-44): the server (`settings::Live`) +takes them whenever their last `settings.set` changes (on every lock of +the store), the viewer reads them from the journal it shows, and neither +reads a file; "today" is the company's time zone's, set per request. Rule: `docs/spec/rules.md` B-29. ## The MCP server's store lock (`ledger-mcp`, P0) diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index ab140d5..f670987 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -53,18 +53,15 @@ one return, a fiscal year that doesn't move under a closed period). No setting holds nine digits that read as an SSN or EIN (bare, or printed 3-2-4 or 2-7) except `company.ein` and `company.duns`. -Books from before P1 hold no `settings.set` and still read the books dir's -`wanderbooks.toml` (default: next to `--data-dir`; `--books-toml PATH`) -and `config.json`'s `receipt_policy`, reloaded whenever either changes, as -before. `books.import_settings` moves them in once; from then on no file -is read, not even at start (a broken file beside the books can't stop the -server). A vault key file is never a books setting: `--vault-key-file PATH` -names it to the server and the viewer (it wins over a file's `[vault] -key_file`). +No settings file is ever read (plan D-44): books keep their own settings, +the defaults until the first is set. Books from before P1, which kept +theirs in a `wanderbooks.toml`, bring it in as they are adopted into the +data root (`wanderbooks books adopt`, plan 5.5). A vault key file is never +a books setting: a book in the data root keeps its own (`keys/legacy.key` +in its folder). -- `books.settings {}` → `{ source ("books" or "file"), timezone, today, +- `books.settings {}` → `{ timezone, today, approval_rules, keys: [{ key, doc, identity, value, set_by, seq }], - books_toml, books_toml_exists, config_json_present, reload_error, loads, company, fiscal_year_start, tax_form, basis, receipt_rule, receipt_required_accounts, receipt_exempt_accounts, parties, members, cash_accounts, financing_accounts, investing_accounts, close_into, @@ -73,39 +70,18 @@ key_file`). `problems`, settings that disagree with the chart, each saying what to change (conventions naming an account no longer in the chart among them); `books.digest` repeats them as `settings_problems`. Settings are - named by key for books that keep their own, as the file spells them for - books that still read one; for those, `keys` shows the file's values, - with no journal line. + named by key. - `books.set_setting { changes: [{ key, value }], decided_by, decided_on?, citation?, note? }` → `{ changed: [{ key, was, value }], problems }`: one or more changes, together or not at all (`value` null clears a key back to its default; a value already set changes nothing, idempotent; - destructive, since it can clear or switch off a setting). Refused while - the books still read a settings file (or `config.json`): import it first, - so no setting is lost. -- `books.import_settings { toml_text, config_json_text?, confirmed?, - skip_unknown?, decided_by, decided_on?, citation?, note? }` → `{ status - ("preview" or "imported"), changes, notes, unknown, comments }`: exactly - the changes - that make the books' settings what the file (and `config.json`'s - `receipt_policy`, which wins as it does for the server) says. `notes` are - what isn't a books setting and where it goes (`[books] data_dir`, `[vault] - key_file`, `[viewer]`, `base_currency`); `unknown` are entries the books - don't know, not imported (confirm with `skip_unknown`); `comments` are - the file's comments, in order, which are not settings and are not - imported (record what matters in them as a decision, open item, - convention or a setting's note). The text must be - the file the server reads, whole (its import is compared with the - server's own reading of its files, key by key), and the server must read - every setting afterwards as it does now; the vault key must then come - from `--vault-key-file` (on the viewer too). A file never turns approval - rules on. Books that keep their own settings take no import that changes - them (an identical one is no change). Without `confirmed`, a preview that - records nothing. An identity value exported masked imports as - "unchanged". + destructive, since it can clear or switch off a setting). - `books.export_settings {}` → `{ toml_text }`: the settings as a settings - file, each with who decided it as a comment, the EIN masked; a copy, and - importing it back changes nothing (`ledger_config::codec`). + file, each with who decided it as a comment, the EIN masked; a copy: + editing it changes nothing (`ledger_config::codec`). No tool reads a + settings file: old books bring theirs in once, whole, when the owner + adopts them (`wanderbooks books adopt`), and only `books.set_setting` + changes the books' settings after that. The accounts invoices, bills, payroll, and closes post to are not settings: they are control roles in the journal (rules.md B-23). @@ -128,7 +104,7 @@ settings-and-conventions section says how to use them. - `books.start { focus? }` (read-only, at most 8 KB) → `{ book: { name, timezone, today, fiscal_year, base_currency, basis }, head: { seq, hash }, - state ("needs_onboarding" or "active"), settings_source, rules: { + state ("needs_onboarding" or "active"), rules: { evidence, parties, approval_rules }, settings_summary: [{ key, value, set_by, seq }], conventions: [{ id, kind, title, summary }], open_items: [{ id, title, due }], projects: [{ id, name, status, kind? }], @@ -144,8 +120,7 @@ settings-and-conventions section says how to use them. to fit say so in `truncated` ("conventions: 4 more (books.conventions)"). `projects` lists the active and paused projects by name, at most 20 ("projects: N more (books.projects)" past that). - `next_steps`, in order: the format upgrade's (when waiting); - `books.import_settings` (books that still read a settings file); the + `next_steps`, in order: the format upgrade's (when waiting); the onboarding step (`books.handbook {section: "contract"}`, when the chart is empty or `focus` is `onboarding`); `books.open_items` (an item due within 30 days or overdue); `books.backup` (none set up, or one stale, @@ -224,20 +199,12 @@ as it is made. ## Transports -- **stdio** (default): `ledger-mcp --data-dir PATH`. For Claude Code, - Claude Desktop and other local agents. No auth: the OS user boundary is - the only boundary. -- **Streamable HTTP**: `ledger-mcp --data-dir PATH --http 127.0.0.1:8080`. - Serves MCP at `http://127.0.0.1:8080/mcp` via rmcp's tower integration. - **There is no authentication on this transport today**: no token and - no Origin check. The only guard is rmcp's default `Host` check, which - accepts `localhost`, `127.0.0.1` and `::1`. Any local process that can - reach the port can read and change the books. Bind it to loopback only; - binding a LAN, tailnet or public interface is an explicit human - decision, never a default. Auth, scopes and Origin validation arrive - with the served books (`plan/served.md`, P2). +Books are served one way (plan D-44): by the books server, which agents +reach through the bridge. + - **Served books** (`wanderbooks server`, started by the host; an agent - connects with `wanderbooks mcp --book ID`, a stdio bridge): every book + connects with `wanderbooks mcp --book ID`, the bridge: stdio to the + agent, the server's socket upstream): every book in the data root, on a socket in the root's private `run/` folder that only this account can connect to. Every request carries a bearer token live for that book (refused 401 without one, 403 with another book's). @@ -305,8 +272,9 @@ as it is made. The MCP `initialize` response carries an `instructions` string, the **connect text** (`ledger-mcp/src/handbook.rs`), composed in `get_info` -from the settings in force when the client connects (the settings are -re-read first, so a client connecting after an edit is told the new rule). +from the settings in force when the client connects (the books' settings +are taken first, so a client connecting after a change is told the new +rule). It is short on purpose: clients cut long instructions (one cut the old text at 4,096 characters), so it is at most **1,400 characters**, and its first paragraph, at most **512**, stands alone: the entry point @@ -327,9 +295,7 @@ the books); that the invoice, bill, payment and payroll tools post at once, unchecked (in those words, naming no tool); TINs only into the vault; show the numbers, and after posting check `books.digest` and offer it to the human; and where settings live: in the -books, changed only through the tools, or, for books that still read a -settings file, that file, the one local file the agent may edit until -`books.import_settings` moves it in. When a long rule (many +books, changed only through the tools. When a long rule (many accounts, a huge threshold, a long name) would push it past 1,400 characters, it drops the tracing hint first (the handbook's tracing-figures section has it), then the reasons in parentheses, then the @@ -368,17 +334,15 @@ path, the data dir or its flag, the old import-root flag, a file of the books (`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull request, a repo doc (`docs/`, `.md`), or tells the agent to read the books' files; no tool title or description, connect text or handbook -section names `wanderbooks.toml` (the input and output schemas name it by -design, for `books.settings`' `books_toml`) (`tool_text_lint`). A rule set by law that the books +section names `wanderbooks.toml` (`tool_text_lint`). A rule set by law that the books don't know for a year names its official source (the statute or publication, with its address) and says to tell the human. Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool -that changes anything also has a `destructiveHint`, true for these 29: -`books.post_entry`, `books.reverse_entry`, `books.import_settings`, -`books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, +that changes anything also has a `destructiveHint`, true for these 28: +`books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, `books.renumber_account`, @@ -388,11 +352,10 @@ that changes anything also has a `destructiveHint`, true for these 29: `books.set_tin` (both overwrite a vault record), `books.purge_tin`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.run_payroll`, `books.remit_payroll_taxes`; and an -`idempotentHint` on the eleven that a repeat with the same arguments leaves +`idempotentHint` on the ten that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, -`books.set_party_roles`, `books.set_setting`, `books.import_settings`, -`books.set_vendor_1099`, `books.set_entry_instrument`, +`books.set_party_roles`, `books.set_setting`, `books.set_vendor_1099`, `books.set_entry_instrument`, `books.update_project` and `books.back_up_now` (a setter such as `books.update_party`, `books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin` @@ -407,11 +370,12 @@ ones with TINs, so they are not read-only. No tool carries `requiresUserInteraction` (owner decision 2026-10-16: no added prompts). `annotations_complete` pins all of this. The read-only hint is true in behaviour: `spec_mcp` checks, on every call, -that a read-only tool leaves the journal, the contents of every file under -the data dir (the database by every row of every table; SQLite's -wal and --shm files, which move pages and hold read marks on their own, aside), -the vault and the settings file as they were -(`read_only_tools_change_nothing`). `books.show` is read-only though it +that a read-only tool leaves the journal and the contents of every file of +the books (their folder, its vault, the database by every row of every +table; SQLite's -wal and -shm files, which move pages and hold read marks +on their own, aside, and what every call writes by design: the access +log, the viewer's side channel, the books' own backup, files handed over) +as they were (`read_only_tools_change_nothing`). `books.show` is read-only though it writes the viewer's request into the books' `ops/` folder: that folder is a side channel, never journaled, and moves only the view. @@ -927,12 +891,11 @@ through the policy. `error` or `refused` (anything else is refused). `total` counts the whole log; `more` says older records match too. The log is read whole and its chain checked first: a damaged log is reported, never shown in - part. Books served over stdio keep no access log: the tool is not - offered there (and is refused if called). + part. ## Tools — backups -Books served from the data root back themselves up (plan 3.7, D-30, +Books back themselves up (plan 3.7, D-30, D-31): each keeps `archive.git`, a bare repository whose commits are its journal, stored files, vault, keys and access log as they were (one commit per backup, made under the store's lock, its message @@ -968,10 +931,9 @@ minutes, then tried again, written to or not; the other targets don't wait for it. Every commit, push and drill is a record in the book's `ops/backup.jsonl` (a chain log; a backup never moves the books' head), each target's records -keyed by the target itself. Setting `backup.*` (books.set_setting, or -books.import_settings with a `[backup]` table) needs books:admin, as +keyed by the target itself. Setting `backup.*` (books.set_setting) needs +books:admin, as backing up does: a backup is the whole book, its vault and key included. -Over stdio neither tool is offered. - `books.backup {}` (books:read) → `{ head_seq, targets: [{ target: "repository" | "folder", at, last_ok_at, through_seq, stale, @@ -1175,7 +1137,7 @@ first entry's). the package's `as_of`; so `books.vendor_1099` given the package's `cash_accounts`, `financing_accounts` and `through` = `as_of` (and its `include_tins`) hands over the package's `1099-worksheet-.csv` - byte for byte (the tool names one with TINs `-with-tins`; checked by `ledger-mcp` `oracle_mcp.rs` + byte for byte (the tool names one with TINs `-with-tins`; checked by `ledger-cli` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`). ## Tools — parties @@ -1420,8 +1382,8 @@ and the CPA package carries them. missing. - **Never out:** no tool output, error, or log holds more than a TIN's last four, except `books.vendor_1099 { include_tins: true }`; the CPA - package carries them only in its zip, handed over as an export (or - inline over stdio), never written to a path. + package carries them only in its zip, handed over as an export, never + written to a path. The tool arguments that take a TIN never echo it, even when refused. - **TIN facts:** any party (any role) may have `tin` facts: `{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`, @@ -1460,13 +1422,12 @@ and the CPA package carries them. - **Handed files** (D-29): no tool takes a path. A file the books make for an agent (`books.get_w9`'s form, `books.vendor_1099`'s `csv`, `books.export_cpa_package`'s zip) comes back as a `HandedFile { - file_name, mime_type, size_bytes, sha256, download?, expires_at?, - content_base64? }`. On books served by `wanderbooks mcp` it is an + file_name, mime_type, size_bytes, sha256, download, expires_at }`: an export, kept 15 minutes for the token that asked (at most 16 per token, swept like uploads), and `download` is the command that saves it in a folder the human names: `wanderbooks download --book B [--token NAME] ex_… --to ` (`GET /b/{book}/x/{export}`, - "Transports"). Over stdio it comes back inline as `content_base64`. + "Transports"). Its bytes never come back inline (D-44). A file with full TINs never goes into the books: the server keeps, beside the vault, the SHA-256 of every file it read or wrote with full TINs (the W-9 `books.set_w9` reads, recorded once the call has passed every check, just before the vault @@ -1544,9 +1505,7 @@ never pass through the agent), or `content_base64` with its `sha256` (at most 256 KiB decoded; longer text is refused before it is decoded). Never both, never neither. Inline bytes that aren't base64, or whose SHA-256 isn't the one given, are refused (guard `base64_corrupt`): send -the file as an upload. Uploads exist only for books served from the -data root (the server's socket carries the bytes); over stdio, only -inline, and the refusal says so and to tell the human. On the socket an +the file as an upload. An upload is kept for the token that began it. An MCP request is at most 1 MiB. A file's name and type are checked when it is filed (every filing tool, `books.begin_upload`, and the W-9's name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`, @@ -1617,8 +1576,7 @@ and no control or invisible (Unicode format) characters; a MIME type `download`, the command `wanderbooks download --book B --to ` (with `--token NAME` when the caller's token isn't the default `agent`; `GET /b/{book}/a/{attachment}`, "Transports"), which - saves a copy as a new file in that folder. Over stdio there is no - download: a larger file is refused. + saves a copy as a new file in that folder. - `books.file_document { category: "formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo" | "warranty" | "import_source" | "other", file_name?, mime_type?, upload_id? | @@ -1641,8 +1599,7 @@ posting. (account codes whose entries always need one, e.g. meals and travel) and `policy.evidence.exempt_accounts` (e.g. automated bank fees; an entry is exempt only when every line is in one). Changed with - `books.set_setting`; books from before P1 read `config.json`'s - `receipt_policy`. + `books.set_setting`. - **Preview.** Every `post_entry` preview includes the receipt checklist (`receipts: [...]`, `receipt_required: bool`, `receipt_missing: bool`, `split_detected: [...]`), so the human sees it before confirming. A @@ -1759,16 +1716,17 @@ posting. ## Tools — chart templates -- `books.chart_template { entity_type, owners? }` → `{ csv, settings_toml, +- `books.chart_template { entity_type, owners? }` → `{ csv, settings, notes }`: a starter chart for a sole proprietorship (incl. single-member LLC), partnership (incl. multi-member LLC), S corporation, or C corporation — the equity and owner-pay accounts that entity needs (owner's capital and draws; per-partner capital and distributions plus guaranteed payments; officer compensation and shareholder distributions; dividends and income tax expense), common income and expense accounts with tax - categories, and `settings_toml`, the settings that go with it (tax form, - members, where closes go) as settings-file TOML for - `books.import_settings`. A proposal: review with the human, then `books.import_chart_csv`. + categories, and `settings`, the settings that go with it (tax form, + members, where closes go) as `[{ key, value }]` changes for + `books.set_setting`. A proposal: review with the human, then + `books.import_chart_csv` and `books.set_setting`. ## Tools — tax categories @@ -1877,7 +1835,8 @@ Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros): view `meta`; no bytes) Resources exist so read-only clients (dashboards, the viewer, -auditors) never need tool credentials. Tested end-to-end over stdio. +auditors) never need tool credentials. Tested end to end through a real +books server (ledger-cli's tests). ## The agent operating contract diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index 89cd7be..2bb4609 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -304,8 +304,7 @@ damn CPA package." `books.export_cpa_package` (its K-1 worksheet, `k1-members.csv`, and its 1099 worksheet; `include_tins` defaults to true there) decrypt them. The package is handed over as a file (an export the asking token - fetches with `wanderbooks download`, or inline over stdio); no tool - takes a path. The MCP server + fetches with `wanderbooks download`); no tool takes a path. The MCP server never returns a TIN in any other tool, log, or error. Tests check that no other output contains a nine-digit run. - **Books without a vault** (another machine, books adopted without @@ -331,10 +330,11 @@ damn CPA package." is the one tool output with full TINs (with a warning; `csv: true` also hands the worksheet over as a CSV file); `books.export_cpa_package` carries them in its zip, handed over the - same way. A key file inside the books dir is - refused. `tests/spec_vault.rs` scans every tool response, the server's - log, and every file in the books dir outside `vault/` for the TINs in - any formatting. + same way. The key is the book's own (`keys/legacy.key` in its folder in + the data root). `tests/spec_vault.rs` (ledger-cli) scans every tool + response, every file handed over, the server's log, and every file in + the books' folder outside `vault/` (and the files handed over, kept for + their download) for the TINs in any formatting. ## Attachments diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 939a5e5..e1f1835 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -757,7 +757,7 @@ shapes), `prop_pure_entries_sections_are_movements`; regressions (`crates/ledger-core/tests/spec_cash_flow.rs`); the seam review's year of books (`ledger-package` `tests/p3_seams.rs`, its books `tests/seams/books.rs`; the viewer's `ledger-gui` `tests/p3_seams.rs`; -the tools' `ledger-mcp` `tests/p3_seams.rs`). +the tools' `ledger-cli` `tests/p3_seams.rs`). ## B-15 Depreciation: straight line, salvage never depreciated, partial months by days @@ -943,7 +943,7 @@ dates, `accumulated_posted` and its edges, `a_reinstated_run_counts_and_is_live`); `ledger-store` `tests/asset_tags.rs` (`runs_replay_reload_and_verify_catches_a_tampered_run`, `the_viewer_and_writer_agree_on_a_bad_depreciation_posted_payload`); -`ledger-mcp` `tests/spec_assets.rs` (`a_reversed_run_is_not_posted`, +`ledger-cli` `tests/spec_assets.rs` (`a_reversed_run_is_not_posted`, `list_assets_book_value_is_what_the_books_carry`). ## B-16 Prepaid expenses and deferred revenue are recognized over the period benefited or earned @@ -1003,8 +1003,8 @@ B-16 cites them (P3 depreciation review R7). `a_schedule_month_posted_again_waits_for_month_reposting_and_replays`, `daily_plans_pin_month_end_clamping`, `prop_daily_plan_sums_exactly_and_lands_on_month_ends`, -`prop_monthly_even_plan_is_unchanged`; `ledger-mcp` -`mcp_daily_schedule_over_stdio`. +`prop_monthly_even_plan_is_unchanged`; `ledger-cli` +`mcp_daily_schedule`. ## B-17 Platform payouts are recorded gross when the business is the principal @@ -1256,7 +1256,7 @@ bucket table is `kernel::payment_bucket` (Kani `payment_bucket_is_the_table`, (ledger-core `tests/form1099.rs`); `the_untagged_note_counts_only_money_out`, `the_other_receipts_note_says_what_each_entry_did` (ledger-package); the -MCP stdio tests; bookspec +MCP tests through a real books server (ledger-cli); bookspec `bookspec/examples/1099-*.toml`. ## B-20 Every transaction has supporting documents; records are kept @@ -1341,7 +1341,7 @@ the books' figure, never the schedule's (P3, defect 13). **Enforced:** `Ledger::set_asset_tag` / `check_asset_tag_for`, `assets::check_asset_tag`, `assets::check_serial`. **Checked:** `ledger-core` `tests/spec_asset_tags.rs`; `ledger-store` -`tests/asset_tags.rs`; `ledger-mcp` `tests/spec_assets.rs`. +`tests/asset_tags.rs`; `ledger-cli` `tests/spec_assets.rs`. ## B-25 Tax figures beside book value: the §723 basis, the depreciable basis, the §704(c) built-in gain @@ -1418,7 +1418,7 @@ non-negative, in cents, at most one quadrillion) and its replay pairs never assumed). **Checked:** `ledger-core` `tests/spec_asset_basis.rs`, `tests/kernel_bridge.rs` (`basis_figures_agree`), `tests/properties.rs` (`prop_basis_figures_laws`); `ledger-store` `tests/asset_tags.rs`; -`ledger-mcp` `tests/spec_assets.rs`. +`ledger-cli` `tests/spec_assets.rs`. ## B-26 Parties: one directory, no full TIN in the books @@ -1447,8 +1447,9 @@ plain rule: a nine-digit job or PO number is written by its last four, as everywhere else in the books. The full TIN, when the books need it for the 1099 worksheet, lives in the -TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key -file outside the books dir): `books.set_w9` `tin` writes it there and +TIN vault (`vault/` in the book's own folder in the data root, encrypted +under the book's own key, `keys/legacy.key` beside it): `books.set_w9` +`tin` writes it there and journals only the last four and the vault record's hash; the signed W-9 goes there too, never to the attachment store. Any party may have its TIN in the vault, whatever its roles: every member's (for their K-1) and @@ -1472,7 +1473,7 @@ don't count toward the three: `sources/unicode-tin-text.md`, Unicode worksheets carry every member's and payee's TIN by default) are never written to a path: no tool takes one (D-29, P2 slice 6). Each is handed over as an export the asking token fetches with `wanderbooks download` -(kept 15 minutes), or inline over stdio. (Until slice 6 they were +(kept 15 minutes), never inline (D-44). (Until slice 6 they were written only outside the books dir: DECISION, P0 review, 2026-10-16, so none could be filed back in as a receipt and read out again.) No file holding a full TIN is filed into the books @@ -1547,16 +1548,17 @@ W-9 facts agree), `set_entry_party`, `validate_payroll`; `ledger-core` `k1` (the K-1 worksheet); `ledger-vault` (Tin::new, the encrypted records, records_held, purge_records); `ledger-mcp` `books.set_w9`, `books.set_tin`, `books.get_w9`, `books.purge_tin`, `books.vendor_1099`, -`books.export_cpa_package` (open_vault refuses a key file in the books -dir; outside_books for files with a TIN; vault_tin checks the vault -against the books); `ledger-mcp` `tin_files` (the registry, the text +`books.export_cpa_package` (open_vault opens the book's own key; +vault_tin checks the vault against the books); `ledger-mcp` `tin_files` +(the registry, the text scan and its probe bound, on every filing tool; `known_tin_last4` gives the books' last fours). **Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store` -`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp` -`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log, -and every file in the books dir outside `vault/` scanned for the TINs in -any formatting), `tests/tin_files.rs` +`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-cli` +`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, every +file handed over, the server's log, and every file in the books' folder +outside `vault/` and the files kept for their download scanned for the +TINs in any formatting), `tests/tin_files.rs` (`a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`, `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`, `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`), and the @@ -1738,7 +1740,7 @@ ledger-gui `owner.rs` (the notice and the dialog). `an_old_reader_never_repairs_newer_books`, `a_blank_line_is_corrupt_for_the_viewer_and_the_writer`, `damaged_journals_are_still_corrupt`) and the unit tests in `format.rs`; -`ledger-gui` `tests/newer_journal.rs`; `ledger-mcp` `tests/startup.rs`; +`ledger-gui` `tests/newer_journal.rs`; `ledger-cli` `tests/startup.rs`; the `fuzz_store_replay` target (the writer and the viewer agree on newer versus bad). The upgrade: `ledger-store` `tests/format_upgrade.rs` (new books in this version's format, a change waiting for the upgrade, every @@ -1866,7 +1868,7 @@ replay pair), `calendar.rs`, the unit tests of `amortization_plan`, `tests/fixtures/old-books`, written by main 188c90ea, opens in the writer and the viewer and replays to exactly the figures main computed) and `every_figure_over_old_books_is_computed_or_refused`, `tests/bounds.rs`; -`ledger-mcp` `tests/hostile_args.rs` (every tool with generated hostile +`ledger-cli` `tests/hostile_args.rs` (every tool with generated hostile arguments and every resource with generated URIs: the server never stops, and the books reopen after; the inputs that once panicked, pinned; every tool over every old book) and `server.rs` @@ -1887,8 +1889,7 @@ kept in the journal like every other fact, each with who decided it and when (journal format 2: `settings.set`, `convention.set`, `convention.retired`, `decision.recorded`, `open_item.raised`, `open_item.resolved`, `entry.approved`). A settings file is only text to -import from or export to; books that keep their own settings never read -one. +import from or export to; no server reads one (D-44). - A setting is one of the known keys, checked by its key and kept in one spelling; a change is all or nothing, and the settings after it hold @@ -1905,9 +1906,9 @@ one. fiscal year, the cash flow roles, the members) present them by the settings in force; the fiscal year can't move once a period is closed under it (a changed tax year needs the IRS's consent, Form 1128). -- Moving a settings file in reads every setting exactly as the server read - the file: the text must be the file the server reads, and books that - keep their own settings take no import that changes them. +- Books keep their own settings, and only `books.set_setting` changes + them; old books bring their settings file in once, whole, as they are + adopted (`wanderbooks books adopt`), and nothing reads one after. - A setting key or convention kind this version doesn't know is a newer version's: the books are "newer", never "damaged" (B-27). - A posting template's lines split the amount exactly on each side, odd @@ -1933,8 +1934,9 @@ one. (format 2 declared before the first such line); `ledger_config::codec` (`import`, `export`); `Store::post_entry` and `approval_gate` (`ApprovalRequired`, `ApprovalNeedsDraft`), `check_renumber_account`; -`ledger-mcp` `books.import_settings` (the file the server reads, whole), -`books.post_entry` (`approval_required`). +`ledger-mcp` adoption (`local.rs` `settings_to_move`: a settings file, +whole, through `codec::import`), `books.post_entry` +(`approval_required`). **Checked:** `ledger-core` settings and conventions unit tests; `ledger-store` `tests/settings.rs` (`settings_replay_is_pure`, 256 cases per check, 2,000 by hand, with posts between records and every record @@ -1943,7 +1945,7 @@ checked to change no figure or entry; `approval_rules_off_by_default`, `a_newer_key_or_kind_is_newer`; `books_written_under_p1_open_to_their_record`, fixture `old-books/p1_record`); `ledger-config` `tests/codec_props.rs` -(`exports_round_trip`) and codec unit tests; `ledger-mcp` +(`exports_round_trip`) and codec unit tests; `ledger-cli` `tests/spec_kept.rs`; Lean `Step.record_changes_no_figure`, `Reachable.records`; fuzz `fuzz_settings_replay`. @@ -2008,8 +2010,8 @@ generated descriptions (`described`) and parameter docs; `ledger-gui` `Books::periods`, `default_period`, `fiscal_ytd`, `App::show`, `RegisterRange`. **Checked:** `ledger-core` `calendar.rs` unit tests and `tests/properties.rs` `prop_period_defaults_resolve`; `ledger-mcp` -`server.rs` `period_tools_describe_the_one_default`, `tests/mcp_stdio.rs` -`one_default_period_everywhere_defect_16` and +`server.rs` `period_tools_describe_the_one_default`; `ledger-cli` +`tests/mcp_journeys.rs` `one_default_period_everywhere_defect_16` and `the_default_period_never_shortens_the_iif_or_the_package`, `tests/oracle_mcp.rs` `show_sends_the_dates_it_names_defect_16`, @@ -2072,7 +2074,7 @@ books declare before their first rename, B-27); `books.rename_account` (scope (`Cmd::Rename`); `ledger-store` `tests/store.rs` (`rename_account_changes_only_the_name_defect_15`, `prop_rename_changes_no_figure`, `a_journaled_rename_replays_past_the_command_rules`), -`tests/format.rs`; `ledger-mcp` `tests/mcp_stdio.rs` +`tests/format.rs`; `ledger-cli` `tests/mcp_journeys.rs` (`rename_account_end_to_end_defect_15`), `tests/hostile_args.rs`; `ledger-core` `tests/spec_ledger.rs` (`a_new_name_is_one_trimmed_line_of_bounded_length`), `ledger-store` @@ -2135,7 +2137,7 @@ their first `settings.set` naming the key, B-27); `ledger-mcp` `books.add_accoun (Info under warn, Todo under refuse). **Checked:** `ledger-core` `tests/tax.rs`; `ledger-store` `tests/store.rs` (`prop_tax_policy_refuses_exactly`, `a_journaled_clear_replays_under_refuse`); -`ledger-mcp` `tests/spec_tax_policy.rs`; `ledger-gui` `tests/view_model.rs` +`ledger-cli` `tests/spec_tax_policy.rs`; `ledger-gui` `tests/view_model.rs` (`needs_attention_flags_every_uncategorized_pl_account_defect_8`); bookspec `examples/tax-category-required.toml`. @@ -2188,7 +2190,7 @@ fixed asset register, mid-year and at year end; `as_of_package_iif_stops_at_as_o `the_1099_worksheet_stops_at_as_of`, `one_list_of_cash_accounts_per_package`, `payments_to_a_vendor_come_from_the_packages_accounts`; the property -`iif_ties_to_the_trial_balance_as_of`, 2,000 cases); `ledger-mcp` +`iif_ties_to_the_trial_balance_as_of`, 2,000 cases); `ledger-cli` `oracle_mcp.rs` (`cpa_package_holds_the_year_and_its_evidence`: the package's CSV is the tool's for the same accounts and `through`); the bookspec examples `bookspec/examples/as-of-package-stops-at-as-of.toml` @@ -2349,10 +2351,10 @@ pinned); `tests/spec_import.rs` (`iif_docnum_is_the_entry_id`, `the_bookspec_iif_example_matches_the_engine`, and B-33's); the bookspec example `bookspec/examples/iif-quickbooks-journal-file.toml` (formats appendix: the file line by line and its changes, derived by hand); -`ledger-mcp` `oracle_mcp.rs` +`ledger-cli` `oracle_mcp.rs` (`iif_declares_accounts_by_role_and_balances_every_transaction`, `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance`), -`mcp_stdio.rs` (`mcp_import_export_over_stdio`, +`mcp_journeys.rs` (`mcp_import_export`, `the_default_period_never_shortens_the_iif_or_the_package`). ## B-35 A party's part of an entry, and a party's totals @@ -2582,7 +2584,7 @@ distributions declared or of depreciated property, the properties), Kani `reverse_target_is_an_involution`, bridge `party_target_agrees`; Lean `reverseTarget_involutive`, `capitalTarget_odd`, `partyTarget_odd`, `sumOver_movement_reversal`; `ledger-store` `tests/party_creditors.rs`; -`ledger-mcp` `tests/spec_party_totals.rs`; `ledger-gui` `tests/parties.rs`. +`ledger-cli` `tests/spec_party_totals.rs`; `ledger-gui` `tests/parties.rs`. ## B-36 The K-1 worksheet reports tax-basis capital beside book capital @@ -2835,7 +2837,7 @@ period_margin_pct, period_lines}`, `kernel::unrecovered_cost`; an invoice's or bill's page (each line's project). **Checked:** `ledger-core` `tests/spec_projects.rs` and `tests/spec_document_projects.rs`, `ledger-store` `tests/projects.rs` and -`tests/document_projects.rs`, `ledger-mcp` `tests/spec_projects.rs`, +`tests/document_projects.rs`, `ledger-cli` `tests/spec_projects.rs`, `ledger-gui` `tests/projects.rs`, `ledger-cli` `tests/projects_parity.rs` and `tests/format_upgrade.rs` (`books_p2_served_cut_over_whole`); bookspec `projects-net-is-its-tagged-lines`, diff --git a/docs/spec/sources/jsonrpc-2.0.md b/docs/spec/sources/jsonrpc-2.0.md index 95032ed..fb2ae16 100644 --- a/docs/spec/sources/jsonrpc-2.0.md +++ b/docs/spec/sources/jsonrpc-2.0.md @@ -1,7 +1,8 @@ # JSON-RPC 2.0 Specification (jsonrpc.org/specification) -Governs `ledger-mcp`'s wire protocol (MCP is JSON-RPC 2.0). Fetched -2026-10-14. +Governs the wire protocol of the books server (`ledger-mcp`) and of the +agents' bridge, which frames each message an agent sends as one line +(`ledger-host` bridge.rs) (MCP is JSON-RPC 2.0). Fetched 2026-10-14. **5 Response object** diff --git a/docs/spec/sources/mcp-tools.md b/docs/spec/sources/mcp-tools.md index 448dce3..9bfbfa4 100644 --- a/docs/spec/sources/mcp-tools.md +++ b/docs/spec/sources/mcp-tools.md @@ -42,6 +42,7 @@ docs/specification/2025-06-18/server/tools.mdx (MIT). Fetched 2026-10-14. > - Rate limit tool invocations > - Sanitize tool outputs -(A local stdio server for one bookkeeper: access control is the file -system's, and rate limiting has no one to limit; wanderbooks takes -"validate all tool inputs" and "sanitize tool outputs" as binding.) +(A local books server for one company's agents: access control is the +bearer tokens and their scopes on the data root's private sockets, and +rate limiting has no one to limit; wanderbooks takes "validate all tool +inputs" and "sanitize tool outputs" as binding.) diff --git a/docs/testing.md b/docs/testing.md index dc28e6f..b575787 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,7 +14,7 @@ promise. |---|---|---|---|---|---| | Spec-derived examples | written specs with sources (1) | `tests/spec_*.rs`, `invariants.rs`, `statements.rs`, `assets.rs`, ...; the store's `tests/spec_store.rs` (written blind, from its specs only) | `just check` | seconds | every change | | Regressions | the bug, named (1) | beside the code they cover, `*_regression`/named for the defect | `just check` | seconds | every change | -| Compatibility contracts | journals written under older rules must open, and replay to the figures their version computed (1) | `ledger-store/tests/store.rs` (`books_written_under_older_rules_still_open`; `books_an_earlier_version_wrote_open_to_its_figures` over `tests/fixtures/old-books`: books main 188c90ea wrote, with main's own figures in `expected.json`, in the writer and the viewer; `every_figure_over_old_books_is_computed_or_refused`); `ledger-mcp/tests/hostile_args.rs` `old_books_answer_every_tool`; `ledger-gui/tests/old_books.rs` | `just check` | ~30s | every change | +| Compatibility contracts | journals written under older rules must open, and replay to the figures their version computed (1) | `ledger-store/tests/store.rs` (`books_written_under_older_rules_still_open`; `books_an_earlier_version_wrote_open_to_its_figures` over `tests/fixtures/old-books`: books main 188c90ea wrote, with main's own figures in `expected.json`, in the writer and the viewer; `every_figure_over_old_books_is_computed_or_refused`); `ledger-cli/tests/hostile_args.rs` `old_books_answer_every_tool`; `ledger-gui/tests/old_books.rs` | `just check` | ~30s | every change | | Properties | laws over all inputs (2) | `properties.rs`, `spec_*.rs`, `kernel_bridge.rs` | `just check` | seconds | every change | | Metamorphic | scaling, reversal, ordering invariance (2) | `spec_reports.rs` | `just check` | seconds | every change | | Refusal law (store) | a refused command never reaches the journal, an accepted one always does; the live ledger equals its journal's replay in every part, and the books reopen to it (2, 3) | `store.rs` `refused_commands_never_reach_the_journal` | `just check` (`STORE_CASES=5000` deeper) | ~20s | every change | @@ -56,26 +56,26 @@ that would fail if it broke: | Law | Test | |---|---| -| Books from a newer wanderbooks are "newer", never "corrupt", from the writer, the viewer and the server; damage stays corruption (rules.md B-27) | `ledger-store` `tests/format.rs` (`old_reader_gets_newer_journal`, `damaged_journals_are_still_corrupt`); `ledger-gui` `tests/newer_journal.rs`; `ledger-mcp` `tests/startup.rs` | +| Books from a newer wanderbooks are "newer", never "corrupt", from the writer, the viewer and the server; damage stays corruption (rules.md B-27) | `ledger-store` `tests/format.rs` (`old_reader_gets_newer_journal`, `damaged_journals_are_still_corrupt`); `ledger-gui` `tests/newer_journal.rs`; `ledger-cli` `tests/startup.rs` | | A line the gate refuses is called newer only once the next line's `prev` checks its hash; a last line is "newer or damaged" | `a_damaged_line_that_looks_newer_is_corrupt_when_the_next_line_checks_it`, `an_unreadable_last_line_is_reported_as_newer_or_damaged`, `a_newer_line_the_next_line_chains_onto_is_newer_and_intact` (the message vouches for that line only) | | The envelope and its hash are frozen across formats: a next line in a newer format (a float, a string seq, keys of its own) settles by its `prev` alone, never as corrupt; a key beside the envelope is newer, never dropped, and still checked through; a refused line followed by an unreadable one is "newer or damaged" | `format.rs` `a_newer_format_frames_its_lines_its_own_way`, `an_unknown_envelope_key_is_newer_and_checked_through`, `a_refused_line_followed_by_a_garbled_line_is_unverified` | | The writer and the viewer read bytes alike: a line that isn't UTF-8 is corrupt to both; an older version never repairs (truncates) newer books | `a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer`, `an_old_reader_never_repairs_newer_books` | | A newer format is newer whatever its features look like; a blank line is damage to the viewer as to the writer | `format.rs` `a_newer_format_is_newer_whatever_its_features_look_like`; `a_blank_line_is_corrupt_for_the_viewer_and_the_writer` | | A request that panics stops the server: every later request, on every session, is refused | `server.rs` `a_request_that_panics_stops_the_server` | -| No input reaches a panic (each would stop the server for every session): every tool with arguments generated from its schema and made hostile (wrong types, u64::MAX, decimals of 30+ digits or absurd exponents, dates like 0000-00-00, 99999-12-31, +262142-12-31, 2026-02-30, periods like 999999-01, control and format characters, long strings), every resource URI generated; the inputs that once panicked, pinned; after each property the server restarts and the books reopen (a call a command accepts but replay refuses would brick them); every tool and resource over every old book (rules.md B-28) | `ledger-mcp` `tests/hostile_args.rs` `hostile_tool_arguments_never_stop_the_server` (with a floor: most calls reach a tool, some succeed), `hostile_resource_uris_never_stop_the_server`, `inputs_that_once_panicked_are_refused_or_answered`, `old_books_answer_every_tool`; `MCP_CASES` and `OLD_BOOKS_CASES` (default 3 per tool per book) set the cases | +| No input reaches a panic (each would stop the server for every session): every tool with arguments generated from its schema and made hostile (wrong types, u64::MAX, decimals of 30+ digits or absurd exponents, dates like 0000-00-00, 99999-12-31, +262142-12-31, 2026-02-30, periods like 999999-01, control and format characters, long strings), every resource URI generated; the inputs that once panicked, pinned; after each property the server restarts and the books reopen (a call a command accepts but replay refuses would brick them); every tool and resource over every old book (rules.md B-28) | `ledger-cli` `tests/hostile_args.rs` `hostile_tool_arguments_never_stop_the_server` (with a floor: most calls reach a tool, some succeed), `hostile_resource_uris_never_stop_the_server`, `inputs_that_once_panicked_are_refused_or_answered`, `old_books_answer_every_tool`; `MCP_CASES` and `OLD_BOOKS_CASES` (default 3 per tool per book) set the cases | | Dates are in 0001-01-01 through 9999-12-31 and amounts at most a line's, for every new command; replay accepts every line an earlier version accepted (each bound has its pair: the command refuses past it, the replayed old line is accepted); a figure derived past exact arithmetic or chrono's calendar is refused, never a panic; a recurring run makes at most 1,000 drafts (rules.md B-28) | `ledger-core` `tests/spec_bounds.rs`, `calendar.rs` tests, `plans_at_the_edges_are_computed_or_refused`, `intervals_reach_chronos_last_day`, `a_vendor_part_is_scaled_exactly_or_refused`, the `share_property` cases; `ledger-store` `tests/bounds.rs`, `tests/store.rs` (the old-books tests); `server.rs` `periods_are_a_year_and_a_month`, `dates_are_four_digit_years_and_real_days` | | Opening the books changes nothing until every complete line passes in memory (chain, gate, first line, replay); the viewer applies the writer's first-line rule (rules.md B-27) | `format.rs` `a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was`, `the_first_line_is_store_created_for_the_viewer_and_the_writer`; `fuzz_store_replay` (the first line varies) | | UTF-16 text without a byte-order mark is scanned like any text; a refused `books.set_w9` marks no file; a refused resource read never echoes a TIN | `tin_files.rs` (tests) `utf16_text_without_a_byte_order_mark_is_scanned_either_way_round`, `a_refused_set_w9_never_marks_its_file`, unit test `only_nul_interleaved_bytes_read_as_utf16_without_a_mark`; `spec_vault.rs` `resource_refusals_never_echo_a_tin` | | idempotentHint only where a repeat with the same arguments leaves the journal and the vault as they were; every tool's hints pinned in one table | `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated`; `annotations_complete` | -| books.settings lists every receipt-required and exempt account, uncut; a fresh company is "a new company"; the lint catches `just` however it ends and where the books are | `mcp_stdio.rs` `every_receipt_account_left_out_of_the_connect_text_is_in_books_settings`; `handbook.rs` `a_fresh_company_is_a_new_company`; `tool_text_lint_catches_helpers_and_locations` | -| A file with full TINs is never written to a path (handed over instead, D-29), and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_are_refused_when_given_back_to_be_filed`; `spec_vault.rs` `a_package_is_handed_over_and_never_written`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` | +| books.settings lists every receipt-required and exempt account, uncut; a fresh company is "a new company"; the lint catches `just` however it ends and where the books are | `mcp_journeys.rs` `every_receipt_account_left_out_of_the_connect_text_is_in_books_settings`; `handbook.rs` `a_fresh_company_is_a_new_company`; `tool_text_lint_catches_helpers_and_locations` | +| A file with full TINs is never written to a path (handed over instead, D-29), and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_journeys.rs` `tin_bearing_files_are_refused_when_given_back_to_be_filed`; `spec_vault.rs` `a_package_is_handed_over_and_never_written`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` | | Text filed into the books is refused when it holds a TIN the vault keeps (nine digits, 3-2-4 by hyphen, space or dot, 2-7 by hyphen or space, anywhere) or a stand-alone printed SSN or EIN (123-45-6789, 12-3456789), and only then: a bank export (routing numbers, long references, a date next to an amount) and a longer number holding an SSN shape go in; a locked vault leaves the printed shapes (rules.md B-26) | `tin_files.rs` (tests) `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`; unit tests `text_holding_a_vault_tin_is_refused_in_every_rendering`, `a_printed_ssn_or_ein_is_refused`, `a_longer_number_holding_a_tin_shape_passes`, `an_ordinary_bank_export_passes`, `binary_bytes_are_not_scanned` | | Filing is no TIN oracle: text with more than two TIN-shaped numbers ending in a known TIN's last four (the books' TIN and W-9 facts, then the vault's own) is refused before the vault is opened, byte for byte alike whether one is the vault's TIN or none is, the vault open or locked, by every filing tool; one or two such numbers are compared against the vault, one number written many ways counts once, and other last fours never count (rules.md B-26) | `tin_files.rs` (tests) `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`; unit tests `text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it` (the vault never opened for a probe), `one_or_two_candidates_are_compared_against_the_vault` | | The connect text fits its budgets, its lead stands alone, it states the receipt rule enforced (exact threshold in the base currency, required and exempt accounts, reversals) and the parties rule in force, and the company name can't break it (control and format characters) | `handbook.rs` `instructions_budget`, `a_typical_company_gets_the_whole_connect_text`, `the_company_name_is_one_plain_line`; `receipt_policy.rs` `the_rule_told_is_the_rule_enforced`, `a_long_or_odd_account_list_stays_short_and_plain`; a new session is told the settings in force: `a_new_session_is_told_the_settings_in_force` | | No text the agent is given (whole tool JSON, resources, connect text, handbook, every error template and refusal) points at files, git, GitHub, repo docs, flags or helper commands; descriptions fit 700 characters (listed exceptions, P5; and what git itself said of a failed backup, which `books.backup` passes on to the human as git said it, its paths hidden) | `tool_text_lint`, `tool_descriptions_fit_700_characters`; `ledger-core` `an_unknown_year_threshold_is_never_guessed` | -| Every tool is annotated (openWorldHint false on all but `books.back_up_now`), and the read-only hint is true in behaviour (journal, every data-dir file's contents but `ops/`, the viewer's side channel for `books.show`, which is not the books; the database's rows, the vault and the settings unchanged) | `annotations_complete`; `spec_mcp.rs` `read_only_tools_change_nothing` (checked on every call in `Mcp::tool`) | -| A bare (or empty-`since`) digest covers this month so far, on any day of it, as the server counts it (the stdio test reads the server's own month, and starts over if it turns) | `a_bare_digest_covers_this_month_so_far`; `mcp_stdio.rs` `digest_resource_bare_reads` | -| The handbook: every section, "all" in order, the schema names every id | `handbook_sections_render_the_rule_in_force`, `the_whole_handbook_is_every_section_in_order`, `mcp_stdio.rs` `handbook_tool_and_resources_over_stdio` | +| Every tool is annotated (openWorldHint false on all but `books.back_up_now`), and the read-only hint is true in behaviour (journal, every file of the books' folder but what every call writes by design: the access log, `ops/` (the viewer's side channel for `books.show`, which is not the books), the books' own backup and the files handed over; the database's rows and the vault unchanged) | `annotations_complete`; `spec_mcp.rs` `read_only_tools_change_nothing` (checked on every call in `Mcp::tool`) | +| A bare (or empty-`since`) digest covers this month so far, on any day of it, as the server counts it (the end-to-end test reads the server's own month, and starts over if it turns) | `a_bare_digest_covers_this_month_so_far`; `mcp_journeys.rs` `digest_resource_bare_reads` | +| The handbook: every section, "all" in order, the schema names every id | `handbook_sections_render_the_rule_in_force`, `the_whole_handbook_is_every_section_in_order`, `mcp_journeys.rs` `handbook_tool_and_resources` | ## P0 mutation testing (2026-10-16) @@ -105,9 +105,9 @@ config and 1 in the viewer. Each was killed by a test of the law it broke, removed by simpler code, or is equivalent: - **Killed (MCP).** A section's title is its heading - (`a_title_is_the_heading_text`; over stdio the handbook tool titles a + (`a_title_is_the_heading_text`; through the server the handbook tool titles a section by its heading and "all" as the whole handbook, - `handbook_tool_and_resources_over_stdio`). A `.jpg` or `.jpeg` W-9 + `handbook_tool_and_resources`). A `.jpg` or `.jpeg` W-9 was taken (`every_w9_file_type_is_taken`); P2 slice 5 judges the W-9 by its first bytes, not its name, so that mutant's code is gone (the test now checks each type by its bytes). The waker wakes: a request pending until another thread wakes it completes @@ -116,9 +116,9 @@ broke, removed by simpler code, or is equivalent: root dropped, never fatal; a folder swapped for a link or a pipe never walked through; a read failure naming its kind, never the path): P2 slice 5 removed `books.import_local_file` and the import roots, and - those tests with them. `resolved` follows 40 links, as the kernel - does, and no more - (`resolved_follows_forty_links_and_no_more`). A file is saved only to + those tests with them. `resolved` followed 40 links, as the kernel + does, and no more (`resolved_follows_forty_links_and_no_more`): it went + with ledger-mcp's `paths.rs`, and that test with it, in D-44. A file is saved only to a new file in a folder that exists, and a missing folder or a taken file is refused as such, never as a failed write (a CPA package test, and @@ -285,11 +285,11 @@ since `asset_long_life` alone takes three minutes a run): |---|---| | Replay is pure: after any sequence of settings, convention, decision, open-item, template and approval commands, valid or refused, the live store, the store reopened from its journal and the viewer's snapshot hold the same settings and conventions, and the store verifies (database row for row) | `ledger-store` `tests/settings.rs` `settings_replay_is_pure` (proptest, 512 cases per check; 2,000 by hand, release, passed) | | Settings need journal format 2: books this version made declared it as they were created, so a change journals only itself; a change to what is set journals nothing; books in format 1 refuse a change (nothing journaled) until their format upgrade, then make it after the one declaration; refused changes journal nothing | `a_change_needs_journal_format_2`, `refused_changes_journal_nothing` | -| Approval rules block nothing until enforced; then an entry a rule covers posts only once its approver approved it as it stands | `approval_rules_off_by_default`; `ledger-mcp` `spec_kept.rs` `approval_rules_through_the_server` | +| Approval rules block nothing until enforced; then an entry a rule covers posts only once its approver approved it as it stands | `approval_rules_off_by_default`; `ledger-cli` `spec_kept.rs` `approval_rules_through_the_server` | | A template drafts exactly, odd cents by largest remainder, its slots named by each draft, all or nothing | `templates_draft_exactly_or_not_at_all`, `template_slots_take_the_account_each_draft_names`, `templates_draft_through_the_server`, core `splits_are_exact` | | Books written under P1 open to the same record forever | `books_written_under_p1_open_to_their_record` (fixture `old-books/p1_record`, also in every old-books test of the store, the viewer and the server) | | A settings file imports exactly, reads as it did, and an export round-trips; a masked EIN never imports | `ledger-config` codec unit tests, `tests/codec_props.rs` `exports_round_trip` (512 cases), `ledger-cli` `the_toml_and_its_examples_import_and_round_trip` | -| Moving settings in: set refused before, the preview records nothing, the file never read after (not even to start), an import that reads differently or loses the vault key refused, the EIN only ever masked | `spec_kept.rs` `a_settings_file_moves_into_the_books`, `an_import_that_reads_differently_is_refused`, `an_import_needs_the_vault_key_on_the_command_line` | +| The books keep their settings: changed only with books.set_setting, the EIN only ever masked, read from the books after a restart; no tool imports a settings file (D-44: no settings file is read, books.import_settings is gone, and the file-moving tests went with it) | `spec_kept.rs` `settings_live_in_the_books_and_the_ein_leaves_masked`, `books_keeping_their_settings_take_no_settings_file` | | books.start fits in 8 KB and says what it cut | `start_fits_in_8_kb` | | Values are canonical; no full TIN but the company's EIN; settings hold together; replay takes shapes | core `settings::tests` | | Every new tool on the happy path, schema-checked; read-only tools change nothing; idempotent tools repeat as no-ops | `spec_mcp.rs` `inv1_*`, `read_only_tools_change_nothing`; `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated` | @@ -333,18 +333,19 @@ the law it broke: strings, an escaped quote), comments carried by an import, a masked EIN matching, not matching and into empty books, an empty list changing nothing, `differences` naming each field, the export's - provenance lines, `effective_receipt_policy` with and without - `config.json` (`tests/effective_policy.rs`). -- **MCP.** `Live::journaled` reads no file, `sync` reloads once per - change; the books://start, settings and conventions resources; a + provenance lines, the receipt policy with and without `config.json` + (`tests/effective_policy.rs`, on its text since D-44). +- **MCP.** `sync` takes the books' settings once per change + (`books_take_their_own_settings_once_per_change`); the books://start, settings and conventions resources; a renumber names exactly the conventions that name the old code. - **Viewer.** The Company page's cards (`tests/kept.rs`): live conventions and not retired ones, standing decisions and not superseded ones, open items and not resolved ones, who decided each and when, a late item saying so in words ("overdue since", added for this: colour alone told only the sighted), and the empty-profile copy - of books that keep their settings. `--vault-key-file` wins over the - settings and an unknown argument exits 2 (`tests/vault_key_flag.rs`). + of books that keep their settings. An unknown argument exits 2 + (`tests/command_line.rs`; until D-44 it also checked the vault key + flag, gone with the viewer's loose-books-folder mode). The first run's 12 timeouts were builds slowed by parallel work: the rerun caught them all. @@ -367,9 +368,10 @@ two-place share to two places changes nothing). ## P2 slice 1: one executable (2026-10-18) -`wanderbooks dev-stdio` and `wanderbooks gui` run the server and the +The development stdio mode (gone since D-44: books are served by +`wanderbooks server` alone) and `wanderbooks gui` ran the server and the viewer from the one executable (plan D-22); `tests/one_executable.rs` -drives both, and `just server-only` builds, clippies and tests the build +drove both, and `just server-only` builds, clippies and tests the build without the viewer and fails if it links a window library. Mutation on the diff, two jobs, the viewer left out (owner, 2026-10-18): the server's `run` against the server's tests, 14 mutants, 12 caught or unviable, the @@ -692,8 +694,8 @@ a close to retained earnings; it opens to main's figures in `books_an_earlier_version_wrote_open_to_its_figures`, R-2); `format.rs` `an_older_reader_stops_at_the_members_cumulative_feature`. -**MCP and viewer**: `ledger-mcp` `close_period_shows_each_members_running_split` -(`tests/mcp_stdio.rs`: the preview's `members` is the confirmed one; after +**MCP and viewer**: `ledger-cli` `close_period_shows_each_members_running_split` +(`tests/mcp_journeys.rs`: the preview's `members` is the confirmed one; after a restart, `list_periods`' `close_rule` and the statement of members' equity); `old_books_name_each_closes_rule_and_true_up_at_the_next_close` (the old-books fixture `x_member_closes` through the server: every @@ -824,7 +826,7 @@ cases: random books with reversals and closed months, random as_of; the file's entries and balances against the trial balance without the closes). -**End to end.** `ledger-mcp` `oracle_mcp.rs`: +**End to end.** `ledger-cli` `oracle_mcp.rs`: `iif_declares_accounts_by_role_and_balances_every_transaction` (as_of, DOCNUM the ids, the warnings exact: the fixture's line-broken memo and the engine's own "Depreciation — Laptop — 2025-03" memos, the alias, a @@ -832,7 +834,7 @@ range refused), `iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance` (review R5), `cpa_package_holds_the_year_and_its_evidence` (the IIF from the prior year's sale through as_of; a payment after as_of to the contractor left out of the 1099 worksheet; the note's count); -`mcp_stdio.rs` (a Chinese account name over stdio: ASCII text, CR LF, +`mcp_journeys.rs` (a Chinese account name through the server: ASCII text, CR LF, `warnings`); `spec_mcp.rs` (iif with as_of); `ledger-gui` `tests/exports.rs` (the README's IIF line names the as_of). @@ -1052,7 +1054,7 @@ the end. the CPA package's statement with both schedules (`the_cash_flow_carries_both_schedules`); the MCP tool and its exports field for field against the core (`cash_flow_exports_equal_the_core_statement`), - the CSV's warnings, the settings' roles and a member-paid expense over stdio + the CSV's warnings, the settings' roles and a member-paid expense through the server (`cash_flow_reads_its_account_roles_from_the_books_settings`), refusals naming the setting (`cash_flow_refuses_contradictory_roles`), the settings' problems from the same check; the viewer's statement as the @@ -1083,7 +1085,7 @@ Every fixture is fictional (Fernhill Labs LLC, its members and vendors); no mutation run (D-33): the targets are listed for P12 below. **Named regressions**, from the brief's reproductions: -- `ledger-mcp` `tests/oracle_mcp.rs`: `show_refuses_start_after_end_defect_15` +- `ledger-cli` `tests/oracle_mcp.rs`: `show_refuses_start_after_end_defect_15` (1.1.1-1.1.2: refused, nothing reaches the viewer), `show_refuses_a_derived_inverted_range_defect_15` (1.1.3: a start after today with no end, the message saying the end was defaulted), @@ -1092,7 +1094,7 @@ no mutation run (D-33): the targets are listed for P12 below. `shown` names), `show_refuses_options_its_target_ignores_defect_15`, and `show_writes_the_viewer_command_for_each_target` (resolved dates; a split never compared). -- `ledger-mcp` `tests/mcp_stdio.rs`: `one_default_period_everywhere_defect_16` +- `ledger-cli` `tests/mcp_journeys.rs`: `one_default_period_everywhere_defect_16` (a July fiscal year, a first entry the year before: P&L, by class, cash flow, the exported tax summary, members' equity, general ledger and journal (csv, json: the period's entries only) and the CPA package all @@ -1100,10 +1102,10 @@ no mutation run (D-33): the targets are listed for P12 below. gives today's fiscal year to date, the clock read before and after, R14), `rename_account_end_to_end_defect_15` (the chart, a past period's P&L, the IIF's full names, verify, two journal lines, refusals write nothing, - a restart keeps the name); `mcp_period_statements_over_stdio`'s old pin + a restart keeps the name); `mcp_period_statements`'s old pin (no start = the first entry) now pins the fiscal year (02-01) that holds the end. -- `ledger-mcp` `tests/spec_tax_policy.rs`: `add_account_warns_without_tax_category_defect_8` +- `ledger-cli` `tests/spec_tax_policy.rs`: `add_account_warns_without_tax_category_defect_8` (1.4.1), `require_tax_category_refuses_defect_8` (refused without, created with, the import refused whole, clearing refused, a bill's 6999 created and named in its warnings and the digest), @@ -1159,7 +1161,7 @@ parameter is the shared constant; "all time", "beginning of time" and "first entry" are gone from every tool and schema; the resources say their period) and `show_reads_each_reports_dates_as_the_viewer_does`; `handbook.rs` (the period rule rendered from the same constant); -`hostile_args.rs` `hostile_renames_ranges_and_categories_are_answered`. +`ledger-cli` `hostile_args.rs` `hostile_renames_ranges_and_categories_are_answered`. Test fixtures that gave two accounts one name under one parent (`ledger-core` `invariants.rs` and `spec_roles.rs`, the store's refusal @@ -1173,7 +1175,7 @@ uncategorized expense account is listed. before its fix (checked by breaking the code and watching it fail): - `ledger-core` `tests/tax.rs` `a_gain_on_business_property_is_figured_on_form_4797_on_every_return`, `ledger-import` `tests/spec_import.rs` `tax_summary_lists_form_4797_apart`, - `ledger-mcp` `tests/spec_tax_policy.rs` `a_gain_on_business_property_is_exported_apart`: + `ledger-cli` `tests/spec_tax_policy.rs` `a_gain_on_business_property_is_exported_apart`: a `form_4797_gain_loss` amount was ordinary income on every return (Schedule C included, whose instructions send it to Form 4797; on a partnership net section 1231 gain is Schedule K line 10), its Tax column @@ -1187,10 +1189,10 @@ before its fix (checked by breaking the code and watching it fail): - `ledger-gui` `tests/view_model.rs` `all_dates_holds_an_entry_dated_after_today`: "Show all dates" and "All time" ended today, leaving out entries dated after it. -- `ledger-mcp` `tests/oracle_mcp.rs` `show_refuses_options_its_target_ignores_defect_15` +- `ledger-cli` `tests/oracle_mcp.rs` `show_refuses_options_its_target_ignores_defect_15` (a split past 240 columns): `books.show` sent a split the viewer shows as one total. -- `ledger-mcp` `tests/mcp_stdio.rs` `rename_says_what_the_new_name_moves_defect_15`: +- `ledger-cli` `tests/mcp_journeys.rs` `rename_says_what_the_new_name_moves_defect_15`: the notes for the name-picked cash and card sets and conventions had no test of their own. - `ledger-store` `tests/store.rs` `rename_account_changes_only_the_name_defect_15` @@ -1200,7 +1202,7 @@ before its fix (checked by breaking the code and watching it fail): **Landing on main (2026-10-21),** beside odd cents, the IIF and the cash flow statement: -- `ledger-mcp` `tests/mcp_stdio.rs` `the_default_period_never_shortens_the_iif_or_the_package`: +- `ledger-cli` `tests/mcp_journeys.rs` `the_default_period_never_shortens_the_iif_or_the_package`: on a July fiscal year with entries before it, the general journal asked without dates is the fiscal year to date, while the IIF without dates holds every posted entry from the first through today under @@ -1287,8 +1289,8 @@ no mutation run here; what P12 must cover is at the end. | Decimal and i64 agree on party_target and reverse_target | `kernel_bridge.rs` `party_target_agrees` | the bridge | | Kani: the direction is the sign, the target is odd, reverse_target an involution | `proofs.rs` `party_target_direction_is_its_sign`, `party_target_is_odd`, `reverse_target_is_an_involution` | the kernel spec | | The journal: party.creditor_set one line each, under the feature the books declared when created; in books an older version wrote, refused (FormatUpgradePending, nothing journaled) until the format upgrade; refusals journal nothing; books without creditors never change, in either format; corrupt lines refused | `ledger-store/tests/party_creditors.rs` | the format gate | -| Over the MCP: get_entry's direction, cash, in_kind; party totals by basis, echoed, others refused; basis all equals members' equity; creditor_of round trips and a card fee isn't a payment; the post_entry preview's parts are what it posts | `ledger-mcp/tests/spec_party_totals.rs` | the tool docs | -| Defect 11 over the MCP: a member's TIN last four in the directory, a W-9's, none, after a purge | `ledger-mcp/tests/spec_vault.rs` `defect11_the_directory_shows_a_members_tin_last4` | B-26 | +| Over the MCP: get_entry's direction, cash, in_kind; party totals by basis, echoed, others refused; basis all equals members' equity; creditor_of round trips and a card fee isn't a payment; the post_entry preview's parts are what it posts | `ledger-cli/tests/spec_party_totals.rs` | the tool docs | +| Defect 11 over the MCP: a member's TIN last four in the directory, a W-9's, none, after a purge | `ledger-cli/tests/spec_vault.rs` `defect11_the_directory_shows_a_members_tin_last4` | B-26 | | The viewer shows the tools' numbers, labelled cash basis, with the in-kind line; its what-for reads a reversal as `parties::flow` does | `ledger-gui/tests/parties.rs` `the_party_page_shows_the_basis_and_in_kind`, `a_reversed_member_card_payment_is_nothing_on_the_issuers_page` | `activity_with`, `flow` | | bookspec | `bookspec/examples/p3-party-*.toml` | the same cases, by hand | @@ -1323,7 +1325,7 @@ mutation run (D-33); the list P12 must cover is at the end. | Law (rules.md B-19) | Tests | |---|---| | Undecided is not "not reportable" (defect 18) | `undecided_is_not_not_reportable` | -| "No 1099" is a decision with who and when; "none" refused (defect 23) | `a_vendor_marked_no_1099_is_not_undecided`, `the_cutovers_reasons_are_recorded_as_given`, `set_vendor_1099_says_what_a_decision_needs` (`ledger-mcp` `spec_1099.rs`) | +| "No 1099" is a decision with who and when; "none" refused (defect 23) | `a_vendor_marked_no_1099_is_not_undecided`, `the_cutovers_reasons_are_recorded_as_given`, `set_vendor_1099_says_what_a_decision_needs` (`ledger-cli` `spec_1099.rs`) | | A member's card is excluded, their check counts, unknown is undetermined (defect 12) | `a_members_personal_card_payment_is_not_a_1099_payment`, `payments_come_from_cash_and_financing_only` | | Card-account payments are listed, not dropped | `card_account_payments_are_listed_not_dropped` | | A bank payment counts unless tagged with a card (D-40) | `a_debit_card_payment_from_the_bank_is_excluded_when_tagged` | @@ -1342,9 +1344,9 @@ mutation run (D-33); the list P12 must cover is at the end. | Journal: lines written only under their features (refused, nothing journaled, in books an older version wrote until the format upgrade; never declared on the way); replay and projection exact; refusals and no-ops journal nothing; newer values newer; old books as they always meant | `ledger-store` `tests/form1099.rs` (`each_feature_waits_for_the_upgrade_and_is_never_declared_on_the_way`, `decisions_and_methods_replay_and_project`, `refusals_and_no_ops_journal_nothing`, `an_unknown_1099_value_is_newer_not_corrupt`, `books_written_before_decisions_read_as_they_always_meant`), unit tests in `ledger-store` `form1099.rs`, the op model (`refused_commands_never_reach_the_journal` with `SetVendor1099`, `SetW9Flag`, `AddInstrument`, `SetEntryInstrument`; an accepted command journals or changes nothing), `books_an_earlier_version_wrote_open_to_its_figures` (fixture `p2_vendor_1099`) | | Records change no entry (verification 13a) | `decision_and_method_records_change_no_entry` | | The quieter untagged note (R7) | `the_untagged_note_counts_only_money_out` (`ledger-package`) | -| One builder: the package CSV is the worksheet CSV for the package's accounts; README wording | `ledger-package` `tests/package.rs` (the zip's CSV equals `vendor_1099_csv` for the request's accounts, else the settings', through as_of: `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of`; the README line pinned); over the MCP, the package's CSV is `books.vendor_1099`'s for the same `cash_accounts`, `financing_accounts` and `through` (`ledger-mcp` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`) | +| One builder: the package CSV is the worksheet CSV for the package's accounts; README wording | `ledger-package` `tests/package.rs` (the zip's CSV equals `vendor_1099_csv` for the request's accounts, else the settings', through as_of: `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of`; the README line pinned); over the MCP, the package's CSV is `books.vendor_1099`'s for the same `cash_accounts`, `financing_accounts` and `through` (`ledger-cli` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`) | | The viewer counts as the server does (brief 1.4) | `the_viewer_counts_the_1099_as_the_server_does` (`ledger-gui` `tests/parties.rs`) | -| End to end, restarting between steps | `payment_methods_round_trip_and_classify_payments` (`spec_1099.rs`), `mcp_vendor_1099_over_stdio`, `spec_mcp.rs` (every tool exercised and schema-checked, `books.instruments` read-only) | +| End to end, restarting between steps | `payment_methods_round_trip_and_classify_payments` (`spec_1099.rs`), `mcp_vendor_1099`, `spec_mcp.rs` (every tool exercised and schema-checked, `books.instruments` read-only) | | bookspec | `bookspec/examples/1099-*.toml` | **For P12 (mutation targets, none run):** `ledger-core` `form1099.rs` @@ -1382,8 +1384,8 @@ must cover is at the end. | One rule for what a card is: the recorded business cards' liabilities, else (none recorded) liabilities named "card", not "gift"; never a member's card, a member's check or a platform; a debit card on the bank is none; money follows it | `ledger-core` `tests/spec_party_totals.rs` `a_card_is_a_recorded_business_card_else_a_liability_named_card` | B-35 item 5, `parties::card_accounts` INVARIANTS 1-4 | | Party totals and the 1099 worksheet never contradict: paid less received is the worksheet's paid + excluded_card + undetermined + not_reportable plus its other receipts, for a vendor named alone, whatever the methods, refunds and reversals | property `party_totals_are_the_1099s_buckets` (`ledger-core` `tests/form1099.rs`; `PROPTEST_CASES`, default 256; 2,000 run clean; dropping a bucket from the sum fails at once) | B-35 item 9, by hand | | The same, one year by hand: a member's check, a member's card, a member's payment of unknown method, a company card charge and a company check to one contractor, then the member reimbursed | `bookspec/examples/1099-and-party-totals-for-a-members-payment.toml` | B-35 item 9; Treas. Reg. §1.6041-1(e)(1), (a)(1)(iv) | -| The CPA package's 1099 worksheet is `books.vendor_1099`'s CSV for the package's accounts (the request's, else the settings', R18) through its as_of, byte for byte; without `through` the files differ; a `through` outside the year and an unknown account are refused | `ledger-mcp` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`; `ledger-package` `tests/package.rs` `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of` | B-33; mcp-interface.md | -| A feature is asked for before a command's first line: `books.update_party` with a field and the first creditor, in P2's books waiting for their format upgrade, is refused whole (nothing journaled, never the field without the creditor); once upgraded, as in books this version made, it journals the field, then the creditor, declaring nothing | `ledger-mcp` `tests/spec_party_totals.rs` `update_party_asks_for_the_creditor_feature_before_its_first_line` | B-27 | +| The CPA package's 1099 worksheet is `books.vendor_1099`'s CSV for the package's accounts (the request's, else the settings', R18) through its as_of, byte for byte; without `through` the files differ; a `through` outside the year and an unknown account are refused | `ledger-cli` `oracle_mcp.rs` `cpa_package_holds_the_year_and_its_evidence`; `ledger-package` `tests/package.rs` `payments_to_a_vendor_come_from_the_packages_accounts`, `one_list_of_cash_accounts_per_package`, `the_1099_worksheet_stops_at_as_of` | B-33; mcp-interface.md | +| A feature is asked for before a command's first line: `books.update_party` with a field and the first creditor, in P2's books waiting for their format upgrade, is refused whole (nothing journaled, never the field without the creditor); once upgraded, as in books this version made, it journals the field, then the creditor, declaring nothing | `ledger-cli` `tests/spec_party_totals.rs` `update_party_asks_for_the_creditor_feature_before_its_first_line` | B-27 | | One bookspec loader for every example, the parties' and the 1099s' with them: `setup.parties`, `setup.members` (`distributions_account`), `setup.payees` (a party's vendor record), `setup.status_changes`, `setup.instruments`, events' `parties` and `instrument`, and the queries `vendor_1099`, `k1`, `party_part`, `party_totals`, `party_tin_last4`; refused: a party or payment method not in the setup, a reversal naming parties, party totals without a basis, a 1099 line leaving a figure out, a wrong direction or status | `ledger-core` `tests/bookspec.rs` (`every_bookspec_example_holds`, `a_party_not_in_the_setup_is_refused`, `a_party_parts_direction_is_checked`, `a_reversal_naming_parties_is_refused`, `party_totals_without_a_basis_are_refused`, `a_vendor_1099_line_leaving_a_figure_out_is_refused`, `a_vendor_1099_status_is_checked`, `a_payment_method_not_in_the_setup_is_refused`) | bookspec/README.md | **For P12 (mutation targets, none run, D-33):** `parties::card_accounts` @@ -1406,9 +1408,9 @@ a bookspec example (`bookspec/examples/`, P13, D-34). |---|---| | Through a day of the life's final month, depreciation is prorated by days, never plugged (repro A: December 483.87, accumulated 11,483.87) | `ledger-core` `spec_assets_payroll.rs` `defect22_depreciation_through_mid_final_month_is_prorated_not_plugged`; `spec_asset_tags.rs` `register_as_of_a_day_before_a_same_month_disposal_is_prorated` | | A mid-month life's drift is spread over the final month's days (repro D: through Apr 13, 920.00; Apr 14, 930.00) | `defect22_final_month_drift_is_spread_by_days`; `spec_replayed_extremes.rs` `depreciation_reaches_chronos_last_month` (1,185.92 through Dec 10 of year 262142) | -| A disposed asset's schedule stops at its disposal day, held, and equals what the disposal took (repro B) | `defect22_a_disposed_assets_schedule_stops_at_its_disposal_day`, property `prop_disposed_schedule_equals_what_disposal_computed`; `ledger-mcp` `spec_assets.rs` `a_disposed_asset_lists_what_it_depreciated_through_its_disposal` | +| A disposed asset's schedule stops at its disposal day, held, and equals what the disposal took (repro B) | `defect22_a_disposed_assets_schedule_stops_at_its_disposal_day`, property `prop_disposed_schedule_equals_what_disposal_computed`; `ledger-cli` `spec_assets.rs` `a_disposed_asset_lists_what_it_depreciated_through_its_disposal` | | A disposal in the final month posts depreciation by days (repro C: 322.58, gain 322.58) | `ledger-store` `tests/assets.rs` `defect22_a_disposal_in_the_final_month_posts_depreciation_by_days` | -| A disposal inside a month already run takes back the days after it (R2: excess 500.00, book value 6,500.00) | `defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days` (store and `ledger-mcp` `spec_assets.rs`) | +| A disposal inside a month already run takes back the days after it (R2: excess 500.00, book value 6,500.00) | `defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days` (store and `ledger-cli` `spec_assets.rs`) | | A month skipped is caught up in the month the run is made; a later run for it posts nothing (settled in design, P3) | core `a_skipped_month_is_caught_up_in_the_month_run`, store `a_skipped_month_is_caught_up_in_the_month_run`, MCP `a_skipped_month_is_caught_up_and_said` | | A reversed run is un-posted and runs again (feature month_reposting); re-instating a run whose month ran again is refused; a disposal, and a run its disposal took as final, can't be reversed; a reversed run before a disposal leaves nothing behind (R3) | store `a_reversed_depreciation_month_can_be_run_again`, `a_disposal_entry_cannot_be_reversed`, `a_reversed_run_before_a_disposal_leaves_nothing_behind`; core `a_reversed_schedule_month_is_due_again` | | What the books carry is read entry by entry, each in its own month: a run reversed after its month ended isn't run twice (its month posts nothing; the reversal's month catches it up), for depreciation; a schedule month reversed after it ended is left for a human, listed with why (P3 review) | store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`; GUI `a_run_reversed_after_its_month_leaves_the_reversals_month_due`; core `schedules.rs` `a_schedule_month_reversed_after_it_ended_is_left_for_a_human`; store `format.rs` `a_schedule_month_posted_again_waits_for_month_reposting_and_replays` | @@ -1420,7 +1422,7 @@ a bookspec example (`bookspec/examples/`, P13, D-34). | The final month is C + (D − C) × k / d, rounded half up, against arithmetic written in the test (P3 review) | property `prop_the_final_month_takes_the_remainder_by_its_days` (500 cases) | | A month that rounds to nothing posts and journals nothing (R10) | store `run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing`, MCP `run_depreciation_skips_a_month_that_rounds_to_nothing` | | Depreciation and daily schedules round to the currency's minor unit (R1: yen, dinars) | `jpy_depreciation_posts_whole_yen`, store `jpy_depreciation_runs_post_whole_yen`, `schedules.rs` `a_jpy_daily_schedule_posts_whole_yen`, `a_kwd_daily_schedule_rounds_to_the_fils`; `money.rs` `steps_are_the_currencys_minor_unit` | -| A new schedule spreads by the day (1,200.00 from March 15: 54.84, eleven 100.00, 45.16); deferred revenue is earned by the day (290.32, 333.34, 333.33, 43.01) | `schedules.rs` `defect9_a_new_schedule_spreads_by_the_day`, `defect9_deferred_revenue_daily_is_earned_to_the_cent`; `schedules.rs` unit `a_daily_plan_spreads_by_the_day`; `ledger-mcp` `mcp_stdio.rs` `mcp_daily_schedule_over_stdio` | +| A new schedule spreads by the day (1,200.00 from March 15: 54.84, eleven 100.00, 45.16); deferred revenue is earned by the day (290.32, 333.34, 333.33, 43.01) | `schedules.rs` `defect9_a_new_schedule_spreads_by_the_day`, `defect9_deferred_revenue_daily_is_earned_to_the_cent`; `schedules.rs` unit `a_daily_plan_spreads_by_the_day`; `ledger-cli` `mcp_journeys.rs` `mcp_daily_schedule` | | Schedules from before P3 stay equal months, forever | `defect9_legacy_schedules_stay_monthly_even`, property `prop_monthly_even_plan_is_unchanged`; every old-books fixture (`books_an_earlier_version_wrote_open_to_its_figures`) | | An equal-months schedule starts on the 1st (D-37); a daily one's last day is in the calendar | `an_equal_months_schedule_starts_on_the_first`, `spec_bounds.rs` `a_daily_schedule_whose_last_day_passes_9999_is_refused` | | chrono's month-end clamping is pinned for daily plans (Jan 29, 30, 31; Feb 29) | `schedules.rs` unit `daily_plans_pin_month_end_clamping` | @@ -1588,7 +1590,7 @@ basis. Mutation runs wait for P12 (D-33); none was run. - **Named regressions** (the brief's reproductions, anonymized): `list_assets_book_value_is_what_the_books_carry` and - `a_reversed_run_is_not_posted` (`ledger-mcp/tests/spec_assets.rs`), + `a_reversed_run_is_not_posted` (`ledger-cli/tests/spec_assets.rs`), `an_asset_bought_before_it_is_in_service_is_on_the_register_from_acquisition`, `personal_use_contribution_keeps_its_723_basis_apart_from_depreciable_basis`, `built_in_gain_is_fixed_at_contribution_not_book_cost` @@ -1664,7 +1666,7 @@ basis. Mutation runs wait for P12 (D-33); none was run. v9 and v10 databases under this version's name rebuilt). The old books (`books_an_earlier_version_wrote_open_to_its_figures`) open to exactly their figures. -- **ledger-mcp:** `spec_assets.rs` (the aliases `purchase_date`, +- **ledger-cli:** `spec_assets.rs` (the aliases `purchase_date`, `in_service`, `tax_basis_rule`, `contributor_basis` and the old rule names, noted, and refused when they disagree; the acquired date from the acquisition entry; a value at contribution required; every refusal @@ -1737,8 +1739,8 @@ rules.md says why not. Mutation runs wait for P12 (D-33). - **Viewer** (`ledger-gui/tests/p3_seams.rs`): its capital statements, party money, cash flow, register and 1099 worksheet, read from the books' folder and settings file as it opens them, are the engine's. -- **Tools** (`ledger-mcp/tests/p3_seams.rs`, the real binary over - stdio): `books.cash_flow` line by line, `books.profit_and_loss`, +- **Tools** (`ledger-cli/tests/p3_seams.rs`, the books adopted into a + data root and served by a real books server): `books.cash_flow` line by line, `books.profit_and_loss`, `books.export` members_equity and iif, `books.list_assets`, `books.vendor_1099` through each date, `books.party` on both bases. - **Found and fixed** (each test fails before its fix): a distribution @@ -1870,7 +1872,7 @@ P12 (D-33); none was run. `prop_project_set_round_trips`); `tests/format.rs` `p3_features_wait_for_the_upgrade_and_are_declared_once` gains `projects` and a `project.set`. -- **ledger-mcp:** `tests/spec_projects.rs` +- **ledger-cli:** `tests/spec_projects.rs` (`add_project_then_project_returns_its_facts_and_figures`, `a_name_only_project_journals_only_class_added`, `update_project_checks_every_field_before_writing`, @@ -1894,7 +1896,7 @@ P12 (D-33); none was run. `books.projects` among the read-only calls; `spec_vault.rs`'s idempotent list gains `books.update_project`; the unit tables (`annotations_complete`, scopes' table) list 119 tools, `period_tools_describe_the_one_default` - checks the period tools' wording, and `mcp_stdio.rs`'s `tools/list` + checks the period tools' wording, and `mcp_journeys.rs`'s `tools/list` shows 116; `every_tool_is_documented_and_every_documented_tool_exists` holds with the four new tools (on the rebase onto the surface review it caught `books.list_classes` written mid-line and a `books.set_line_class` @@ -1990,7 +1992,7 @@ fail. sign, and `project_figures`' to-date is `project_to_date`'s). `project_figures` takes the net before its first month from to date less the months (one scan fewer); the proptest pins it. -- **ledger-mcp:** `tests/spec_projects.rs` +- **ledger-cli:** `tests/spec_projects.rs` `project_snapshot_margin_by_account_and_running_net` (the period's and to date's margin_pct, to_date.by_account, the 12 months' net_to_date opening on the December deposit and ending on to_date.net, a loss's @@ -2001,7 +2003,7 @@ fail. - **viewer and server on one book:** ledger-cli `tests/projects_parity.rs` `a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server` - (`wanderbooks dev-stdio`'s `books.project` against the viewer's + (the books server's `books.project` against the viewer's `page_figures` and `Snap` on the same books: to date's revenue, expenses, net, margin_pct, unrecovered cost and by_account, every month's figures and net_to_date over the chart's span, and the fiscal @@ -2104,7 +2106,7 @@ receivable or payable and the payments untagged; journaled as the line's `a_reversed_tagged_invoice_replays_with_its_projects_at_zero`: one `entry.reversed`, its replay tagged as live); `format.rs` `document_line_projects_is_its_own_feature`. -- **ledger-mcp:** `tests/spec_projects.rs` +- **ledger-cli:** `tests/spec_projects.rs` (`invoice_and_bill_lines_count_toward_their_projects`: by name, id and any capitals; `books.get_entry`'s line `project` and `class_id`; payments untagged; @@ -2256,6 +2258,130 @@ fails; rounding toward zero fails both `cashflow::tests`; without the tax-category route `operating_classes_follow_asc_230_10_45_25`, the second test and the example fail. +## One way to serve books (D-44, 2026-10-23) + +The stdio server and its binary are gone, and with them the second +harness: every suite that drove it moved to +`crates/ledger-cli/tests/` and drives a real `wanderbooks server` on a +data root of its own, reached as an agent reaches it. + +- **The harness** (`tests/common/mod.rs`, `mod common;` in each file): + `Home` (a `wb-` temp home made by `wanderbooks setup --home`, its admin + socket, tokens with every scope, `adopt`), `Server` (the server on that + root with an empty environment but PATH, and HOME and XDG_CONFIG_HOME in + the home; ended by its own PID), `Wire` (one MCP session, synchronous: + a message POSTed to `/b/{book}/mcp` with the bearer token, session and + protocol headers the bridge sends; `fetch` takes a handed file by its + download and checks its size and SHA-256), `Books` and `Place` (a home, + its server and the books `acme`; `Place` starts the server again on + each start, as a new server process read the same books, and lets a + test write books into the root before the first), the async rmcp + `connect` and `call`, and `book_files` (the books' files but what every + call writes by design: the access log, `ops/`, `archive.git/`, + `exports/`, `uploads/`, SQLite's -wal and -shm). Settings a test gives + as a settings file's text are set as an agent sets them: the changes + `ledger_config::codec::import` reads from it, by `books.set_setting`. +- **Moved, every assertion kept:** `mcp_journeys.rs` (was + `mcp_stdio.rs`), `spec_mcp.rs`, `spec_vault.rs`, `spec_kept.rs`, + `spec_attachments.rs`, `spec_parties.rs`, `spec_projects.rs`, + `spec_assets.rs`, `spec_party_totals.rs`, `spec_1099.rs`, + `spec_tax_policy.rs`, `oracle_mcp.rs`, `hostile_args.rs`, + `tin_files.rs`, `http_concurrency.rs`, `p3_seams.rs`, `startup.rs`; + `projects_parity.rs` and `one_executable.rs` drive the server too. + Files the books make are checked by their download; restarts are the + server closed and started again on the same root; old books are put in + the root (or adopted with their settings file, as the owner moves old + books in); the JSON-RPC framing tests check the server's door (a 4xx, + and any error it carries -32700 or -32600; an agent's malformed lines + are the bridge's, `the_agents_input_is_read_message_by_message`). +- **Retargeted:** a held lock and newer books (`startup.rs`) are now a + book the server reports not open, with why, on its route and in its + list, while the others are served; a bad receipt threshold is refused + as it is set (`inv5_bad_threshold_refused_when_set`); a changed and a + lowered setting take effect on the next call + (`a_changed_setting_is_read_on_the_next_call`, + `a_lowered_threshold_is_enforced`); `get_receipt` above 256 KiB gives + its download (`get_receipt_gives_bytes_up_to_256_kib_and_a_download_above`); + an unknown tool is a protocol error (`jsonrpc_unknown_tool_is_a_protocol_error`, + now true of the server itself). +- **Deleted, for what they tested is gone:** startup's + `help_names_the_options`, `the_default_data_dir_is_under_home`, + `the_import_root_flag_is_refused_as_unknown`, + `the_settings_beside_the_books_are_read` and + `the_cash_roles_beside_the_books_are_read` (the stdio binary's flags and + its settings file; the rules stay covered by + `mcp_receipts_always_required_when_threshold_is_zero` and + `cash_flow_reads_its_account_roles_from_the_books_settings`); + `a_restarted_server_waits_for_the_old_one_to_let_go` (its lock wait; + the server's is `local_server.rs` `a_second_server_waits_for_the_first`); + spec_vault's `a_key_file_inside_the_books_is_refused` and + `books_in_git_must_ignore_the_vault` (a books dir's key and git; a book + in the root keeps its own: `a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder`); + spec_kept's `an_import_that_reads_differently_is_refused`, + `an_import_needs_the_vault_key_on_the_command_line` and the settings + file's half of the others; spec_mcp's `a_broken_edit_keeps_the_last_good_settings` + (a settings file's reload); `dev_stdio_is_the_books_server` (the + command is gone, no name kept: it is an unknown mode, as any other + word is); the unit tests of `wire.rs`, of + `paths.rs` (the key-inside-the-books check's path resolution) and of + the settings file's watching in `settings.rs`. +- **The owner's three decisions (2026-10-23).** (a) `books.import_settings` + is gone: deleted spec_kept's import half of + `books_keeping_their_settings_take_no_settings_file` (now: the tool is + not listed, and a call to it is a protocol error), the import calls in + spec_mcp's every-tool walk and spec_vault's idempotent list, and the + scope rule's unit test of imports (`scopes.rs`); ledger-config's + `load_missing_path_errors` and `load_matches_parse` (`load` is gone: + adoption reads the file's text), `tests/effective_policy.rs` now on + `config.json`'s text. `books.chart_template` gives its settings as + changes, which `mcp_journeys.rs` sets by `books.set_setting`. + (b) The viewer's loose-books-folder mode is gone: deleted `live.rs` + `a_settings_file_made_later_is_followed` (replaced by + `a_setting_changed_while_it_runs_is_followed`: a journaled change is + followed, a file beside the books never read), + `vault_key_flag.rs` `the_key_named_on_the_command_line_wins` (the file + is `command_line.rs`: an unknown argument, and each gone flag, exits + 2), `tin_copy.rs` `a_key_inside_the_books_is_refused` (a book keeps its + key inside its own folder), `old_books.rs` + `books_that_keep_no_settings_read_their_file_only_outside_a_root` and + `an_older_books_dirs_config_json_sets_its_receipt_rule` (now one test, + `books_that_keep_no_settings_read_no_file_beside_them`), and + `exports.rs` `the_menu_reads_an_older_books_dirs_settings_file` (now + `the_menu_reads_the_books_own_settings`). The TIN tests open the + vault where a book of the data root keeps it; `one_executable.rs`'s + windowless viewer opens empty books in a root and home of its own. (c) `wanderbooks dev-stdio` has no + stub: it is an unknown command. +- **Review of the move (2026-10-23).** The windowless viewer opened the + demo, whose build alone took over its minute under load: it opens + empty books in a data root of the test's own, with an empty + environment but PATH and a temp HOME, as `ledger-gui`'s + `command_line.rs` now runs too (neither can reach this account's + books or preferences). `old_books_name_each_closes_rule_and_true_up_at_the_next_close` + had lost the refusal it was written for: its books are now P1's + journal in P2's format (declared offline, as P2's upgrade did), so + the close names `members_cumulative` in its `format_upgrade_pending` + refusal, and closes once the owner upgrades. `hostile_args.rs`'s old + books take the fixture company's settings only where their format + keeps settings: each change must be taken, or refused as + `format_upgrade_pending` (all of them, then, and the books are served + on the defaults), or, for the fiscal year, refused under periods + already closed on the calendar year; any other answer fails when the + books are first served. spec_vault's full-TIN scans + skip only the bytes of the exports the test was handed (their + records are scanned, and nothing else may be in `exports/`). The + harness fails on a stream line or JSON body from the server that + isn't JSON, instead of dropping it. `settings_live_in_the_books_and_the_ein_leaves_masked` + reads the timezone and the new EIN back after its restart, and + `inv5_bad_threshold_refused_when_set` compares the journal itself. +- **Unit tests** of the tools build their server the one way the books + server does (`Served::open` on books in a data root of their own), with + any settings they need journaled first as `books.set_setting` journals + them (`Store::change_settings`), so no test-only serving mode or + settings are left. +- **Time:** `just server-only` runs the one executable's own suites; the + tool suites run once, in `check`. Mutation of `ledger-mcp` runs the + ledger-cli suites too, without the viewer (`just mutants-mcp`). + ## Benchmarks (baseline 2026-10-13) No budget, by decision (the owner, 2026-10-13: the books do little work @@ -2319,7 +2445,7 @@ B-14) are the first. |---|---|---| | Fault injection | SKIP for the core (no I/O, clock, or network); DONE for the store's journal (full disk, cut-off append) | the first I/O or clock in `ledger-core` | | Integration | SKIP for the core (no collaborators) | the core gains one; covered at the store and MCP layers | -| End-to-end | SKIP for the core (a library) | MCP stdio tests cover the journeys | +| End-to-end | SKIP for the core (a library) | the MCP tests through a real books server (ledger-cli) cover the journeys | | Security properties | SKIP for the core (no roles, no secrets) | the first permission concept, or a secret-bearing field | | Concurrency stress | SKIP for the core (no shared state); DONE for the store's writer + viewer (found the false "differs") | the first `Arc`, atomic, or lock in the core; the MCP server's HTTP transport shares one store across sessions (next) | | Lean: apportion's sum law for any n | DONE (2026-10-14) | verify/lean/Wanderbooks/Apportion.lean: ranks are a permutation of 0..n-1 (strict total order + pigeonhole, core Lean only), so exactly u parts get a unit and one the fraction; `apportion_sum` and `apportion_sum_of_shortfall`, standard axioms only | diff --git a/justfile b/justfile index 8d7e7f7..17578dc 100644 --- a/justfile +++ b/justfile @@ -21,12 +21,14 @@ check: sweep-test-tmp @just sweep-test-tmp # The server-only build (plan D-22, for another machine) builds, passes -# clippy and its tests, and links no window libraries +# clippy and the one executable's own tests, and links no window libraries. +# The books server's tool suites (mcp_journeys, spec_*, oracle_mcp, +# hostile_args, ...: the same binary's server, D-44) run once, in `check`. server-only: #!/usr/bin/env bash set -euo pipefail cargo clippy -p ledger-cli --no-default-features --all-targets -- -D warnings - cargo test -p ledger-cli --no-default-features + cargo test -p ledger-cli --no-default-features --bins --test one_executable --test setup --test local_server --test open_while_held --test backups --test format_upgrade --test startup tree=$(cargo tree -p ledger-cli --no-default-features -e normal) if grep -qiE '\b(eframe|egui|winit|wgpu|glow|ledger-gui)\b' <<<"$tree"; then echo "the server-only build links a window library" >&2 @@ -196,11 +198,12 @@ mutants-config shard="0/1": sweep-test-tmp PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-config --test-package ledger-config -j 4 --timeout-multiplier 3 --minimum-test-timeout 120 --shard {{shard}} -o mutants-config @just sweep-test-tmp -# Mutation testing of the MCP server (every mutant runs the stdio suites; -# fewer random cases each, as for the store). +# Mutation testing of the MCP server: every mutant runs its unit tests and +# the suites that drive a real books server (ledger-cli's tests, D-44), +# built without the viewer; fewer random cases each, as for the store. mutants-mcp shard="0/1": sweep-test-tmp mkdir -p {{mutants_tmp}} - MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-mcp + MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp --test-package ledger-cli --no-default-features -j 4 --timeout-multiplier 3 --minimum-test-timeout 600 --shard {{shard}} -o mutants-mcp @just sweep-test-tmp # No mutation runs on the viewer (owner, 2026-10-18: they crushed the @@ -215,13 +218,14 @@ bench: cargo bench -p ledger-core --bench books cargo bench -p ledger-store --bench store -# Build the release binaries (CLI, MCP server, GUI) +# Build the release binaries (`wanderbooks`, the one executable, and the +# viewer's own) build: - cargo build --release -p ledger-cli -p ledger-mcp -p ledger-gui + cargo build --release -p ledger-cli -p ledger-gui # Install the binaries from this checkout into ~/.cargo/bin install: - for c in ledger-cli ledger-mcp ledger-gui; do cargo install --locked --path crates/$c; done + for c in ledger-cli ledger-gui; do cargo install --locked --path crates/$c; done # Open the GUI on the demo company demo: diff --git a/plan/STATUS.md b/plan/STATUS.md index cdd65d0..faa389c 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -35,8 +35,8 @@ settings: 1,396 characters. conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and approvals are journal events (format 2); books that keep their settings never read -`wanderbooks.toml` or `config.json`, and `books.import_settings` moves a -file in exactly once. Two review rounds (all findings fixed), `just +`wanderbooks.toml` or `config.json`, and adoption moves a file in +exactly once (`books.import_settings` did too, until D-44). Two review rounds (all findings fixed), `just check`, `just prove` (Kani 20/20, Lean 31 theorems, standard axioms only), and mutation on the whole diff (956 mutants; every survivor killed by a test but two equivalent: `docs/testing.md`). **Live on the @@ -182,6 +182,27 @@ shows a line's project; the viewer's invoice and bill pages list the document's lines, each project a link; the demo's "Client work" carries its invoices. The handbook's caveat is gone for books with the feature. +**One way to serve books (2026-10-23, D-44).** The owner: "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?" Gone: +`wanderbooks dev-stdio`, the `ledger-mcp` binary and `ledger_mcp::run` +(stdio and `--http` over a bare data dir), with what only it had: files +the books make coming back inline (every handed file is a download now), +books with no access log or backups, the settings file read and reloaded +(`legacy_files`), the vault key named by a file or `--vault-key-file`. +`BooksServer` is built one way, from a book as the books server serves it +(`Served`); an unknown tool is a protocol error. 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`), files by +their download. Inline file input is unchanged. The owner then decided +(2026-10-23, the question tool): `books.import_settings` goes (118 +tools; old books come in whole by `books adopt`; `books.chart_template` +gives its settings as `books.set_setting` changes); the viewer's +loose-books-folder mode goes (`--data-dir`, `--books-toml`, +`--vault-key-file`, the settings file followed): it opens only books of +the data root; and `wanderbooks dev-stdio` goes with no stub, an unknown +command like any other. + **Still open after P3 (each for the phase named, or the lead):** - Invoices and bills take a project now (above), behind the new journal feature `document_line_projects`: the first company's books declared @@ -386,6 +407,9 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done ## Decisions log +- 2026-10-23: D-44, one way to serve books: the stdio server, its inline + file outputs and its settings-file mode are gone; tests drive a real + books server (plan/served.md D-44). - 2026-10-13: Formal methods are part of the bar (the owner: "This shit is fucking GOLD"). Money arithmetic lives in `kernel.rs` with Kani harnesses; accounting laws get Lean theorems; `just prove` stays green. diff --git a/plan/roadmap.md b/plan/roadmap.md index 2817787..2d7ecdc 100644 --- a/plan/roadmap.md +++ b/plan/roadmap.md @@ -14,7 +14,8 @@ The primary interface is MCP, not a cloned GUI (the owner's call, 2026-10-10). - `ledger-store`: append-only hash-chained JSONL journal (source of truth), SQLite as disposable projection with auto-rebuild, content-addressed blob store, durable drafts. -- `ledger-mcp`: 27 tools over stdio and Streamable HTTP, preview-before-post, +- `ledger-mcp`: 27 tools (then over stdio and Streamable HTTP; since D-44 + served only by the books server), preview-before-post, server-side receipt policy (>$25) with anti-splitting, asset management, digest for human review, read-only resources. - `ledger-import`: QuickBooks CSV import (bank, chart), IIF and XLSX export. diff --git a/plan/served.md b/plan/served.md index caef27a..71b25b8 100644 --- a/plan/served.md +++ b/plan/served.md @@ -58,7 +58,7 @@ Paths are relative to `/home/swaits/Code/wanderbooks/crates/` unless they are ab | # | Invariant | Enforced by | |---|---|---| -| I-1 | **The MCP is the only agent interface to the books.** Locally this is **advisory** (owner decision 2026-10-16): the server runs as the human, and the books are hidden, not locked. Mechanical enforcement is the self-host deployment on another machine, where agents have no access to the data root. | A data root outside every project directory (3.4), mode 0700, created with an explicit `set_permissions` call (not the umask). No tool, instruction, handbook text or result names a filesystem path (I-2, I-12). The stdio data-dir transport is removed from every `connect` path (`dev-stdio` refuses any data dir inside the data root). `books.start`, `wanderbooks doctor` and the docs report `isolation: "same_user (advisory)"`. Landlock and seccomp confine the server itself where available; they do not restrict the agent and are never described as if they did. Test `agent_uid_cannot_read_tenant`, run in CI against the two-user self-host deployment, not the desktop default. | +| I-1 | **The MCP is the only agent interface to the books.** Locally this is **advisory** (owner decision 2026-10-16): the server runs as the human, and the books are hidden, not locked. Mechanical enforcement is the self-host deployment on another machine, where agents have no access to the data root. | A data root outside every project directory (3.4), mode 0700, created with an explicit `set_permissions` call (not the umask). No tool, instruction, handbook text or result names a filesystem path (I-2, I-12). The stdio data-dir transport is gone (D-44): books are served only by the books server. `books.start`, `wanderbooks doctor` and the docs report `isolation: "same_user (advisory)"`. Landlock and seccomp confine the server itself where available; they do not restrict the agent and are never described as if they did. Test `agent_uid_cannot_read_tenant`, run in CI against the two-user self-host deployment, not the desktop default. | | I-2 | **No tool takes or returns a server filesystem path.** | Schema lint `no_path_params`: the build fails on any parameter matching `*path*`, `save_to` or `*_file`. Landlock harness `no_tool_touches_outside_tenant`, run with fuzzed arguments. Static check: no `std::fs` in `ledger-mcp/src/tools/**` outside the ledger-store APIs. | | I-3 | **The journal holds all books state.** Settings, conventions, decisions, draft states, evidence links, annotations, extraction records and attestations are journaled. Reports are pure functions of the journal. `wanderbooks.toml` is import/export text only. The server never reads a toml file. | `ledger-core::settings` replay. Property test `settings_replay_is_pure` (Store, Snapshot and the view are identical). Test `toml_round_trip_zero_events`. The daemon binary contains no toml file-read code path. | | I-4 | **Money is exact Decimal, every entry balances, and posted entries are permanent.** Corrections are reversal drafts that need evidence. | Existing kernel, Kani and Lean. New `Step.draftReversal` and `Step.discard`, with preservation proofs. | @@ -78,10 +78,10 @@ Paths are relative to `/home/swaits/Code/wanderbooks/crates/` unless they are ab |---|---|---| | Process shape | A: single process. B: gateway plus per-book worker. C: daemon, with a gateway when hosted. | **One `ledger-mcp serve` process** (A), both locally and on a self-host machine. Locally it runs as the human, only while something the human started holds it open: the app, an agent session, or a terminal. Nothing is installed to run in the background (owner decisions D-21 and D-23, 2026-10-18). Every local program is a mode of the one `wanderbooks` executable (D-22). It has no OAuth authorization server and only a minimal human-page surface. The multi-tenant front/worker split (B) belongs to the hosted service, which this plan does not build (section 11). | | Isolation, and when it lands | A: P5. B: P6. C: P3. All three assumed a service user. | **Owner decision 2026-10-16: "you, hidden away."** The desktop gets no service user, sudo, system account or container. P2 moves the books into a server-owned data root (0700, outside every project directory) whose path no text ever names. This isolation is advisory and is labelled that way. Mechanical enforcement is the self-host deployment on another machine (P9). From P2 on, CI proves it with `agent_uid_cannot_read_tenant` against a two-user deployment. P2 needs journaled settings first (P1), because the server never reads toml. | -| Settings import | A: server reads legacy toml (`from_legacy:true`). B/C: agent passes text. | **`import_settings {toml_text, config_json_text?}` only.** A legacy fallback (today's `settings::Live`) survives only in `dev-stdio`, until a book is adopted. `books adopt` refuses a journal with no `settings.set` unless `--toml FILE` is given. The human's CLI reads that file and passes the text. | -| Before import | C: writes blocked by a `settings_not_imported` guard. | **Never block.** Legacy books keep `legacy_files` mode in `dev-stdio` (P1). Daemon books always have journaled settings (enforced by `adopt` and `create`). | -| Preview/post | A: `confirmed`. C: split tools plus `preview_token`. B: seven read-only twins. | **Keep `confirmed`, add `preview_token`.** Add one read-only twin, `books.preview_entry`, for the high-frequency post path, so claude.ai doesn't prompt on every preview. Close, reconcile, dispose, `run_*` and purge keep `confirmed` plus the token. `confirmed` without a token is accepted for one release with a deprecation warning. This limits churn in `mcp_stdio.rs`, `oracle_mcp.rs` and `spec_mcp.rs`. | -| `requiresUserInteraction` | B: on every post. | **Not used anywhere in this plan** (owner decision 2026-10-16: no added friction). Post, reverse, close, reconcile, dispose, `run_*`, `purge_tin`, `consolidate_duplicates` and `import_settings` carry `destructiveHint` plus `preview_token`, and the client's normal permission rules apply. Full TINs leave the agent's path through human pages: TIN-bearing outputs from P2, and all TIN handling in P7. | +| Settings import | A: server reads legacy toml (`from_legacy:true`). B/C: agent passes text. | **No import tool** (D-44: `import_settings` is gone, owner 2026-10-23). No settings file is read by any server or the viewer: old books bring theirs in, once, by `books adopt`, and `books.set_setting` changes settings after. `books adopt` refuses a journal with no `settings.set` unless `--toml FILE` is given. The human's CLI reads that file and passes the text. | +| Before import | C: writes blocked by a `settings_not_imported` guard. | **Never block.** Daemon books always have journaled settings (enforced by `adopt` and `create`); the `legacy_files` mode of `dev-stdio` is gone (D-44). | +| Preview/post | A: `confirmed`. C: split tools plus `preview_token`. B: seven read-only twins. | **Keep `confirmed`, add `preview_token`.** Add one read-only twin, `books.preview_entry`, for the high-frequency post path, so claude.ai doesn't prompt on every preview. Close, reconcile, dispose, `run_*` and purge keep `confirmed` plus the token. `confirmed` without a token is accepted for one release with a deprecation warning. This limits churn in `mcp_journeys.rs`, `oracle_mcp.rs` and `spec_mcp.rs`. | +| `requiresUserInteraction` | B: on every post. | **Not used anywhere in this plan** (owner decision 2026-10-16: no added friction). Post, reverse, close, reconcile, dispose, `run_*`, `purge_tin`, `consolidate_duplicates` and (until D-44 removed it) `import_settings` carry `destructiveHint` plus `preview_token`, and the client's normal permission rules apply. Full TINs leave the agent's path through human pages: TIN-bearing outputs from P2, and all TIN handling in P7. | | Harness deny rules | C: P0 `.claude/settings.json` deny rules. | **Rejected.** That is plan A, which is dropped. P0 corrects only the sentence in the server text and the generated template that caused the `cat` (`/home/swaits/Documents/the first company-Books/AGENTS.md:68-69`, "the journal is the evidence — read it"). This is a content fix, not a guard. | | Defect 12 rule | A: everything through `financing_accounts` is excluded. | **Classified by instrument (C).** Payment-card and third-party-network payments are excluded (1099-K) and listed. A member's personal cash or check through 2100 stays reportable. An unknown instrument is `undetermined`. | | Odd cent (17) | A: fair ties. C: rotating tie-break. | **Cumulative apportionment (B)**, extending `kernel::apportion` (kernel.rs:309), its existing Kani proofs, and `Apportion.lean`. | @@ -140,7 +140,6 @@ The data root is `$XDG_DATA_HOME/wanderbooks` on Linux (default `~/.local/share/ | Viewer | `wanderbooks gui `, the viewer mode (the `ledger-gui` crate; local, direct, the default; what opening the app shows); `wanderbooks gui --books ` (remote replica, P9) | the human | local: yes, read-only, never writes. Remote: none | | CLI | `wanderbooks` (ledger-cli) | the human | **by design never**. Talks to `run/admin.sock` (peer-credential check: same user ID as the server). The admin socket cannot read books data; it can create or adopt books and mint agent tokens, admin-scope ones included. The peer-credential check keeps other local accounts out; any process running as the human, agents included, can use it (advisory, like the rest of the desktop deployment). | | Agent bridge | `wanderbooks mcp --book ` (stdio to the agent; `--url` for a remote book, P9) | the agent's user | never; it has no data-dir flag. It may start the host (D-23), whose server then reads the disk as itself | -| Test/demo | `wanderbooks dev-stdio --data-dir` (also the `ledger-mcp` binary until the scaffold goes) | any | refuses inside the data root; `wanderbooks connect` never configures it (D-18) | ### 3.2 Transport (one code path, local and remote) @@ -196,7 +195,7 @@ No scope unwraps the vault. Unwrap happens only inside an `/h/` request that has - **Kept in the OS keyring** (Secret Service on Linux, through keyring-core; macOS and Windows stores come with P11), under service `wanderbooks` and an account naming the root by a hash of its path, the book and the token's name. One account is one item (2026-10-22, after a bridge on the live books ran on a token the keyring no longer gave while `download` read a revoked one): keeping a token first removes every item for its account in every collection, then makes one and reads it back; reading finds the items in every collection and, finding several, refuses with how to keep one again (`token create --replace`) instead of giving one of them; a keyring that is locked or can't be reached is an error, never "no token". The bridge (`wanderbooks mcp --book ID [--token NAME]`) reads it before each new session (even with a token held: the keyring's is used, and a change is said on stderr; a slow keyring leaves the held one in use after 2 s) and each request until it has one, and again when the door refuses it, so a token made or replaced mid-session is used from the next call (with a new session: a session belongs to the token that opened it); without one, the server's refusal says how to make one, but when the keyring couldn't be read the agent is told that instead (locked or unreachable, the token itself fine; or several items), never to make a token. Tests keep tokens in files under the root's `run/` (the `file_keyring` knob): a folder per collection, which a test can lock, make unreachable, or plant a second item in. - **The door** (`/b/{id}/mcp`): no token or an unknown one, 401 with `WWW-Authenticate: Bearer` (the scheme in any case); another book's, 403; a session is bound to its token when opened and used only with it (403), forgotten when it ends, is found gone (an idle one, when next used), or its token is revoked or replaced; an id the server never opened binds nothing; past 100,000 bindings an initialize is refused, never a binding dropped. Each refusal at the door is recorded in that book's access log (action `connect`). - **Scopes** (`ledger-mcp/src/scopes.rs`): a table of every tool. Read-only tools need books:read; a tool with `confirmed` needs books:write to preview and books:post to act; tools that post at once, settings commits, voids, renumbering, corrections and role changes need books:post, and so do the controls on posting (setting an approval rule, retiring a convention, approving an entry) and sealing a TIN (`set_tin`, `set_w9`, which can replace one); `books.access_log` and the tools that can still put a full TIN out (`vendor_1099`, `export_cpa_package`, `get_w9`) need books:admin, and so do backing up now and choosing where backups go (a `backup.*` setting, by `set_setting` or an imported `[backup]` table; slice 7): a backup is the whole book. A token is offered only the tools it can call (the list is then cached per caller, `cacheScope: private`); a call beyond its scopes is refused, unrun, naming the scope. Reading a resource needs books:read. A local token gets all four unless told otherwise. -- **The access log** (`books//access.jsonl`, own genesis) records every tool call and resource read, refusals included: who (token id, name), the client as it named itself (cut to 64 characters, any full TIN masked), the request id, the tool or resource, a SHA-256 of the arguments' canonical JSON with what may be a TIN masked first (a value under a key naming one, any key or text holding one in a shape the TIN scanner knows, any number of eight digits or more: an unsalted digest of a TIN whose last four the books show is broken by 10^5 guesses; a file's base64 is hashed as it is, never spelling a TIN in the clear), what came of it (`ok`, `error`, or `refused` with a code), and the journal's head after the call's own last write, if it wrote, whatever it answered (taken under the store's lock as the call lets go). A call's record is written on its own blocking thread while it still counts as running (one that panics part way included, with what it wrote), so a close waits for it too, and before the answer goes; one that can't be written stops the book (no more requests until a restart) and the answer says the call ran (a call refused as not run, by a close or a stopped server, keeps its "not run" code). A tool no table names is refused unrun. Known gap: a crash of the process between a call's journal write and its record leaves the journal ahead of the log for that call. A damaged log, or one holding a record that isn't an access, keeps its book closed, with why. `books.access_log` (admin) reads it newest first, filtered by token, action or outcome, paged with `before`; over stdio (no log) the tool isn't offered. +- **The access log** (`books//access.jsonl`, own genesis) records every tool call and resource read, refusals included: who (token id, name), the client as it named itself (cut to 64 characters, any full TIN masked), the request id, the tool or resource, a SHA-256 of the arguments' canonical JSON with what may be a TIN masked first (a value under a key naming one, any key or text holding one in a shape the TIN scanner knows, any number of eight digits or more: an unsalted digest of a TIN whose last four the books show is broken by 10^5 guesses; a file's base64 is hashed as it is, never spelling a TIN in the clear), what came of it (`ok`, `error`, or `refused` with a code), and the journal's head after the call's own last write, if it wrote, whatever it answered (taken under the store's lock as the call lets go). A call's record is written on its own blocking thread while it still counts as running (one that panics part way included, with what it wrote), so a close waits for it too, and before the answer goes; one that can't be written stops the book (no more requests until a restart) and the answer says the call ran (a call refused as not run, by a close or a stopped server, keeps its "not run" code). A tool no table names is refused unrun. Known gap: a crash of the process between a call's journal write and its record leaves the journal ahead of the log for that call. A damaged log, or one holding a record that isn't an access, keeps its book closed, with why. `books.access_log` (admin) reads it newest first, filtered by token, action or outcome, paged with `before`. - **The owner passphrase** is an Argon2id hash (m=64 MiB, t=3, p=1, its own salt) in `auth/owner.json`, replaced whole; 12 to 1024 characters, no other rule (NIST SP 800-63B); a check refuses a hash weaker than that (changed by hand). `setup` at a terminal asks for it (twice); elsewhere it says to run `wanderbooks passphrase`, which works only at a terminal, unechoed, and asks for the one in force before a change. (Advisory, like everything local: a process running as the human can drive a pseudo-terminal and set the first one.) A stored hash of another Argon2 version, with a short salt or output, or asking a check for more than 1 GiB, 16 passes or 8 lanes is refused as changed by hand. The command line wipes token texts and passphrases from memory once used (the server and the bridge, which hold tokens for their lifetime, don't). `doctor` says whether it is set and lists the tokens. Its uses (`/h/` pages, consents) come with the slices that build them. - **Known, product-wide (for P7):** the TIN scanner (`has_full_tin`) takes hyphens and spaces as separators, not commas or parentheses, so "123,45,6789" passes it in party fields and the access-log digest alike. - **Departures:** rate limits (tower-governor) wait for P9: on a socket only this account can reach, nothing could be limited that couldn't read the files instead. Principals are per book, not lists of books. `initialize` (the connect text, composed from the book's settings) is given to any valid token and isn't recorded. Changing the token record holds its lock across one fsync, briefly delaying token checks. @@ -260,13 +259,13 @@ The server, the CLI and the GUI find the data root with one shared function (`le - **Color schemes** (owner, 2026-10-19: "full blown colorscheme support … all the top neovim style colorschemes", "on the order of 20 families, each with a flyout of all its variants", a light/dark/all filter; Ayu included). The button in the sidebar's lower corner opens a menu: the filter, then, alphabetically and all on screen at once, the viewer's own family (Wanderbooks Light and Dark) and twenty families of editor schemes (Ayu, Catppuccin, Cyberdream, Dracula, Everforest, GitHub, Gruvbox, Kanagawa, Material, Melange, Monokai Pro, Night Owl, Nightfox, Nord, One Dark, Oxocarbon, Rosé Pine, Solarized, Sonokai, Tokyo Night), each a flyout of all its variants, alphabetical, each a row with a strip of its colors that chooses it; a row rested on for 50 ms is worn live until the pointer moves on (owner, 2026-10-19), and only a click keeps it. The choice and the filter are per-human eframe preferences like `open_at_start`; nothing is journaled and no figure changes (`schemes.rs` paints nine pages in all 80 schemes and checks every painted word and figure is the same, in the same place, and readable on what is under it). Every palette keeps every text color (figures, gains and losses, warnings, links, the faintest text) at WCAG AA contrast (4.5:1) on every background it is drawn on: a scheme's own color that falls short keeps its hue and only changes lightness, and gains, losses and text stay plainly different to the eye. The scheme pins egui's light or dark theme, so the system flipping changes nothing. The palettes come from each scheme's official source (`docs/color-schemes.md`). **As built (P2 slice 6), and where it departs:** -- **Finding the books.** `wanderbooks gui [BOOK]` (`ledger-gui --root PATH --book ID`): the data root from `--root`, else the one `setup` recorded (`Root::find`), never the working directory or a toml; the book named, else the root's only one (or the first by id). `--data-dir` still opens an older books dir's `books-data` (tests, and the first company's books until the cutover), and `--books-toml` names its settings file, read only while its journal keeps none. -- **Settings come from the journal.** A book of the data root never reads `wanderbooks.toml` or `config.json`: books that keep no settings show the defaults. An older books dir whose journal keeps none is read as its server reads it, from its `wanderbooks.toml` (or `--books-toml`) and `config.json`, followed live, until its settings move in (`books.import_settings`). The demo company journals its settings. -- **The vault.** For a book in the data root (`inside_a_root`), its own `vault/` and `keys/legacy.key`, whatever a setting says; for an older books dir, as before (`--vault-key-file`, the setting, the default). No passphrase: TINs get no handling beyond the vault (D-29); a click copies one to the clipboard as before. +- **Finding the books.** `wanderbooks gui [BOOK]` (`ledger-gui --root PATH --book ID`): the data root from `--root`, else the one `setup` recorded (`Root::find`), never the working directory or a toml; the book named, else the root's only one (or the first by id). `--data-dir` still opened an older books dir's `books-data` (tests, and the first company's books until the cutover), and `--books-toml` its settings file, until D-44 removed both (owner, 2026-10-23): the viewer opens only books of the data root. +- **Settings come from the journal.** A book of the data root never reads `wanderbooks.toml` or `config.json`: books that keep no settings show the defaults. An older books dir whose journal kept none was read from its `wanderbooks.toml` and `config.json`, followed live, until D-44 removed that mode: the viewer reads every book's settings from its journal alone. The demo company journals its settings. +- **The vault.** For a book in the data root (`inside_a_root`), its own `vault/` and `keys/legacy.key`, whatever a setting says; for an older books dir, as before (`--vault-key-file`, the setting, the default), until D-44: now every book's vault is its own. No passphrase: TINs get no handling beyond the vault (D-29); a click copies one to the clipboard as before. - **`books.show` and presence** (`ledger_store::ops`, which replaces `viewer.rs`): the server makes the book's `ops/` (0700) and `ops/viewer.lock` (0600) when it opens the books; a show request is a line of `ops/show.jsonl` (`{seq, at, target}`, at most 32, the file replaced whole); the viewer watches `ops/` once it exists and holds a shared `flock` on `ops/viewer.lock` through a read-only descriptor while it runs, let go of when it closes; `books.show` answers `viewer_open` and says so. `ops/` is not the books: the read-only tool check leaves it out. - **Save a copy / Open** on every document, **Save the W-9** on a party whose W-9 is in the vault, and the **Export** menu (the CPA package as of today, the 1099 worksheet for this year or last, full TINs included): each a new file in the Downloads folder (`ledger_store::saving`: never over a file, written aside and linked in; the viewer's report PDFs now save the same way instead of overwriting), what came of it shown beside the button. The menu's files are built off the UI thread from a fresh read-only snapshot; a document's copy and a W-9 (one stored file each, hash-checked as read) are saved at the click. "Open" is a copy in Downloads opened with the system's app (the one folder the viewer may write once confined, slice 8). - **One builder** (`ledger-package`): the CPA package, the 1099 worksheet and the report helpers the server's tools share moved out of `server.rs` into a crate both the server and the viewer use, over `Store` or `Snapshot` alike (`PackageBooks`), the TINs passed in. Proven unchanged: 91 tool responses, 19 packages and 366 entries byte-identical before and after (only the XLSX and zip timestamps, which differ between any two runs, aside). The package now names files of the same name `name (2).ext` instead of failing, its README names only what it holds, and a K-1 worksheet whose rows don't match the members is left out with the reason. -- **The agent's side (D-29):** see 4.7's slice 5 list; `export_cpa_package`, `vendor_1099` (`csv: true`) and `get_w9` hand their files over as exports (`GET /b/{book}/x/{export}`, `wanderbooks download ex_…`), inline over stdio; no tool takes a path. +- **The agent's side (D-29):** see 4.7's slice 5 list; `export_cpa_package`, `vendor_1099` (`csv: true`) and `get_w9` hand their files over as exports (`GET /b/{book}/x/{export}`, `wanderbooks download ex_…`), never inline (D-44); no tool takes a path. - **Not in this slice:** the menu's owner intents ("Close the books", "Back up now", "Restore…", "New books…", "Connect Claude Code / Codex") need the viewer's helper and the lease socket, and come with backups (slice 7) and setup (slice 8); so does running the viewer under read-only Landlock and Open-with through its helper. ### 3.7 Backup and tamper evidence @@ -292,7 +291,7 @@ Email or webhook head delivery and a witness log belong to the hosted service (s **As built (P2 slice 7), and where it departs (D-30, D-31):** - **No archive process, no encryption.** D-31 made a backup the book as it is, in git, so there is nothing to encrypt and nothing to segment: the server itself backs each book up (`ledger_store::backup`, `ledger-mcp backup.rs`). Each book keeps a bare `archive.git` beside its journal; a backup is one commit of its journal, `blobs/`, `vault/`, `keys/` and `access.jsonl` exactly as they are, made under the store's lock (so whole lines and a vault that matches), its message `seq N head H` with `book ` below, then pushed outside the lock, fast-forward only (a target that moved on is refused and said, never forced), to `backup.git_remote` and to `backup.folder/.git` (made bare on first use; a folder that isn't there, a disk not connected, is said plainly). git runs with no system config, no hooks (in a repository on this computer it pushes to too, through `--receive-pack`), no prompts, signing, ignore or attributes files or file watchers, its own identity, only the file, ssh and https protocols (never `ext::` or `fd::`, whatever the owner's own configuration says), and a limit of five minutes without a word (pushes and clones report progress; a git stopped is stopped with all it started); the owner's own `~/.gitconfig` still applies otherwise, for credential helpers. The stored files are staged outside the store's lock (they never change; a failure there is only a lost head start) and the commit, under it, is checked to hold every file there is, by name; bytes are kept as they are because no attributes rule of the owner's, the system's or the archive's is read, and a `.gitattributes` in a book's folder refuses the backup. A repository on this computer (a folder target, or an address that is a path) is pushed to or read only when plain: a bare repository at that very path (never one git would find beside or inside it), nothing in it sending git to another repository (a `.git`, `commondir` or `gitdir` file, objects borrowed from elsewhere, a link where it or its own parts are, a `.git` or `.bundle` beside it), its configuration only what `git init --bare` writes (and, for a copy made with `git clone --bare`, where it came from), since a setting of its own could run a command on a push; repositories are made with the files ref format and SHA-1 whatever the owner's own defaults; one is read as a far one is (`--no-local`, so its copying reports progress). A folder target's repository is made only while the target never took a backup: one gone since is a disk not connected (a mount point with nothing on it), said, never made again where the disk should be. A lock a stopped git left in the archive is cleared once older than any git may run quiet. The host now passes the owner's `SSH_AUTH_SOCK` (only a socket the account owns, not a link to one) so a private repository over ssh is reachable. `backup.folder` must be absolute and `backup.git_remote` can't read as an option; setting either (or importing a `[backup]` table) needs books:admin, as backing up does (a backup is the whole book, vault and key included). - **When.** When the books open; a while after they were last written (`--backup-quiet`, 30 s, the `backup_quiet` knob for tests); before they close (at most 60 s); and on `books.back_up_now`. A failed commit holds every run off for 15 minutes; a target whose push failed is left out of the backups after writes for 15 minutes and then tried again, written to or not, while the others carry on. `period.closed` needs no trigger of its own: a close is a write. No head handoff files (`ops/heads.jsonl`) yet: the backup's commits are the head record. -- **The record and the tools.** Every commit, push and drill is a record of `ops/backup.jsonl` (a chain log; a backup never moves the journal's head), each target's keyed by a digest of where it is (a new folder or address starts with no standing). Stale means behind, with the first try to back up what it lacks more than a day old (not a day since the last good push: quiet books aren't stale the moment they're written); a failed commit is every target's error until a push succeeds. The chain isn't verified at each run: the server verifies it when it opens the books, and drills prove what reached each target. Two tools, not `{status|run}`: `books.backup` (books:read) for the standing of each target (last good backup, through which seq, stale after a day behind, the last error, the last drill and why it failed) and `books.back_up_now` (books:admin, the one open-world tool). `books.start` adds a step when none is set up, or one is stale, failing or failed its drill. Over stdio neither is offered. No error names a path in the data root. +- **The record and the tools.** Every commit, push and drill is a record of `ops/backup.jsonl` (a chain log; a backup never moves the journal's head), each target's keyed by a digest of where it is (a new folder or address starts with no standing). Stale means behind, with the first try to back up what it lacks more than a day old (not a day since the last good push: quiet books aren't stale the moment they're written); a failed commit is every target's error until a push succeeds. The chain isn't verified at each run: the server verifies it when it opens the books, and drills prove what reached each target. Two tools, not `{status|run}`: `books.backup` (books:read) for the standing of each target (last good backup, through which seq, stale after a day behind, the last error, the last drill and why it failed) and `books.back_up_now` (books:admin, the one open-world tool). `books.start` adds a step when none is set up, or one is stale, failing or failed its drill. No error names a path in the data root. - **Drills** run weekly per target that holds a backup, and at the next look after a failed one once 15 minutes have passed (looked for a minute after the books are first seen open, so also at the first start after one fell due, then hourly; `--drill-first`, the `drill_first` knob, for tests): clone, check the tree is plain files under the backed-up paths before any is written out (a link or submodule could otherwise point the checkout, or the store opened on it, at the live books), replay the chain, re-hash every stored file, check every TIN record the books name is there with its key, check the head against the commit's, and check the journal is a byte prefix of the live one (the reports then follow from the same journal, so no report comparison). Not after each close: weekly is the floor and a close changes nothing a drill would see. - **Restore** is `wanderbooks books restore BACKUP [--id ID] [--replace] [--yes]` (without `--id`, the id of the books the backup holds, as its preview names them, else the repository's name), the admin socket's `restore`, previewed then confirmed against the previewed head. The server clones the backup into `books/.restore-*` (the address or folder crosses the owner-only admin socket as an adoption's path does; no staging through `uploads/`), proves it as a drill does (but for the live-journal check), one restore at a time, builds the book as an adoption builds (every stored file re-hashed, opened, verified, synced; the vault and its key come with it, D-31), gives it the backup's history as its own `archive.git` (so its next backups fast-forward), and renames it into place. Over existing books it is refused unless `--replace`: then the server lets go of the open books (their writer lock released; a session still holding them is refused, its client's next session sees the restored books), waits for their last backup run and starts no other until the restore is done (one in flight could otherwise push past the backup being restored), and moves them whole into `legacy/-