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

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

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

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

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

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

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

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

No files matched your search

+2 -3
View File
@@ -1,5 +1,5 @@
//! Who is calling, what their token allows, and the record of each call
//! (plan 3.3, I-9), for books served from the data root.
//! (plan 3.3, I-9), for every book the books server serves.
//!
//! ```text
//! GOAL: a call reaches a book only with a token for that book and for
@@ -9,8 +9,7 @@
//! logged, and logging fails closed"), section 7's record.
//! INVARIANTS:
//! 1. The HTTP door (local.rs) puts a Caller on every request it lets
//! through; a call that reaches a book with an access log and has no
//! Caller is refused.
//! through; a call that reaches a book with no Caller is refused.
//! 2. A call runs only if its Caller's token has the scope it needs
//! (scopes.rs); otherwise it is refused, unrun.
//! 3. Every call, run or refused, gets one record, written before its
+11 -51
View File
@@ -19,11 +19,9 @@
//! ReceiptPolicy::rule), never a fixed one; the connect text states
//! the parties rule in force too.
//! 3. No connect text or handbook section names a filesystem path, a
//! helper command, a pull request, or where the books are; none
//! names the settings file, which is the one books.settings names.
//! Books that keep their own settings are told no local file is
//! theirs to edit; books that still read a settings file, that it is
//! the one they may edit, until it is imported.
//! helper command, a pull request, or where the books are; the books
//! keep their own settings, and the agent is told no local file is
//! its to edit.
//! 4. The connect text's first paragraph stands alone: the entry
//! point, the local-files rule, decimal strings, and the entry flow.
//! 5. The company's name is shown as one line of plain text (no
@@ -246,23 +244,9 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
// (with the hint back), then the hint and the account codes the rule
// lists (counted, with books.settings listing them), and last the
// words that only help.
// Where settings live: in the books (changed through the tools), or,
// for books that still read a settings file, that one file.
let settings_rule = |why: bool| {
if settings.journaled {
" Settings and conventions live in the books: change them only with these tools."
.to_string()
} else {
format!(
" The only local file you may edit is the settings file books.settings names{}.",
if why {
" (until books.import_settings)"
} else {
""
}
)
}
};
// Where settings live: in the books, changed through the tools.
let settings_rule =
" Settings and conventions live in the books: change them only with these tools.";
let rest = |why: bool, codes: usize, trace: bool, terse: bool| {
let rule = settings.policy.rule_listing(currency, codes);
let because = |s: &'static str| if why { s } else { "" };
@@ -287,7 +271,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
} else {
""
},
settings_rule(why),
settings_rule,
)
};
let mut text = String::new();
@@ -358,13 +342,7 @@ mod tests {
longest_rule(&long, PartyPolicy::Suggest),
longest_rule(&long, PartyPolicy::Off),
];
// Each as books still reading a settings file, and as books that
// keep their own settings.
let journaled = every.clone().map(|mut s| {
s.journaled = true;
s
});
for s in every.into_iter().chain(journaled) {
for s in every {
let text = connect_text(&s, "USD");
let chars = text.chars().count();
assert!(chars <= CONNECT_MAX, "{chars} chars: {text}");
@@ -391,8 +369,8 @@ mod tests {
.any(|&n| text.contains(&s.policy.rule_listing("USD", n))),
"{text}"
);
// What every entry must be, and where settings live: the one
// file the agent may edit, or none.
// What every entry must be, and where settings live: in the
// books.
for must in [
"balances exactly",
"real account from books.chart",
@@ -401,14 +379,8 @@ mod tests {
] {
assert!(text.contains(must), "lacks {must:?}: {text}");
}
assert_eq!(
text.contains("the settings file books.settings names"),
!s.journaled,
"{text}"
);
assert_eq!(
assert!(
text.contains("Settings and conventions live in the books"),
s.journaled,
"{text}"
);
// The parties rule in force (INVARIANT 2), and only it.
@@ -611,22 +583,10 @@ mod tests {
for why in [
"(their schedules are the evidence)",
"(derived from the books)",
"(until books.import_settings)",
"(the parties setting requires it)",
] {
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
}
// The same company once its settings are in the books.
s.journaled = true;
let text = connect_text(&s, "USD");
assert!(text.chars().count() <= CONNECT_MAX);
for why in [
"(their schedules are the evidence)",
"(the parties setting requires it)",
"Settings and conventions live in the books",
] {
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
}
assert!(!text.contains("local file you may edit"), "{text}");
}
}
+11 -265
View File
@@ -1,19 +1,9 @@
//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio.
//!
//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks
//! dev-stdio` (one executable, plan D-22) both call it.
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth),
//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each
//! schema: ledger_store::db_file_for), `blobs/` (content-addressed receipts),
//! and optionally `config.json`. If no store exists, one is created with the
//! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
//! default the one next to the data dir, and reload whenever it changes
//! (settings.rs). Files come in only as tool input (inline, at most 256
//! KiB): uploads need the books server's socket (`wanderbooks mcp`).
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
//! every book of the data root on the root's local sockets, which agents
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as
//! tool input (inline, at most 256 KiB) or as uploads, and the files the
//! books make go out as downloads.
pub mod access;
pub mod admin;
@@ -22,59 +12,20 @@ mod handbook;
#[cfg(unix)]
pub mod local;
pub mod opening;
mod paths;
mod policy;
pub mod scopes;
mod server;
mod settings;
mod tin_files;
mod types;
mod wire;
use ledger_core::Currency;
use ledger_store::Store;
use policy::ReceiptPolicy;
use rmcp::ServiceExt;
use serde::Deserialize;
use server::BooksServer;
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
use std::path::PathBuf;
/// Server configuration, JSON. All fields optional.
#[derive(Debug, Default, Deserialize)]
struct Config {
/// ISO 4217 base currency for a new store. Default "USD".
#[serde(default)]
base_currency: Option<String>,
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
}
fn default_data_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".local/share/wanderbooks")
}
fn print_usage(prog: &str) {
eprintln!(
"usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR]"
);
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
eprintln!(
" --books-toml company settings, for books that don't keep their own yet (default <data-dir>/../wanderbooks.toml, optional)"
);
eprintln!(
" --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)"
);
eprintln!(
" --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio"
);
}
/// How [`run`] stops when it can't go on: the reason is already printed
/// on stderr, and the number is the process's exit code (2: a bad argument; 1: books from a newer wanderbooks). A
/// library never ends the process itself, so the books it opened are
/// closed properly on the way out; the binaries turn this into the exit.
/// How a mode of the one executable stops when it can't go on: the
/// reason is already printed on stderr, and the number is the process's
/// exit code (2: a bad argument). A library never ends the process
/// itself, so the books it opened are closed properly on the way out;
/// the executable turns this into the exit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Exit(pub i32);
@@ -86,211 +37,6 @@ impl std::fmt::Display for Exit {
impl std::error::Error for Exit {}
/// Run the books server with `args` (the command line after the program
/// name). `prog` names the program in the usage text: `ledger-mcp`, or
/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad
/// argument or books from a newer wanderbooks return [`Exit`] after a
/// plain message.
pub fn run(
prog: &str,
args: impl IntoIterator<Item = String>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut data_dir = default_data_dir();
let mut config_path: Option<PathBuf> = None;
let mut books_toml: Option<PathBuf> = None;
let mut vault_key_file: Option<PathBuf> = None;
let mut http_addr: Option<String> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--data-dir" => {
data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?);
}
"--config" => {
config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?));
}
"--books-toml" => {
books_toml = Some(PathBuf::from(
args.next().ok_or("--books-toml needs a path")?,
));
}
"--vault-key-file" => {
vault_key_file = Some(PathBuf::from(
args.next().ok_or("--vault-key-file needs a path")?,
));
}
"--http" => {
http_addr = Some(args.next().ok_or("--http needs an address")?);
}
"--help" | "-h" => {
print_usage(prog);
return Ok(());
}
other => {
eprintln!("unknown argument: {other}");
print_usage(prog);
return Err(Box::new(Exit(2)));
}
}
}
// Books in the data root are the books server's alone (plan 3.1):
// a second writer beside it is refused before anything is opened.
if ledger_store::root::inside_a_root(&data_dir) {
eprintln!(
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
);
return Err(Box::new(Exit(2)));
}
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
// config.json's base currency matters only to books being created: it
// is read then, and never otherwise (books that keep their own
// settings read no file).
let read_config = || -> Result<Config, Box<dyn std::error::Error>> {
if config_path.exists() {
let raw = std::fs::read_to_string(&config_path)?;
Ok(serde_json::from_str(&raw)
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?)
} else {
Ok(Config::default())
}
};
// Logs go to stderr: stdout is the MCP channel.
eprintln!("ledger-mcp: data dir {}", data_dir.display());
// An explicit --books-toml must exist; the default (beside the data
// dir) is optional. Either is reloaded whenever it changes.
let source = settings::Source {
required: books_toml.is_some(),
books_toml: books_toml.unwrap_or_else(|| {
data_dir
.parent()
.map(|p| p.join("wanderbooks.toml"))
.unwrap_or_else(|| PathBuf::from("wanderbooks.toml"))
}),
config_json: config_path.clone(),
};
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
match open_waiting_for_lock(&data_dir) {
Ok(store) => store,
// Books from a newer wanderbooks are fine; this server is old.
// Say so in plain words (the error's text), and leave them be.
Err(e @ ledger_store::StoreError::NewerJournal { .. }) => {
eprintln!("ledger-mcp: {e}");
return Err(Box::new(Exit(1)));
}
Err(e) => return Err(e.into()),
}
} else {
let config = read_config()?;
let code = config.base_currency.as_deref().unwrap_or("USD");
let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?;
eprintln!("ledger-mcp: creating new store (base currency {code})");
Store::create(&data_dir, currency)?
};
// The journal is the source of truth: verify the chain, the replay, and
// the whole projection before serving a single tool call, rebuilding
// the projection if it drifted.
let report = store
.verify_and_heal()
.map_err(|e| format!("store verify failed: {e}"))?;
if let Some(why) = &report.healed {
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
}
// The viewer's side channel, made as the books open (ledger_store::ops
// INVARIANT 2), so a viewer already open shows its presence before
// the first books.show. Not the books: a failure only says so.
if let Err(e) = ledger_store::ops::prepare(&data_dir) {
eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})");
}
// Books that keep their own settings never read a settings file, not
// even to start (settings INVARIANT 1); others read it as before.
let mut live = if store.ledger().settings().is_journaled() {
settings::Live::journaled(source)
} else {
settings::Live::watching(source)?
}
.with_vault_key_file(vault_key_file);
live.sync(store.ledger().settings(), store.base_currency().code());
eprintln!(
"ledger-mcp: company {:?} (settings from {})",
live.current().company.name,
if live.is_journaled() {
"the books"
} else {
"the settings file"
}
);
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
if let Some(addr) = http_addr {
eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}");
rt.block_on(serve_http(addr, store, live))?;
return Ok(());
}
eprintln!("ledger-mcp: store verified, serving over stdio");
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
rt.block_on(async {
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
let running = server.serve((input, output)).await?;
let _ = running.waiting().await;
let _ = writer.await;
Ok::<(), Box<dyn std::error::Error>>(())
})
}
/// Serve MCP over Streamable HTTP (for network clients).
async fn serve_http(
addr: String,
store: Store,
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
) -> Result<(), Box<dyn std::error::Error>> {
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use std::sync::Arc;
// One store for every connection: the async mutex keeps it
// single-writer, and tool bodies run on blocking threads (server.rs).
let store = server::SharedStore::new(store);
let service = StreamableHttpService::new(
move || {
let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone());
Ok(server)
},
Arc::new(LocalSessionManager::default()),
Default::default(),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind(&addr).await?;
eprintln!("ledger-mcp: listening on http://{addr}/mcp");
axum::serve(listener, router).await?;
Ok(())
}
/// Open the books, waiting up to ten seconds for another writer to let go.
/// A restarted server (a client reconnecting) often starts while the old
/// process is still exiting with the lock; waiting turns that race into a
/// short delay. A writer that keeps the books longer is a real conflict,
/// and the refusal says so.
fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result<Store, ledger_store::StoreError> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match Store::open(data_dir) {
Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
other => return other,
}
}
}
#[cfg(test)]
mod exit_tests {
use super::Exit;
+16 -49
View File
@@ -60,9 +60,7 @@
//! ```
use crate::admin::{self, Request, Response};
use crate::policy::ReceiptPolicy;
use crate::server::{BooksServer, Layout, SharedStore};
use crate::settings::Live;
use crate::server::{BooksServer, Served, SharedStore};
use ledger_store::Store;
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
use ledger_store::auth::{Principals, Scope, Token};
@@ -294,61 +292,30 @@ impl Books {
self.hide(why.clone())
);
}
let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code());
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
let database = (store.database_file(), store.other_databases());
let layout = Layout::Root(self.root.path().to_path_buf());
let access = Arc::new(
AccessLog::open(store.data_dir())
.map_err(|e| format!("its access log can't be used: {e}"))?,
);
let uploads =
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_uploads = uploads.clone();
let exports =
ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp())
.map_err(|e| self.hide(e.to_string()))?;
let server_exports = exports.clone();
// The viewer's side channel, made by the server (ops.rs
// INVARIANT 2): a viewer can show its presence from the start.
ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?;
let live = Arc::new(Mutex::new(live));
let book_dir = store.data_dir().to_path_buf();
let book_id = book_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let store = SharedStore::new(store);
let backups = Arc::new(
crate::backup::Backups::open(&book_id, &book_dir, store.clone())
.map_err(|e| self.hide(e))?,
);
let server_backups = backups.clone();
let log = access.clone();
let shared = store.clone();
let served = Served::open(store).map_err(|e| self.hide(e))?;
let config = StreamableHttpServerConfig::default()
.with_json_response(true)
.with_legacy_session_mode(true)
.with_max_request_body_bytes(MAX_BODY)
.enforce_origin_validation()
.with_cancellation_token(self.sessions.child_token());
let parts = served.clone();
// Every session's server is made the one way (server.rs Served).
let service = StreamableHttpService::new(
move || {
Ok(
BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
.with_live_settings(live.clone())
.with_layout(layout.clone())
.with_access(log.clone())
.with_uploads(server_uploads.clone())
.with_exports(server_exports.clone())
.with_backups(server_backups.clone()),
)
},
move || Ok(BooksServer::new(&parts)),
Arc::new(sessions()),
config,
);
let Served {
store: shared,
access,
uploads,
exports,
backups,
..
} = served;
Ok(Book::Open {
service: Box::new(service),
store: shared,
@@ -884,9 +851,9 @@ impl Books {
}
}
/// The settings a settings file moves in at adoption, checked as
/// `books.import_settings` checks them: they parse, they apply, and the
/// vault key file it names (which the books don't keep) is the one given.
/// The settings a settings file moves in at adoption, checked: they
/// parse, they apply, and the vault key file it names (which the books
/// don't keep) is the one given.
fn settings_to_move(
toml: &str,
config_json: Option<&str>,
-12
View File
@@ -1,12 +0,0 @@
//! `ledger-mcp`: the books server over stdio (or `--http ADDR`). The same
//! server is `wanderbooks dev-stdio`; both call [`ledger_mcp::run`].
fn main() -> Result<(), Box<dyn std::error::Error>> {
match ledger_mcp::run("ledger-mcp", std::env::args().skip(1)) {
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
Some(exit) => std::process::exit(exit.0),
None => Err(e),
},
done => done,
}
}
-132
View File
@@ -1,132 +0,0 @@
//! Paths as the server judges them: where a path really leads.
use std::path::{Component, Path, PathBuf};
/// `path` made absolute and resolved as the kernel would follow it, name
/// by name: every symlink followed (a dangling one too, to where it
/// points), `.` and `..` folded in order, and whatever doesn't exist kept
/// as written. For an existing path this is its canonical form; for a
/// missing one, where it would be.
pub fn resolved(path: &Path) -> PathBuf {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("/"))
.join(path)
};
// The names still to walk, last first.
let mut todo: Vec<std::ffi::OsString> = Vec::new();
let mut out = PathBuf::new();
// An absolute path starts the walk over at its root.
let push_all = |todo: &mut Vec<std::ffi::OsString>, p: &Path, out: &mut PathBuf| {
*out = PathBuf::new();
let mut parts = Vec::new();
for c in p.components() {
match c {
Component::Prefix(_) | Component::RootDir => out.push(c.as_os_str()),
other => parts.push(other.as_os_str().to_os_string()),
}
}
todo.extend(parts.into_iter().rev());
};
push_all(&mut todo, &abs, &mut out);
// Links followed at most this many times (as the kernel's ELOOP).
let mut hops = 0;
while let Some(name) = todo.pop() {
if name == "." {
continue;
}
if name == ".." {
out.pop();
continue;
}
let next = out.join(&name);
let link = std::fs::symlink_metadata(&next)
.is_ok_and(|m| m.file_type().is_symlink())
.then(|| std::fs::read_link(&next).ok())
.flatten();
match link {
Some(target) if hops < 40 => {
hops += 1;
if target.is_absolute() {
push_all(&mut todo, &target, &mut out);
} else {
todo.extend(
target
.components()
.map(|c| c.as_os_str().to_os_string())
.rev(),
);
}
}
_ => out = next,
}
}
out
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
/// `resolved` follows as many links as the kernel does (40, as
/// `canonicalize` does), and no more.
#[test]
fn resolved_follows_forty_links_and_no_more() {
let dir = tempfile::tempdir().unwrap();
let b = dir.path().canonicalize().unwrap();
let file = b.join("receipt.pdf");
std::fs::write(&file, "x").unwrap();
let link = |n: usize| b.join(format!("link-{n}"));
symlink(&file, link(1)).unwrap();
for n in 2..=41 {
symlink(link(n - 1), link(n)).unwrap();
}
assert_eq!(link(40).canonicalize().unwrap(), file);
assert_eq!(resolved(&link(40)), file);
assert!(link(41).canonicalize().is_err(), "the kernel stops at 40");
assert_ne!(resolved(&link(41)), file);
}
/// `resolved` places a path where the kernel would follow it: the
/// canonical form for one that exists, and through a dangling link to
/// where it points.
#[test]
fn resolved_follows_every_link_a_dangling_one_too() {
let dir = tempfile::tempdir().unwrap();
let b = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(b.join("Downloads/sub")).unwrap();
std::fs::create_dir_all(b.join("data")).unwrap();
std::fs::write(b.join("Downloads/receipt.pdf"), "x").unwrap();
std::fs::write(b.join("data/journal.jsonl"), "x").unwrap();
symlink(
b.join("Downloads/receipt.pdf"),
b.join("Downloads/good-link"),
)
.unwrap();
symlink(b.join("data"), b.join("Downloads/data-link")).unwrap();
for p in [
b.join("Downloads/good-link"),
b.join("Downloads/data-link/journal.jsonl"),
b.join("Downloads/sub/../receipt.pdf"),
b.join("Downloads/./good-link"),
] {
assert_eq!(resolved(&p), p.canonicalize().unwrap(), "{}", p.display());
}
symlink("../elsewhere/gone.pdf", b.join("Downloads/rel-dangling")).unwrap();
assert_eq!(
resolved(&b.join("Downloads/rel-dangling")),
b.join("elsewhere/gone.pdf")
);
symlink(b.join("Downloads/loop-b"), b.join("Downloads/loop-a")).unwrap();
symlink(b.join("Downloads/loop-a"), b.join("Downloads/loop-b")).unwrap();
// A loop ends (the hop limit) inside the folder it loops in.
assert!(resolved(&b.join("Downloads/loop-a")).starts_with(b.join("Downloads")));
assert_eq!(
resolved(&b.join("Downloads/missing/../../elsewhere/x")),
b.join("elsewhere/x")
);
}
}
+6 -47
View File
@@ -25,9 +25,8 @@
//! the one there) needs books:post (review of slice 4).
//! 6. Where the books' backups go is books:admin's, as backing up is
//! (a backup is the whole book, its vault and key included):
//! setting a `backup.*` key, or importing settings that hold one,
//! needs books:admin; other settings books:post as before (review of
//! slice 7).
//! setting a `backup.*` key needs books:admin; other settings
//! books:post as before (review of slice 7).
//! ```
use ledger_store::auth::Scope;
@@ -46,14 +45,9 @@ pub enum Needs {
/// books:admin for a change to a `backup.*` setting, books:post for
/// any other (INVARIANT 6).
AdminForBackupSettings,
/// As PostWhenConfirmed, and books:admin when the settings imported
/// hold a `[backup]` table (INVARIANT 6).
ImportSettings,
}
use Needs::{
AdminForBackupSettings, Always, ImportSettings, PostForApprovalRules, PostWhenConfirmed,
};
use Needs::{AdminForBackupSettings, Always, PostForApprovalRules, PostWhenConfirmed};
const R: Needs = Always(Scope::Read);
const W: Needs = Always(Scope::Write);
const P: Needs = Always(Scope::Post);
@@ -62,12 +56,11 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 119] = [
pub const TABLE: [(&str, Needs); 118] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
("books.set_setting", AdminForBackupSettings),
("books.import_settings", ImportSettings),
("books.export_settings", R),
("books.conventions", R),
("books.set_convention", K),
@@ -220,20 +213,6 @@ pub fn needs(
});
if backup { Scope::Admin } else { Scope::Post }
}
ImportSettings => {
let backup = args
.and_then(|a| a.get("toml_text"))
.and_then(serde_json::Value::as_str)
.and_then(|t| t.parse::<toml::Table>().ok())
.is_some_and(|t| t.contains_key("backup"));
if backup {
Scope::Admin
} else if confirmed(args) {
Scope::Post
} else {
Scope::Write
}
}
PostForApprovalRules => {
let kind = args
.and_then(|a| a.get("kind"))
@@ -259,7 +238,7 @@ fn confirmed(args: Option<&serde_json::Map<String, serde_json::Value>>) -> bool
pub fn shown(tool: &str, scopes: &std::collections::BTreeSet<Scope>) -> bool {
match TABLE.iter().find(|(name, _)| *name == tool) {
Some((_, Always(scope))) => scopes.contains(scope),
Some((_, PostWhenConfirmed | PostForApprovalRules | ImportSettings)) => {
Some((_, PostWhenConfirmed | PostForApprovalRules)) => {
scopes.contains(&Scope::Write) || scopes.contains(&Scope::Post)
}
Some((_, AdminForBackupSettings)) => {
@@ -333,25 +312,6 @@ mod tests {
);
assert_eq!(set(&[" Backup.Folder"]), Some(Scope::Admin));
assert_eq!(needs("books.set_setting", None), Some(Scope::Post));
let import = |text: &str, confirmed: bool| {
needs(
"books.import_settings",
args(json!({"toml_text": text, "confirmed": confirmed})).as_ref(),
)
};
assert_eq!(
import("[company]\nname = \"A\"\n", false),
Some(Scope::Write)
);
assert_eq!(import("[company]\nname = \"A\"\n", true), Some(Scope::Post));
assert_eq!(
import("[backup]\nfolder = \"/x\"\n", false),
Some(Scope::Admin)
);
assert_eq!(
import("[backup]\nfolder = \"/x\"\n", true),
Some(Scope::Admin)
);
// Shown to whoever can set some setting.
let post: std::collections::BTreeSet<Scope> = [Scope::Post].into();
let write: std::collections::BTreeSet<Scope> = [Scope::Write].into();
@@ -359,7 +319,6 @@ mod tests {
assert!(shown("books.set_setting", &post));
assert!(shown("books.set_setting", &admin));
assert!(!shown("books.set_setting", &write));
assert!(shown("books.import_settings", &write));
}
#[test]
@@ -372,7 +331,7 @@ mod tests {
.and_then(|p| p.as_object())
.is_some_and(|p| p.contains_key("confirmed"));
let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap();
assert_eq!(takes, matches!(*needs, C | ImportSettings), "{}", tool.name);
assert_eq!(takes, *needs == C, "{}", tool.name);
let takes_kind = tool
.input_schema
.get("properties")
File diff suppressed because it is too large. Load diff
+37 -465
View File
@@ -1,91 +1,56 @@
//! The server's settings: the books' own (journaled, ledger_core::
//! settings), or, for books that hold none yet, the settings files
//! (`wanderbooks.toml`, and the receipt policy in `books-data/config.json`)
//! reloaded whenever they change.
//! settings), the defaults until the first is set.
//!
//! ```text
//! GOAL: every request sees the settings in force, one version for the
//! whole request; books that keep their own settings never read a
//! file; a broken file edit never takes the server down.
//! whole request, and they are only ever the books' own.
//! SOURCE: plan/served.md I-3 (the journal holds all books state; the
//! server never reads a settings file for books that hold their
//! settings), 2.1 ("dev-stdio keeps legacy_files mode until a book
//! is adopted"); DECISION (owner, 2026-10-15): file settings are
//! always auto-reloaded.
//! server never reads a settings file), 3.4 (a book in the data
//! root keeps its own settings), D-44 (one way to serve books: no
//! settings file is read by any of them).
//! INVARIANTS:
//! 1. Books whose journal holds settings (any settings.set) use those,
//! and from then on no file is read: sync takes them whenever the
//! journal's last settings.set changes.
//! 2. Otherwise every request (tool call, resource read) sees the files'
//! contents as of its start: a request after an edit sees the edit
//! (compared by content, not by timestamp, so two edits in one second
//! are both seen).
//! 3. A file that fails to load leaves the last good settings in force,
//! and the failure is reported (books.settings, stderr) until a later
//! edit loads.
//! 4. A settings file that doesn't exist and wasn't asked for (the
//! default wanderbooks.toml beside the books) means the defaults; one
//! named with --books-toml that goes missing is a failure (3).
//! 5. Only the settings change while serving: the data dir, the books'
//! base currency, and a vault key file given on the command line are
//! fixed when the server starts (the key file given wins over any
//! file's [vault] key_file).
//! 6. A book in the data root (plan 3.4) has no settings file at all:
//! its settings are its own, the defaults until the first is set,
//! and no file is ever read for it.
//! 1. The settings in force are the books' own: the defaults until
//! their journal holds any settings.set, then those; sync takes them
//! whenever the journal's last settings.set changes, and only then.
//! 2. No settings file is ever read.
//! ```
use crate::policy::ReceiptPolicy;
use ledger_config::Company;
use std::path::PathBuf;
use std::sync::Arc;
/// `[books]` account roles for the cash flow statement, used when a call
/// doesn't pass its own: cash, financing, and investing account codes.
#[derive(Clone, Debug, Default)]
pub struct CashRoles {
/// `[books] cash_accounts`.
/// `books.cash_accounts`.
pub cash: Vec<String>,
/// `[books] financing_accounts`.
/// `books.financing_accounts`.
pub financing: Vec<String>,
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
pub investing: Vec<String>,
}
/// What the settings files say, as last loaded.
/// The settings in force, as the server uses them.
#[derive(Clone, Debug, Default)]
pub struct Settings {
/// Which entries need a receipt.
pub policy: ReceiptPolicy,
/// `[company]`.
/// The company (`company.*`).
pub company: Company,
/// `[books]` cash flow roles.
/// The cash flow roles.
pub roles: CashRoles,
/// The prefix of generated asset tags (`[books] asset_tag_prefix`, or
/// The prefix of generated asset tags (`books.asset_tag_prefix`, or
/// the company's initials).
pub asset_tag_prefix: String,
/// `[vault] key_file`: the TIN vault's key file (unset: the vault's
/// default, `ledger_vault::default_key_file`).
pub vault_key_file: Option<PathBuf>,
/// `[books] parties`: suggest, require, or never mention each entry's
/// `policy.parties`: suggest, require, or never mention each entry's
/// counterparty.
pub parties: ledger_config::PartyPolicy,
/// The company's time zone (an IANA name), if set: what "today" means.
pub timezone: Option<String>,
/// Whether these are the books' own settings (journaled) rather than a
/// file's.
pub journaled: bool,
}
impl Settings {
/// The vault key file in force: `[vault] key_file`, else the default
/// outside the books (`~/.config/wanderbooks/vault.key`).
pub fn key_file(&self) -> PathBuf {
self.vault_key_file
.clone()
.unwrap_or_else(ledger_vault::default_key_file)
}
/// The settings the books keep (`ledger_core::settings`), as the
/// server uses them.
pub fn from_books(books: &ledger_core::settings::Settings, currency: &str) -> Self {
@@ -93,7 +58,6 @@ impl Settings {
Settings {
policy: ledger_config::codec::receipt_policy(books),
asset_tag_prefix: b.asset_tag_prefix(),
vault_key_file: None,
parties: b.parties,
timezone: b.timezone.clone(),
roles: CashRoles {
@@ -102,7 +66,6 @@ impl Settings {
investing: b.investing_accounts,
},
company: b.company,
journaled: true,
}
}
@@ -116,179 +79,24 @@ impl Settings {
}
}
/// Where the settings come from.
#[derive(Clone, Debug)]
pub struct Source {
/// The books' wanderbooks.toml.
pub books_toml: PathBuf,
/// Whether it must exist (named with --books-toml) or may be absent
/// (the default beside the books: then the defaults apply).
pub required: bool,
/// `config.json`, for its receipt policy (optional).
pub config_json: PathBuf,
}
/// Both files' bytes (`None`: absent), to tell when either changed.
type Seen = (Option<Vec<u8>>, Option<Vec<u8>>);
impl Source {
fn read(&self) -> Seen {
(
std::fs::read(&self.books_toml).ok(),
std::fs::read(&self.config_json).ok(),
)
}
/// Load the settings now.
pub fn load(&self) -> Result<Settings, String> {
let books = if self.required || self.books_toml.exists() {
ledger_config::load(&self.books_toml)?
} else {
ledger_config::BooksSettings::default()
};
// config.json sits in the data dir, which no message names.
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)
.map_err(|e| e.replace(&self.config_json.display().to_string(), "config.json"))?;
Ok(Settings {
asset_tag_prefix: books.asset_tag_prefix(),
vault_key_file: books.vault_key_file.clone(),
parties: books.parties,
timezone: books.timezone.clone(),
journaled: false,
policy,
roles: CashRoles {
cash: books.cash_accounts,
financing: books.financing_accounts,
investing: books.investing_accounts,
},
company: books.company,
})
}
}
/// The settings in force, and how they got there.
/// The settings in force, and the books' settings.set they came from.
#[derive(Debug)]
pub struct Live {
current: Arc<Settings>,
source: Option<Source>,
seen: Seen,
error: Option<String>,
loads: u64,
/// The seq of the books' last settings.set taken (INVARIANT 1).
journal_seq: Option<u64>,
/// A vault key file given on the command line (INVARIANT 5).
vault_key_file: Option<PathBuf>,
/// Whether the settings files may be read: false once the books keep
/// their own (INVARIANT 1).
read_files: bool,
/// A book in the data root: its settings are only ever its own
/// (INVARIANT 6).
own: bool,
}
/// What books.settings reports about the files.
#[derive(Clone, Debug)]
pub struct Status {
/// The wanderbooks.toml read, if the settings come from files.
pub books_toml: Option<PathBuf>,
/// Whether that file exists now.
pub books_toml_exists: bool,
/// Whether a config.json is read (it sits in the data dir, which is
/// never named).
pub config_json_present: bool,
/// Why the last change failed to load (the last good settings stay in
/// force), or `None`.
pub error: Option<String>,
/// How many times the settings have loaded, the first included.
pub loads: u64,
/// Whether the settings are the books' own (journaled): then no file
/// is read.
pub journaled: bool,
}
impl Live {
/// Settings that never reload (tests, and servers built in code).
pub fn fixed(settings: Settings) -> Self {
Self {
current: Arc::new(settings),
source: None,
seen: (None, None),
error: None,
loads: 1,
journal_seq: None,
vault_key_file: None,
read_files: false,
own: false,
}
}
/// For a book in the data root (INVARIANT 6): no settings file, now
/// or later; `sync` brings the books' settings in once they hold any.
/// For a book (INVARIANT 1): the defaults; `sync` brings the books'
/// settings in once they hold any.
pub fn own() -> Self {
Self {
current: Arc::new(Settings {
journaled: true,
..Settings::default()
}),
source: None,
seen: (None, None),
error: None,
loads: 0,
current: Arc::new(Settings::default()),
journal_seq: None,
vault_key_file: None,
read_files: false,
own: true,
}
}
/// Load from `source`, and reload from it whenever it changes. Fails
/// only on this first load: a server doesn't start on broken settings.
pub fn watching(source: Source) -> Result<Self, String> {
let seen = source.read();
let settings = source.load()?;
Ok(Self {
current: Arc::new(settings),
source: Some(source),
seen,
error: None,
loads: 1,
journal_seq: None,
vault_key_file: None,
read_files: true,
own: false,
})
}
/// For books that keep their own settings: nothing is read from
/// `source` (INVARIANT 1), not even at start, so a broken settings
/// file beside them can't stop the server; `sync` brings the books'
/// settings in.
pub fn journaled(source: Source) -> Self {
Self {
current: Arc::new(Settings {
journaled: true,
..Settings::default()
}),
source: Some(source),
seen: (None, None),
error: None,
loads: 0,
journal_seq: None,
vault_key_file: None,
read_files: false,
own: false,
}
}
/// Use this vault key file whatever the settings say (INVARIANT 5).
pub fn with_vault_key_file(mut self, path: Option<PathBuf>) -> Self {
self.vault_key_file = path;
if let Some(p) = &self.vault_key_file {
Arc::make_mut(&mut self.current).vault_key_file = Some(p.clone());
}
self
}
/// Take the books' own settings when they hold any and they changed
/// since the last look (INVARIANT 1). The server calls this whenever
/// it holds the books.
@@ -296,230 +104,34 @@ impl Live {
if !books.is_journaled() || books.last_seq() == self.journal_seq {
return;
}
let mut s = Settings::from_books(books, currency);
s.vault_key_file = self.vault_key_file.clone();
self.current = Arc::new(s);
self.current = Arc::new(Settings::from_books(books, currency));
self.journal_seq = books.last_seq();
self.read_files = false;
self.error = None;
self.loads += 1;
}
/// The settings files' texts as the server reads them now
/// (`wanderbooks.toml`, `config.json`; `None` for one absent), when the
/// settings come from files; `None` when the books keep their own or
/// the settings were built in code.
pub fn file_texts(&self) -> Option<(Option<String>, Option<String>)> {
let source = self.source.as_ref().filter(|_| self.read_files)?;
Some((
std::fs::read_to_string(&source.books_toml).ok(),
std::fs::read_to_string(&source.config_json).ok(),
))
}
/// The vault key file given on the command line, if any.
pub fn vault_key_override(&self) -> Option<PathBuf> {
self.vault_key_file.clone()
}
/// Whether the settings in force are the books' own.
pub fn is_journaled(&self) -> bool {
self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
}
/// The settings in force.
pub fn current(&self) -> Arc<Settings> {
self.current.clone()
}
/// Reload if a file changed since the last look: the server calls this
/// once at the start of every request, so the whole request sees one
/// version.
pub fn refresh(&mut self) {
if !self.read_files {
// The books keep their own settings (or the settings were
// built in code): no file is read (INVARIANT 1).
return;
}
if let Some(source) = &self.source {
let now = source.read();
if now != self.seen {
self.seen = now;
match source.load() {
Ok(mut s) => {
if let Some(p) = &self.vault_key_file {
s.vault_key_file = Some(p.clone());
}
self.current = Arc::new(s);
self.error = None;
self.loads += 1;
eprintln!("ledger-mcp: reloaded {}", source.books_toml.display());
}
Err(e) => {
eprintln!(
"ledger-mcp: settings not reloaded (keeping the last good ones): {e}"
);
self.error = Some(e);
}
}
}
}
}
/// Change the settings in force (builders; never reloads them away
/// unless a watched file changes).
#[cfg(test)]
pub fn edit(&mut self, f: impl FnOnce(&mut Settings)) {
f(Arc::make_mut(&mut self.current));
}
/// Where the settings come from and whether the last change loaded.
pub fn status(&mut self) -> Status {
self.refresh();
let journaled = self.is_journaled();
Status {
books_toml: self
.source
.as_ref()
.filter(|_| !journaled)
.map(|s| s.books_toml.clone()),
books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()),
config_json_present: self.source.as_ref().is_some_and(|s| s.config_json.exists()),
error: self.error.clone(),
loads: self.loads,
journaled,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn dir(tag: &str) -> TestDir {
let d =
std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
TestDir(d)
}
fn source(d: &std::path::Path, required: bool) -> Source {
Source {
books_toml: d.join("wanderbooks.toml"),
required,
config_json: d.join("config.json"),
}
}
/// INVARIANT 3: an absent default means the defaults, and the file
/// loads once it appears; an unchanged file is not reloaded.
/// INVARIANT 1: books that hold no settings keep the defaults; their
/// settings come in once per change, and an unchanged journal leaves
/// the settings in force as they are.
#[test]
fn an_absent_default_is_the_defaults_until_it_appears() {
let d = dir("absent");
let mut live = Live::watching(source(&d, false)).unwrap();
fn books_take_their_own_settings_once_per_change() {
let mut live = Live::own();
assert_eq!(live.current().company.name, Company::default().name);
live.refresh();
assert_eq!(live.status().loads, 1, "nothing changed");
std::fs::write(
d.join("wanderbooks.toml"),
"[company]\nname = \"Late Co\"\n",
)
.unwrap();
live.refresh();
assert_eq!(live.current().company.name, "Late Co");
assert_eq!(live.status().loads, 2);
assert!(live.status().books_toml_exists);
}
/// INVARIANT 3: a named file must exist to start; INVARIANT 2: the
/// first load fails on broken settings, a later one keeps the last.
#[test]
fn a_named_file_must_load_to_start() {
let d = dir("named");
assert!(Live::watching(source(&d, true)).is_err());
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
assert!(Live::watching(source(&d, true)).is_err());
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = \"One\"\n").unwrap();
let mut live = Live::watching(source(&d, true)).unwrap();
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = 5\n").unwrap();
live.refresh();
assert_eq!(live.current().company.name, "One");
assert!(live.status().error.is_some());
}
/// config.json sits in the data dir, which no tool result names: a
/// broken one is reported by name, never by its path.
#[test]
fn a_broken_config_json_never_names_the_data_dir() {
let d = dir("json-broken");
std::fs::write(d.join("config.json"), "{ not json").unwrap();
let err = Live::watching(source(&d, false)).unwrap_err();
assert!(err.contains("config.json"), "{err}");
assert!(!err.contains(d.to_str().unwrap()), "{err}");
let status = Live::fixed(Settings::default()).status();
assert!(!status.config_json_present);
}
/// The receipt policy in config.json is watched too.
#[test]
fn the_config_json_policy_reloads() {
let d = dir("json");
std::fs::write(
d.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"25.00\"\n",
)
.unwrap();
let mut live = Live::watching(source(&d, true)).unwrap();
let before = live.current().policy.rule("USD");
std::fs::write(
d.join("config.json"),
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
)
.unwrap();
live.refresh();
assert_ne!(live.current().policy.rule("USD"), before);
assert!(live.status().error.is_none(), "{:?}", live.status().error);
}
/// Settings built in code never reload.
#[test]
fn fixed_settings_stay() {
let mut live = Live::fixed(Settings::default());
live.refresh();
let s = live.status();
assert_eq!((s.loads, s.books_toml), (1, None));
}
/// Books that keep their own settings: no file is read, even a broken
/// one; their settings come in once per change (INVARIANT 1).
#[test]
fn journaled_books_take_their_own_settings_once_per_change() {
let d = dir("journaled");
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
let mut live = Live::journaled(source(&d, true));
assert!(live.current().journaled);
assert!(live.is_journaled());
assert!(live.file_texts().is_none());
let mut books = ledger_core::settings::Settings::default();
let before = live.current();
live.sync(&books, "USD");
assert_eq!(live.status().loads, 0, "books without settings");
assert!(
Arc::ptr_eq(&before, &live.current()),
"books without settings"
);
let src = ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
@@ -533,54 +145,14 @@ mod tests {
books.apply(&[change("Kept Co")], &src, 7).unwrap();
live.sync(&books, "USD");
assert_eq!(live.current().company.name, "Kept Co");
assert_eq!(live.status().loads, 1);
let taken = live.current();
live.sync(&books, "USD");
assert_eq!(live.status().loads, 1, "unchanged: not taken again");
assert!(
Arc::ptr_eq(&taken, &live.current()),
"unchanged: not taken again"
);
books.apply(&[change("Kept Co 2")], &src, 9).unwrap();
live.sync(&books, "USD");
assert_eq!(live.current().company.name, "Kept Co 2");
assert_eq!(live.status().loads, 2);
assert!(live.current().journaled && live.is_journaled());
}
/// Settings built in code are the books' own once synced.
#[test]
fn fixed_settings_synced_are_journaled() {
let mut live = Live::fixed(Settings::default());
assert!(!live.is_journaled());
let mut books = ledger_core::settings::Settings::default();
let src = ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
citation: None,
note: None,
};
books
.apply(
&[ledger_core::settings::Change {
key: "company.name".into(),
value: Some(serde_json::json!("Kept Co")),
}],
&src,
3,
)
.unwrap();
live.sync(&books, "USD");
assert!(live.is_journaled());
}
/// Books in the data root keep their own settings from the start
/// (INVARIANT 6): before they hold any, the settings in force say so
/// too, so no message sends anyone to a settings file; none is read.
#[test]
fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() {
let mut live = Live::own();
assert!(live.current().journaled);
assert!(live.is_journaled());
assert!(live.file_texts().is_none());
live.sync(&ledger_core::settings::Settings::default(), "USD");
assert!(live.current().journaled);
let s = live.status();
assert!(s.journaled && s.books_toml.is_none(), "{s:?}");
}
}
-3
View File
@@ -66,9 +66,6 @@ impl BooksServer {
params: Parameters<AccessLogParams>,
) -> Result<Json<AccessLogOut>, String> {
let p = params.0;
if self.access.is_none() {
return Err("these books keep no access log: only books served from the books' private folder do".into());
}
let limit = p.limit.unwrap_or(DEFAULT_RECORDS);
if limit == 0 || limit > MAX_RECORDS {
return Err(format!("limit is 1 to {MAX_RECORDS}"));
+8 -14
View File
@@ -7,9 +7,7 @@
//! SOURCE: plan 3.7 ("books.backup {status|run} reports it": two tools,
//! one read-only), D-30.
//! INVARIANTS:
//! 1. Only books served from the data root are backed up: over stdio
//! both tools are hidden, and refuse.
//! 2. A status reads the backup record only; a run makes and pushes a
//! 1. A status reads the backup record only; a run makes and pushes a
//! backup (backup.rs) and answers the status after it.
//! ```
@@ -86,14 +84,6 @@ impl From<crate::backup::Status> for BackupOut {
}
}
impl BooksServer {
fn backups(&self) -> Result<&std::sync::Arc<crate::backup::Backups>, String> {
self.backups.as_ref().ok_or_else(|| {
"these books aren't backed up by this server: only books kept in the books' own private folder are".to_string()
})
}
}
#[tool_router(router = backup_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
@@ -103,7 +93,7 @@ impl BooksServer {
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn backup(&self, _params: Parameters<BackupParams>) -> Result<Json<BackupOut>, String> {
Ok(Json(self.backups()?.status().await.into()))
Ok(Json(self.backups.status().await.into()))
}
#[tool(
@@ -121,7 +111,11 @@ impl BooksServer {
&self,
params: Parameters<BackUpNowParams>,
) -> Result<Json<BackupOut>, String> {
let b = self.backups()?;
Ok(Json(b.run_with("asked", params.0.new_disk).await?.into()))
Ok(Json(
self.backups
.run_with("asked", params.0.new_disk)
.await?
.into(),
))
}
}
+38 -43
View File
@@ -17,17 +17,17 @@
//! 2. begin_upload makes nothing for a receipt or document whose bytes
//! are stored already: it names the attachment (not withdrawn) that
//! holds them.
//! 3. Uploads exist only for books served from the data root (the
//! server's socket carries the bytes); over stdio, only inline.
//! 3. An upload is kept for the token that began it, and so is an
//! export: a call with no token (never one the books server lets
//! through) begins and takes none.
//! 4. An upload is filed only by a tool of the purpose it was begun for
//! (a receipt or document, a CSV import, a W-9); another tool's
//! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
//! before it is decoded.
//! 6. A file the books make for an agent (D-29) is handed over as an
//! export the caller's token fetches with `wanderbooks download`
//! (books served from the data root), or over stdio inline; never
//! by a path.
//! export the caller's token fetches with `wanderbooks download`;
//! never inline, never by a path.
//! ```
use super::*;
@@ -37,8 +37,10 @@ use serde::{Deserialize, Serialize};
/// The most bytes given inline (plan 2.1: 256 KiB).
pub(crate) const INLINE_MAX: usize = 256 * 1024;
/// Why an upload is refused on a connection that has none (INVARIANT 3).
const STDIO_ONLY_INLINE: &str = "this connection takes files inline only (content_base64 with its sha256, at most 256 KiB): a larger one needs these books served by `wanderbooks mcp`; tell the human";
/// Why a call with no token begins, takes or is handed no file
/// (INVARIANT 3).
const NO_CALLER: &str =
"this call came without a token for these books: a file is kept only for the token that asked";
/// What the bytes of a file a tool files came as (INVARIANT 1), and the
/// upload purposes the tool takes (INVARIANT 4).
@@ -146,7 +148,7 @@ fn plain_file_name(name: &str) -> String {
impl BooksServer {
/// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export
/// for the caller's token, or inline over stdio.
/// for the caller's token.
pub(crate) fn hand_over(
&self,
book: &str,
@@ -160,23 +162,9 @@ impl BooksServer {
None => file_name.to_string(),
Some(_) => plain_file_name(file_name),
};
let mut out = HandedFile {
file_name: file_name.clone(),
mime_type: mime.to_string(),
size_bytes: bytes.len() as u64,
sha256: ledger_store::journal::sha256_hex(&bytes),
download: None,
expires_at: None,
content_base64: None,
};
let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else {
out.content_base64 = Some(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bytes,
));
return Ok(out);
};
let kept = exports
let token = self.caller_token().ok_or(NO_CALLER)?;
let kept = self
.exports
.put(
&token,
&file_name,
@@ -185,14 +173,22 @@ impl BooksServer {
chrono::Utc::now().timestamp(),
)
.map_err(|e| e.to_string())?;
out.download = Some(format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
token_flag(),
kept.id
));
out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
Ok(out)
// Always a real time: a kept export expires EXPORT_TTL from now.
let expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.ok_or("the file's expiry is past the calendar")?;
Ok(HandedFile {
download: format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
token_flag(),
kept.id
),
expires_at,
size_bytes: bytes.len() as u64,
sha256: ledger_store::journal::sha256_hex(&bytes),
file_name,
mime_type: mime.to_string(),
})
}
/// The token of the call running on this thread (its id), if any.
@@ -210,10 +206,9 @@ impl BooksServer {
),
(Some(id), None) => {
// INVARIANT 3.
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let got = uploads
let token = self.caller_token().ok_or(NO_CALLER)?;
let got = self
.uploads
.take(id.trim(), &token, chrono::Utc::now().timestamp())
.map_err(|e| e.to_string())?;
// INVARIANT 4: dropped, the claim gives it back.
@@ -268,7 +263,7 @@ impl BooksServer {
#[tool(
name = "books.begin_upload",
title = "Begin an upload",
description = "Send a file of up to 25 MiB (books served by `wanderbooks mcp`; elsewhere only inline) without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
description = "Send a file of up to 25 MiB without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -313,11 +308,10 @@ impl BooksServer {
existing_attachment_id: Some(id),
}));
}
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
return Err(STDIO_ONLY_INLINE.into());
};
let token = self.caller_token().ok_or(NO_CALLER)?;
let now = chrono::Utc::now();
let grant = uploads
let grant = self
.uploads
.grant(
&token,
&sha,
@@ -328,9 +322,10 @@ impl BooksServer {
now.timestamp(),
)
.map_err(|e| e.to_string())?;
// Always a real time: a grant lapses UPLOAD_TTL from now.
let expires = chrono::DateTime::from_timestamp(grant.expires, 0)
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_default();
.ok_or("the upload's expiry is past the calendar")?;
Ok(Json(BeginUploadOut {
command: Some(format!(
"wanderbooks upload --book {book}{} {} -- <the file's path>",
+15 -287
View File
@@ -1,7 +1,7 @@
//! How the books are kept, through the server: `books.start`, settings
//! (`books.settings`, `books.set_setting`, `books.import_settings`,
//! `books.export_settings`), conventions, decisions, open items, drafts
//! from posting templates, and approvals (plan/served.md P1, 4.2, 4.9).
//! (`books.settings`, `books.set_setting`, `books.export_settings`),
//! conventions, decisions, open items, drafts from posting templates,
//! and approvals (plan/served.md P1, 4.2, 4.9).
//!
//! ```text
//! GOAL: an agent anywhere learns how these books are kept from the books
@@ -12,17 +12,14 @@
//! INVARIANTS:
//! 1. Every change names who decided it and when (decided_by;
//! decided_on defaults to today in the company's time zone).
//! 2. A settings file is never half moved in: books.set_setting refuses
//! while the books still read a settings file (import it first), and
//! books.import_settings is refused unless, after it, the server
//! reads every setting as it does now (company, receipt rule, cash
//! flow roles, asset tag prefix, parties, time zone, vault key).
//! 2. The books keep their own settings, and only books.set_setting
//! changes them (old books' settings file is read once, by
//! `wanderbooks books adopt`).
//! 3. Identity values (the company's EIN) leave the server masked.
//! 4. books.start is read-only and at most START_MAX bytes.
//! ```
use super::*;
use crate::settings::Settings as Live;
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::{self as core_settings, Change, KEYS, Sensitivity, Source};
use schemars::JsonSchema;
@@ -242,10 +239,6 @@ pub struct StartOut {
pub head: HeadOut,
/// needs_onboarding (no chart of accounts yet) or active.
pub state: String,
/// Where the settings come from: "books" (journaled, the books' own)
/// or "file" (these books still read a settings file: import it with
/// books.import_settings).
pub settings_source: String,
/// The rules in force.
pub rules: RulesOut,
/// Every setting set, with who set it.
@@ -322,54 +315,12 @@ pub struct SetSettingOut {
pub problems: Vec<String>,
}
/// Parameters for `books.import_settings`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ImportSettingsParams {
/// The settings file's text (wanderbooks.toml), as written.
pub toml_text: String,
/// The receipt policy file's text ({"receipt_policy": {...}}), when
/// books.settings says the server reads one (config_json_present):
/// rebuild it from books.settings' receipt rule and account lists.
#[serde(default)]
pub config_json_text: Option<String>,
/// Without true: a preview of every change, note and unknown entry;
/// nothing is recorded.
#[serde(default)]
pub confirmed: bool,
/// Import even though the file holds entries the books don't know
/// (listed in the preview as unknown; they are not imported).
#[serde(default)]
pub skip_unknown: bool,
/// Who decided to move the settings in, and when.
#[serde(flatten)]
pub source: SourceIn,
}
/// Result of `books.import_settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ImportSettingsOut {
/// "preview" or "imported".
pub status: String,
/// The changes that make the books' settings what the file says.
pub changes: Vec<ChangeOut>,
/// What the file says that isn't a books setting, and where it goes.
pub notes: Vec<String>,
/// Entries the books don't know: not imported (confirm with
/// skip_unknown, or record them as conventions first).
pub unknown: Vec<String>,
/// The file's comments, in order. They are not settings and are not
/// imported: record what matters in them (a value's source, a
/// discrepancy, something pending) as a decision, an open item, a
/// convention, or a setting's note, before or after confirming.
pub comments: Vec<String>,
}
/// Result of `books.export_settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ExportSettingsOut {
/// The settings as a settings file (TOML), each with who decided it
/// as a comment; identity values masked. A copy: editing it changes
/// nothing until imported.
/// nothing.
pub toml_text: String,
}
@@ -740,51 +691,12 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut {
}
}
/// Where two server readings of the settings differ (INVARIANT 2).
fn reads_differently(a: &Live, b: &Live) -> Vec<&'static str> {
let threshold = |s: &Live| ledger_config::decimal(s.policy.require_receipts_above.trim());
[
("company", a.company.name == b.company.name),
(
"fiscal year",
a.company.fiscal_year_start == b.company.fiscal_year_start,
),
("members", a.company.members == b.company.members),
("basis", a.company.basis == b.company.basis),
("tax form", a.company.tax_form == b.company.tax_form),
("close target", a.company.close_into == b.company.close_into),
(
"close sweep",
a.company.close_sweep == b.company.close_sweep,
),
("receipt threshold", threshold(a) == threshold(b)),
(
"receipt required accounts",
a.policy.receipt_required_accounts == b.policy.receipt_required_accounts,
),
(
"receipt exempt accounts",
a.policy.receipt_exempt_accounts == b.policy.receipt_exempt_accounts,
),
("cash accounts", a.roles.cash == b.roles.cash),
("financing accounts", a.roles.financing == b.roles.financing),
("investing accounts", a.roles.investing == b.roles.investing),
("asset tag prefix", a.tag_prefix() == b.tag_prefix()),
("parties", a.parties == b.parties),
("time zone", a.timezone == b.timezone),
]
.into_iter()
.filter(|(_, same)| !same)
.map(|(n, _)| n)
.collect()
}
impl BooksServer {
/// The books' `books.start` (INVARIANT 4).
fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut {
let cfg = self.settings();
let ledger = store.ledger();
let kept = self.settings_as_kept(store);
let kept = store.ledger().settings().clone();
let books = &kept;
let today = today();
let (fy_start, fy_end) = cfg.company.fiscal_year_containing(today);
@@ -843,7 +755,6 @@ impl BooksServer {
projects.sort_by_key(|a| (a.name.to_lowercase(), a.id));
let mut p_cut = projects.len().saturating_sub(START_PROJECTS);
projects.truncate(START_PROJECTS);
let journaled = cfg.journaled;
let focus = focus.unwrap_or("daily");
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
tool: tool.into(),
@@ -862,15 +773,6 @@ impl BooksServer {
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
));
}
// Books that read a settings file move it in before anything else
// changes a setting (books.set_setting refuses until then).
if !journaled {
next_steps.push(step(
"books.import_settings",
None,
"These books still read a settings file. Move it into the books (preview, show the human, confirm), so every agent sees one record and no file is read.",
));
}
if onboarding || focus == "onboarding" {
next_steps.push(step(
"books.handbook",
@@ -893,8 +795,8 @@ impl BooksServer {
}
// The books' backups (backup.rs): none set up, or one behind or
// failing.
if let Some(b) = &self.backups {
let st = b.standing(store);
{
let st = self.backups.standing(store);
if st.note.is_some() {
next_steps.push(step(
"books.backup",
@@ -958,12 +860,10 @@ impl BooksServer {
"active"
}
.into(),
settings_source: if journaled { "books" } else { "file" }.into(),
rules: RulesOut {
evidence: cfg.policy.rule(self.currency()),
parties: cfg.parties.key().into(),
// What the server enforces: the books' own setting (a
// settings file's is never enforced).
// What the server enforces: the books' own setting.
approval_rules: match ledger.settings().approval_rules() {
core_settings::ApprovalMode::Off => "off",
core_settings::ApprovalMode::Enforce => "enforce",
@@ -1047,19 +947,6 @@ impl BooksServer {
out
}
/// Refuse a settings change while the books still read a settings
/// file that sets something (INVARIANT 2): it would drop the rest.
fn ensure_settings_in_books(&self) -> Result<(), String> {
let status = self.live().status();
if !status.journaled && (status.books_toml_exists || status.config_json_present) {
return Err(
"these books still read their settings file: move it in with books.import_settings first (preview, then confirm), so no setting is lost; then change settings here"
.into(),
);
}
Ok(())
}
/// The settings in force after a settings change: take the books'.
fn sync_settings(&self, store: &Store) {
self.live().sync(store.ledger().settings(), self.currency());
@@ -1090,7 +977,7 @@ impl BooksServer {
#[tool(
name = "books.set_setting",
title = "Change settings",
description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing. Refused while the books still read a settings file: import it first (books.import_settings).",
description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -1104,7 +991,6 @@ impl BooksServer {
) -> Result<Json<SetSettingOut>, String> {
let p = params.0;
let source = p.source.source()?;
self.ensure_settings_in_books()?;
if p.changes.is_empty() {
return Err("changes is empty: pass at least one {key, value}".into());
}
@@ -1133,173 +1019,16 @@ impl BooksServer {
}))
}
#[tool(
name = "books.import_settings",
title = "Move a settings file into the books",
description = "Move a settings file (its TOML text, and the receipt policy file's text if the server reads one) into the books, after which no file is read. Without confirmed: a preview of every change, what the file says that isn't a books setting (notes), and entries the books don't know (unknown). Show it to the human; confirm with confirmed: true. Refused if afterwards any setting would read differently than now.",
annotations(
read_only_hint = false,
destructive_hint = true,
idempotent_hint = true,
open_world_hint = false
)
)]
async fn import_settings(
&self,
params: Parameters<ImportSettingsParams>,
) -> Result<Json<ImportSettingsOut>, String> {
let p = params.0;
let source = p.source.source()?;
let mut store = self.lock()?;
let status = self.live().status();
if let Some(e) = &status.error {
return Err(format!(
"the settings file doesn't load ({e}): fix it first, so the import moves in the settings in force"
));
}
let current = store.ledger().settings().clone();
let import = ledger_config::codec::import(
&p.toml_text,
p.config_json_text.as_deref(),
&current,
self.currency(),
)?;
let differing = |theirs: &[Change]| -> Vec<String> {
let mut keys: Vec<String> = import
.changes
.iter()
.filter(|c| !theirs.contains(c))
.chain(theirs.iter().filter(|c| !import.changes.contains(c)))
.map(|c| c.key.clone())
.collect();
keys.sort();
keys.dedup();
keys
};
if status.journaled {
// The books keep their own settings: an import may only confirm
// them (INVARIANT 2); changes go through books.set_setting.
let differs = differing(&[]);
if !differs.is_empty() {
return Err(format!(
"these books keep their own settings, and this text differs from them in {}: change settings with books.set_setting (the books record who decided each change)",
differs.join(", ")
));
}
} else if let Some((toml, json)) = self.live().file_texts() {
// INVARIANT 2: the text is the file the server reads, exactly:
// its import is the server's own reading of its files.
let own = ledger_config::codec::import(
toml.as_deref().unwrap_or(""),
json.as_deref(),
&current,
self.currency(),
)?;
let differs = differing(&own.changes);
if !differs.is_empty() {
return Err(format!(
"this text isn't the settings the server reads now: it differs in {}. Pass the settings file books.settings names, whole, as toml_text{}",
differs.join(", "),
if json.is_some() {
", and the receipt policy as config_json_text: {\"receipt_policy\": {...}} with books.settings' threshold and its required and exempt account lists"
} else {
""
}
));
}
}
// And, after it, the server reads every setting as now (the
// company, the receipt rule, the cash flow roles, the asset tag
// prefix, parties, the time zone, approvals).
let mut after = current.clone();
after
.apply(&import.changes, &source, u64::MAX)
.map_err(bad)?;
let now = self.settings();
let then = Live::from_books(&after, self.currency());
let differs = reads_differently(&now, &then);
if !differs.is_empty() {
return Err(format!(
"after this import the server would read these settings differently than it does now: {}",
differs.join(", ")
));
}
if !status.journaled && after.approval_rules() != ledger_core::settings::ApprovalMode::Off {
return Err(
"the server doesn't enforce approval rules for books that read a settings file: take approval_rules out of the file, import it, then set policy.approval_rules with books.set_setting"
.into(),
);
}
// The vault key the server uses now, and after: the books never
// keep its path, so a file's [vault] key_file has to come from the
// command line from then on (settings INVARIANT 5).
let key_now = now.key_file();
let key_then = self
.live()
.vault_key_override()
.unwrap_or_else(ledger_vault::default_key_file);
if key_now != key_then {
return Err(
"the settings file names the vault's key file ([vault] key_file), which the books don't keep: start the server with --vault-key-file naming that same file before moving the settings in, or the vault would stop opening"
.into(),
);
}
let changes = |list: &[Change]| -> Vec<ChangeOut> {
list.iter()
.map(|c| ChangeOut {
key: c.key.clone(),
was: shown(&c.key, current.get(&c.key).map(|r| &r.value)),
value: shown(&c.key, c.value.as_ref()),
})
.collect()
};
// The preview refuses what the import would: the same checks.
store
.ledger()
.check_settings_adopting(&import.changes, &source, true)
.map_err(bad)?;
let mut out = ImportSettingsOut {
status: "preview".into(),
changes: changes(&import.changes),
notes: import.notes.clone(),
unknown: import.unknown.clone(),
comments: import.comments.clone(),
};
if !p.confirmed {
return Ok(Json(out));
}
if !import.unknown.is_empty() && !p.skip_unknown {
return Err(format!(
"the file holds entries the books don't know ({}): they would not be imported. Show them to the human; record any that matter as conventions (books.set_convention), then confirm with skip_unknown: true",
import.unknown.join(", ")
));
}
let made = store
.adopt_settings(&import.changes, &source)
.map_err(bad)?;
self.sync_settings(&store);
out.status = "imported".into();
out.changes = changes(&made);
Ok(Json(out))
}
#[tool(
name = "books.export_settings",
title = "Settings as a file",
description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading or for other books: editing it changes nothing here until imported.",
description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading: editing it changes nothing here.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export_settings(&self) -> Result<Json<ExportSettingsOut>, String> {
let store = self.lock()?;
// The books' own: the defaults until the first is set.
let s = store.ledger().settings();
// Books in the data root read no file: theirs are the defaults
// until the first is set.
if !s.is_journaled() && !self.live().status().journaled {
return Err(
"these books still read their settings file, which is the copy: move it in with books.import_settings first"
.into(),
);
}
Ok(Json(ExportSettingsOut {
toml_text: ledger_config::codec::export(s, self.currency()),
}))
@@ -1655,8 +1384,7 @@ pub(super) fn keys_out(books: &core_settings::Settings) -> Vec<SettingKeyOut> {
identity: k.sensitivity == Sensitivity::Identity,
value: r.map(|r| core_settings::display(k, &r.value, false)),
set_by: r.map(|r| source_out(&r.source)),
// Seq 0: read from the settings file, not journaled.
seq: r.map(|r| r.seq).filter(|q| *q > 0),
seq: r.map(|r| r.seq),
}
})
.collect()
+58 -72
View File
@@ -536,13 +536,13 @@ pub struct Vendor1099Params {
#[serde(default)]
pub csv: bool,
/// The cash account codes payments are counted out of, e.g.
/// `["1000"]`. Defaults to `[books] cash_accounts`, as the CPA
/// `["1000"]`. Defaults to `books.cash_accounts`, as the CPA
/// package's do: one or the other is required.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// The financing liability codes (what the members advanced, loans)
/// payments are counted out of. Defaults to `[books]
/// financing_accounts`, as the CPA package's do.
/// payments are counted out of. Defaults to
/// `books.financing_accounts`, as the CPA package's do.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Count only the payments dated on or before this day of `year`
@@ -782,13 +782,23 @@ pub struct ChartTemplateOut {
/// Chart CSV (code,name,type,parent_code,tax_category) for
/// `books.import_chart_csv`, after review.
pub csv: String,
/// The settings that go with it, as settings-file TOML for
/// `books.import_settings`: tax form, owners, where period closes go.
pub settings_toml: String,
/// The settings that go with it, as changes for `books.set_setting`
/// (after review, with who decided): tax form, owners, where period
/// closes go.
pub settings: Vec<TemplateSettingOut>,
/// What to decide or know.
pub notes: Vec<String>,
}
/// One setting a chart template goes with.
#[derive(Debug, Serialize, JsonSchema)]
pub struct TemplateSettingOut {
/// The key.
pub key: String,
/// Its value, as `books.set_setting` takes it.
pub value: serde_json::Value,
}
/// Parameters for `books.renumber_account`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RenumberAccountParams {
@@ -807,10 +817,9 @@ pub struct RenumberAccountOut {
pub previous_code: String,
/// The code it has now.
pub code: String,
/// Settings that still name the old code (as books.set_setting keys,
/// or the settings file's labels for books whose settings are in a
/// file), and conventions whose template or approval rule names it:
/// change each (books.set_setting, books.set_convention).
/// Settings that still name the old code (as books.set_setting keys),
/// and conventions whose template or approval rule names it: change
/// each (books.set_setting, books.set_convention).
pub config_mentions: Vec<String>,
}
@@ -832,10 +841,6 @@ pub struct MemberSettingOut {
/// Result of `books.settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SettingsOut {
/// Where the settings come from: "books" (the books keep them; change
/// them with books.set_setting) or "file" (a settings file these books
/// still read: move it in with books.import_settings).
pub source: String,
/// The company's time zone (IANA), or null: the computer's.
pub timezone: Option<String>,
/// Today, in that time zone.
@@ -845,27 +850,14 @@ pub struct SettingsOut {
/// Every setting the books know: what it takes, its value (null:
/// unset, its default applies), and who set it.
pub keys: Vec<crate::server::SettingKeyOut>,
/// The wanderbooks.toml the server reads (null: the books keep their
/// own settings, or settings built in).
pub books_toml: Option<String>,
/// Whether that file exists (absent: the defaults apply).
pub books_toml_exists: bool,
/// Whether the server reads a receipt policy from a configuration
/// file of its own.
pub config_json_present: bool,
/// Why the last edit failed to load, or null. While set, the settings
/// below are the last ones that loaded: fix the file.
pub reload_error: Option<String>,
/// How many times the settings have loaded (the first included); it
/// goes up each time an edit loads.
pub loads: u64,
/// `[company] name`.
/// `company.name`.
pub company: String,
/// `[company] fiscal_year_start`, "MM-DD".
/// `company.fiscal_year_start`, "MM-DD".
pub fiscal_year_start: String,
/// The return filed (`tax_form`, or from `tax_classification`), or null.
/// The return filed (`company.tax_form`, or from
/// `company.tax_classification`), or null.
pub tax_form: Option<String>,
/// `[books] accounting_basis`.
/// `books.accounting_basis`.
pub basis: String,
/// The receipt rule in force (it shows a few account codes of each
/// kind, shortened; the two lists below hold them all).
@@ -877,22 +869,22 @@ pub struct SettingsOut {
/// every line is in one needs none): the policy's whole list, as
/// written.
pub receipt_exempt_accounts: Vec<String>,
/// `[books] parties`: "off", "suggest", or "require".
/// `policy.parties`: "off", "suggest", or "require".
pub parties: String,
/// `[[company.members]]`.
/// `company.members`.
pub members: Vec<MemberSettingOut>,
/// `[books] cash_accounts`.
/// `books.cash_accounts`.
pub cash_accounts: Vec<String>,
/// `[books] financing_accounts`.
/// `books.financing_accounts`.
pub financing_accounts: Vec<String>,
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
pub investing_accounts: Vec<String>,
/// `[books] close_into`.
/// `books.close_into`.
pub close_into: Option<String>,
/// `[books] close_sweep`.
/// `books.close_sweep`.
pub close_sweep: Vec<String>,
/// Settings that disagree with the books, each saying what to change in
/// wanderbooks.toml (or the chart). Empty: nothing to fix.
/// Settings that disagree with the books, each saying what to change
/// (with books.set_setting, or in the chart). Empty: nothing to fix.
pub problems: Vec<String>,
}
@@ -1415,7 +1407,7 @@ pub struct DraftEntryParams {
/// `vendor`, both must name the same vendor.
pub party: Option<String>,
/// Why the entry has no counterparty (a transfer between the company's
/// own accounts, an accrual), when `[books] parties = "require"` and
/// own accounts, an accrual), when `policy.parties = "require"` and
/// none applies. Not with `party`, `parties`, or `vendor`.
pub no_party_reason: Option<String>,
/// Several parties, each with their share (as `books.set_entry_party`
@@ -1605,7 +1597,7 @@ pub struct PostEntryOut {
/// receipt threshold (same date, description, and debit accounts).
/// Empty when no split is detected.
pub split_detected: Vec<u64>,
/// `[books] parties`: "off", "suggest", or "require".
/// `policy.parties`: "off", "suggest", or "require".
pub party_policy: String,
/// The entry's counterparty, when it names exactly one.
pub party_id: Option<u64>,
@@ -2089,11 +2081,11 @@ pub struct DigestOut {
/// lost; tell the human, and if they had asked for a change moments before,
/// check it is there and redo it if not.
pub journal_repairs: Vec<JournalRepair>,
/// Settings in wanderbooks.toml that need changing: an edit that
/// didn't load, or a setting that disagrees with the chart. Fix each
/// with the human, then check books.settings.
/// Settings that need changing: a setting that disagrees with the
/// chart. Fix each with the human (books.set_setting), then check
/// books.settings.
pub settings_problems: Vec<String>,
/// Cash and card accounts (`[books] cash_accounts`, and liabilities
/// Cash and card accounts (`books.cash_accounts`, and liabilities
/// with a recorded statement) with activity not reconciled through the
/// end of last month: record each statement and reconcile it.
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
@@ -2167,17 +2159,17 @@ pub struct ExportParams {
#[schemars(description = export_as_of_doc())]
pub as_of: Option<String>,
/// Cash account codes for cash_flow, and for iif its BANK accounts.
/// Defaults to `[books] cash_accounts`; one or the other is required
/// Defaults to `books.cash_accounts`; one or the other is required
/// for cash_flow.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing for cash_flow.
/// Defaults to `[books] financing_accounts`, else empty.
/// Defaults to `books.financing_accounts`, else empty.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the
/// company makes, investments it buys (ASC 230-10-20). Defaults to
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
#[serde(default)]
pub investing_accounts: Vec<String>,
}
@@ -2261,8 +2253,8 @@ pub struct RegisterAssetParams {
/// Depreciation expense account, id or code.
pub expense_account: String,
/// The asset tag for its label (letters, digits, - _ . /; at most 32).
/// Omit to generate the next one ("FA-0001", from `[books]
/// asset_tag_prefix` or the company's initials).
/// Omit to generate the next one ("FA-0001", from
/// `books.asset_tag_prefix` or the company's initials).
pub tag: Option<String>,
/// The manufacturer's serial number, if known.
pub serial: Option<String>,
@@ -2750,7 +2742,7 @@ pub struct DisposeAssetParams {
/// receipt policy applies.
pub receipt_attachment_ids: Option<Vec<u64>>,
/// The buyer (party id or name), named on the disposal entry. Under
/// `[books] parties = "require"` a sale with proceeds needs one; a
/// `policy.parties = "require"` a sale with proceeds needs one; a
/// retirement with no proceeds and no buyer is marked as having no
/// counterparty.
#[serde(default)]
@@ -3266,7 +3258,7 @@ pub struct RemitPayrollTaxesParams {
/// Cash account id or code the remittance comes from.
pub cash_account: String,
/// The tax agency paid (party id or name, e.g. the IRS), named on the
/// remittance entry. Required under `[books] parties = "require"`.
/// remittance entry. Required under `policy.parties = "require"`.
#[serde(default)]
pub agency: Option<String>,
}
@@ -3640,17 +3632,17 @@ pub struct ExportCpaPackageParams {
#[schemars(description = AS_OF_DOC)]
pub as_of: Option<String>,
/// Cash account codes the cash flow statement reconciles to, e.g.
/// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other
/// `["1000"]`. Defaults to `books.cash_accounts`; one or the other
/// is required.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Liability account codes treated as financing. Defaults to `[books]
/// financing_accounts`, else empty.
/// Liability account codes treated as financing. Defaults to
/// `books.financing_accounts`, else empty.
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the
/// company makes, investments it buys (ASC 230-10-20). Defaults to
/// `[books] investing_accounts`.
/// `books.investing_accounts`.
#[serde(default)]
pub investing_accounts: Vec<String>,
/// Include every receipt for the year's entries (every current file
@@ -3895,16 +3887,11 @@ pub struct HandedFile {
pub size_bytes: u64,
/// Its SHA-256, hex.
pub sha256: String,
/// The command that saves it in a folder you name (run it; books
/// served by `wanderbooks mcp`). Kept 15 minutes.
#[serde(skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
/// When it lapses (UTC), with `download`.
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
/// Over stdio: its bytes, base64.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>,
/// The command that saves it in a folder you name (run it). Kept 15
/// minutes.
pub download: String,
/// When it lapses (UTC).
pub expires_at: String,
}
/// Result of `books.get_w9`: the W-9 file, handed over.
@@ -4026,7 +4013,7 @@ pub struct SetEntryPartyParams {
/// shares that don't add up to what it moved.
pub parties: Option<Vec<PartyShareParam>>,
/// With `party` null: mark the entry as having no counterparty, with
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
/// why (journaled as `entry.no_party`). Satisfies `policy.parties =
/// "require"`.
pub no_party_reason: Option<String>,
}
@@ -4159,12 +4146,11 @@ pub struct ListPartiesOut {
/// reversal counts on its original's side and nets it to zero. (Not the
/// books' accounting method.)
///
/// Money is what left or reached the `[books] cash_accounts` (every asset
/// Money is what left or reached the `books.cash_accounts` (every asset
/// account but receivables when none are set), the cards (the
/// business-card payment methods' liabilities, `books.instruments`; while
/// none is recorded, liabilities whose names say "card", not "gift
/// card"), and the `[books]
/// financing_accounts` (what the members
/// card"), and the `books.financing_accounts` (what the members
/// advanced, loans): a card charge or a member paying for the company is
/// paid to the payee then; cash that pays one of these down is paid to
/// its creditor (a reimbursement to the members, a card payment to the
-236
View File
@@ -1,236 +0,0 @@
//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the
//! transport doesn't do.
//!
//! ```text
//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says
//! it gets, even one rmcp would drop.
//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request,
//! id echoed, or null when it can't be detected; 5.1: -32700 Parse
//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches.
//! INVARIANTS:
//! 1. A line that isn't JSON gets {"code": -32700} with id null.
//! 2. JSON that isn't a request, notification, or response object (an
//! array, a scalar, an object without "jsonrpc": "2.0" and a string
//! "method" or a result/error) gets {"code": -32600}, with the
//! line's id when it has a valid one (string, number, null), else
//! null.
//! 3. Everything else goes to rmcp unchanged; a blank line is ignored.
//! 4. Output lines are written whole: replies made here never split a
//! line rmcp writes.
//! ```
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::mpsc;
/// What to do with one line from the client.
#[derive(Debug, PartialEq)]
pub enum Line {
/// Hand it to rmcp.
Forward,
/// Ignore it (blank).
Skip,
/// Answer it here with this JSON-RPC response.
Reply(Value),
}
/// Classify one line (INVARIANTS 1-3).
pub fn classify(line: &str) -> Line {
if line.trim().is_empty() {
return Line::Skip;
}
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")),
};
let Some(obj) = v.as_object() else {
return Line::Reply(error(Value::Null, -32600, "Invalid Request"));
};
let id = match obj.get("id") {
Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(),
_ => Value::Null,
};
let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
let id_ok = match obj.get("id") {
None => true,
Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null),
};
let request = obj.get("method").is_some_and(Value::is_string);
let response =
obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error"));
if version_ok && id_ok && (request || response) {
Line::Forward
} else {
Line::Reply(error(id, -32600, "Invalid Request"))
}
}
fn error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
/// Run the wire between the real `input`/`output` and rmcp's ends:
/// returns (what rmcp reads, what rmcp writes) and the task that must be
/// awaited after rmcp finishes, so the last lines are flushed.
pub fn spawn<R, W>(
input: R,
mut output: W,
) -> (
tokio::io::DuplexStream,
tokio::io::DuplexStream,
tokio::task::JoinHandle<()>,
)
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
const BUF: usize = 1 << 20;
let (rmcp_in, mut feed) = tokio::io::duplex(BUF);
let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF);
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let replies = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(input).lines();
while let Ok(Some(line)) = lines.next_line().await {
match classify(&line) {
Line::Skip => {}
Line::Forward => {
if feed
.write_all(format!("{line}\n").as_bytes())
.await
.is_err()
{
break;
}
}
Line::Reply(v) => {
let _ = replies.send(v.to_string());
}
}
}
// Dropping `feed` ends rmcp's input.
});
tokio::spawn(async move {
let mut lines = BufReader::new(from_rmcp).lines();
while let Ok(Some(line)) = lines.next_line().await {
if tx.send(line).is_err() {
break;
}
}
});
let writer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
if output
.write_all(format!("{line}\n").as_bytes())
.await
.is_err()
|| output.flush().await.is_err()
{
break;
}
}
});
(rmcp_in, rmcp_out, writer)
}
#[cfg(test)]
mod tests {
use super::*;
fn code(l: Line) -> (i64, Value) {
match l {
Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()),
other => panic!("{other:?}"),
}
}
#[test]
fn classifies_by_the_jsonrpc_spec() {
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)),
(-32700, Value::Null)
);
assert_eq!(code(classify("not json")), (-32700, Value::Null));
assert_eq!(code(classify("[]")), (-32600, Value::Null));
assert_eq!(code(classify("[1]")), (-32600, Value::Null));
assert_eq!(code(classify("5")), (-32600, Value::Null));
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","id":98}"#)),
(-32600, json!(98))
);
assert_eq!(
code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)),
(-32600, json!("a"))
);
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)),
(-32600, Value::Null)
);
assert_eq!(
code(classify(r#"{"jsonrpc":"2.0","method":5}"#)),
(-32600, Value::Null)
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
Line::Forward
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
Line::Forward
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#),
Line::Forward
);
assert_eq!(
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#),
Line::Forward
);
assert_eq!(
code(classify(
r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"#
)),
(-32600, json!(7))
);
assert_eq!(classify(" "), Line::Skip);
}
/// Counts the lines written to it; every flush fails.
struct FailingFlush(std::sync::Arc<std::sync::atomic::AtomicUsize>);
impl AsyncWrite for FailingFlush {
fn poll_write(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
let lines = buf.iter().filter(|&&b| b == b'\n').count();
self.0.fetch_add(lines, std::sync::atomic::Ordering::SeqCst);
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Err(std::io::Error::other("closed")))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
/// A client that can't take a flushed reply is gone: the writer stops
/// after the first, rather than writing on into a dead channel.
#[tokio::test]
async fn a_failed_flush_stops_the_writer() {
let written = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let input = std::io::Cursor::new(b"not json\nnor this\n".to_vec());
let (rmcp_in, rmcp_out, writer) = spawn(input, FailingFlush(written.clone()));
drop((rmcp_in, rmcp_out));
writer.await.unwrap();
assert_eq!(written.load(std::sync::atomic::Ordering::SeqCst), 1);
}
}