P1: the books keep how they are kept

Settings, conventions (posting templates, approval rules, policies,
close steps, calendar, mappings, notes), decisions, open items and
entry approvals are journal events (journal format 2). Books that keep
their settings never read wanderbooks.toml or config.json; the server
and the viewer take the vault key from --vault-key-file instead.
books.import_settings moves a settings file in once, exactly as the
server reads it. Approval rules, when enforced, gate every posting
path. Replay refuses what it can't read as newer, never as corrupt.

Lean: a record changes no figure. Mutation on the whole diff: every
survivor killed by a test but two equivalent (docs/testing.md). The
Company page says a late open item is overdue in words, not colour
alone.

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-17 19:27:48 -06:00
1 parent e60ecc5317
commit c587593d78
71 files changed
+12249 -461

No files matched your search

+67 -17
View File
@@ -12,8 +12,8 @@
//! in the handbook, by section.
//! INVARIANTS:
//! 1. The connect text is at most CONNECT_MAX characters, and its first
//! paragraph is at most CONNECT_LEAD characters and names
//! books.handbook.
//! paragraph is at most CONNECT_LEAD characters and names books.start
//! and books.handbook.
//! 2. The connect text and every handbook section state the receipt
//! rule in force (from the settings, in the books' base currency:
//! ReceiptPolicy::rule), never a fixed one; the connect text states
@@ -21,6 +21,9 @@
//! 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.
//! 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
@@ -169,11 +172,11 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
};
let lead = format!(
"wanderbooks: the double-entry books of {name}. You are their bookkeeping agent. \
Read books.handbook (or books://handbook) first: it holds every procedure. These \
tools are the only way to the books; never look for them in local files. Amounts \
are decimal strings (\"123.45\"), never floats. Entries: books.draft_entry, \
books.add_line, a preview from books.post_entry without confirmed, shown to the \
human, then books.post_entry with confirmed: true."
Call books.start first each session: rules, conventions, open items. \
books.handbook has every procedure. These tools are the only way to the books; \
never look for them in local files. Amounts are decimal strings (\"123.45\"), \
never floats. Entries: books.draft_entry, books.add_line, books.post_entry without \
confirmed for a preview shown to the human, then with confirmed: true."
);
// Closes, depreciation and amortization post with no party: the rule
// is for the entries the agent posts.
@@ -197,6 +200,23 @@ 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 {
""
}
)
}
};
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 { "" };
@@ -209,8 +229,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
depreciation, amortization{} and closes{} need none. Invoice, bill, payment and \
payroll tools post at once{}: attach their evidence right after. A full TIN goes \
only into the vault{}: never repeat one. After each change, show the affected \
balances{}; after posting, check books.digest and offer it to the human.{} The \
only local file you may edit is the settings file books.settings names{}.",
balances{}; after posting, check books.digest and offer it to the human.{}{}",
unless_terse(", previewed the same way"),
because(" (their schedules are the evidence)"),
because(" (derived from the books)"),
@@ -222,7 +241,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
} else {
""
},
because(" (handbook: settings-and-conventions)"),
settings_rule(why),
)
};
let mut text = String::new();
@@ -283,7 +302,7 @@ mod tests {
fn instructions_budget() {
use ledger_config::PartyPolicy;
let long = "A Very Long Legal Name ".repeat(20);
for s in [
let every = [
settings("", Decimal::ZERO),
settings("Example Labs LLC", Decimal::new(2500, 2)),
settings("Tiny", Decimal::new(1, 3)),
@@ -292,7 +311,14 @@ mod tests {
longest_rule(&long, PartyPolicy::Require),
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) {
let text = connect_text(&s, "USD");
let chars = text.chars().count();
assert!(chars <= CONNECT_MAX, "{chars} chars: {text}");
@@ -303,10 +329,12 @@ mod tests {
lead.chars().count()
);
for must in [
"books.start",
"books.handbook",
"never look for them in local files",
"decimal strings",
"books.post_entry with confirmed: true",
"books.post_entry without confirmed for a preview",
"then with confirmed: true",
] {
assert!(lead.contains(must), "the lead lacks {must:?}: {lead}");
}
@@ -317,16 +345,26 @@ mod tests {
.any(|&n| text.contains(&s.policy.rule_listing("USD", n))),
"{text}"
);
// What every entry must be, and the one file the agent edits.
// What every entry must be, and where settings live: the one
// file the agent may edit, or none.
for must in [
"balances exactly",
"real account from books.chart",
"the settings file books.settings names",
"post at once",
"after posting, check books.digest and offer it to the human.",
] {
assert!(text.contains(must), "lacks {must:?}: {text}");
}
assert_eq!(
text.contains("the settings file books.settings names"),
!s.journaled,
"{text}"
);
assert_eq!(
text.contains("Settings and conventions live in the books"),
s.journaled,
"{text}"
);
// The parties rule in force (INVARIANT 2), and only it.
let party = [
(
@@ -476,10 +514,22 @@ mod tests {
for why in [
"(their schedules are the evidence)",
"(derived from the books)",
"(handbook: settings-and-conventions)",
"(until books.import_settings)",
"(the parties setting requires it)",
] {
assert!(text.contains(why), "{text}");
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}");
}
}
+42 -11
View File
@@ -49,12 +49,15 @@ fn default_data_dir() -> PathBuf {
fn print_usage() {
eprintln!(
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR] [--import-root DIR]..."
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..."
);
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 (default <data-dir>/../wanderbooks.toml, optional)"
" --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"
@@ -68,6 +71,7 @@ fn main() -> 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 import_roots: Vec<PathBuf> = Vec::new();
let mut args = std::env::args().skip(1);
@@ -84,6 +88,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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")?);
}
@@ -109,12 +118,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ImportRoots::new(import_roots)
};
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
let config: Config = if config_path.exists() {
let raw = std::fs::read_to_string(&config_path)?;
serde_json::from_str(&raw)
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?
} else {
Config::default()
// 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());
@@ -130,9 +144,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}),
config_json: config_path.clone(),
};
let live = settings::Live::watching(source)?;
eprintln!("ledger-mcp: company {:?}", live.current().company.name);
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
match open_waiting_for_lock(&data_dir) {
@@ -146,6 +157,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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})");
@@ -160,6 +172,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Some(why) = &report.healed {
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
}
// 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));
// No import root may be `/`, the home folder, or hold the books, the
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
// server here, a default one is dropped.
+396 -45
View File
@@ -332,36 +332,57 @@ fn vendor_1099_report(
}
impl BooksServer {
/// The wanderbooks.toml settings that name account `code`.
/// The settings that name account `code`, by key (or, for books
/// that still read a settings file, as the file spells them).
fn config_mentions(&self, code: &str) -> Vec<String> {
let mut out = Vec::new();
for (key, codes) in [
("[books] cash_accounts", &self.settings().roles.cash),
let settings = self.settings();
for (key, in_file, codes) in [
(
"[books] financing_accounts",
&self.settings().roles.financing,
"books.cash_accounts",
"[books] cash_accounts",
&settings.roles.cash,
),
(
"books.financing_accounts",
"[books] financing_accounts",
&settings.roles.financing,
),
(
"books.investing_accounts",
"[books] investing_accounts",
&self.settings().roles.investing,
&settings.roles.investing,
),
(
"books.close_sweep",
"[books] close_sweep",
&settings.company.close_sweep,
),
(
"policy.evidence.required_accounts",
"receipt_required_accounts",
&settings.policy.receipt_required_accounts,
),
(
"policy.evidence.exempt_accounts",
"receipt_exempt_accounts",
&settings.policy.receipt_exempt_accounts,
),
] {
if codes.iter().any(|c| c == code) {
out.push(key.to_string());
out.push(self.setting_label(key, in_file));
}
}
if self.settings().company.close_into.as_deref() == Some(code) {
out.push("[company] close_into".to_string());
if settings.company.close_into.as_deref() == Some(code) {
out.push(self.setting_label("books.close_into", "[books] close_into"));
}
for m in &self.settings().company.members {
let members = self.setting_label("company.members", "[[company.members]]");
for m in &settings.company.members {
if m.capital_account == code {
out.push(format!("[[company.members]] {} capital_account", m.name));
out.push(format!("{members} {} capital_account", m.name));
}
if m.distribution_account.as_deref() == Some(code) {
out.push(format!(
"[[company.members]] {} distribution_account",
m.name
));
out.push(format!("{members} {} distribution_account", m.name));
}
}
out
@@ -418,6 +439,56 @@ impl BooksServer {
self.settings.lock().unwrap_or_else(|e| e.into_inner())
}
/// The settings in force as the books would keep them: the books' own,
/// or, for books that still read a settings file, that file's reading
/// (no source: the file is the source).
fn settings_as_kept(&self, store: &Store) -> ledger_core::settings::Settings {
let books = store.ledger().settings();
if books.is_journaled() {
return books.clone();
}
let Some((toml, json)) = self.live().file_texts() else {
return books.clone();
};
let empty = ledger_core::settings::Settings::default();
match ledger_config::codec::import(
toml.as_deref().unwrap_or(""),
json.as_deref(),
&empty,
self.currency(),
) {
Ok(i) => {
let mut s = empty;
let file = ledger_core::settings::Source {
decided_by: "the settings file".into(),
decided_on: today(),
citation: None,
note: None,
};
match s.apply(&i.changes, &file, 0) {
Ok(()) => s,
Err(_) => books.clone(),
}
}
Err(_) => books.clone(),
}
}
/// How a message names a setting: by its key when the books keep
/// their own settings, else as the settings file spells it.
fn setting_label(&self, key: &str, in_file: &str) -> String {
if self.settings().journaled {
key.to_string()
} else {
in_file.to_string()
}
}
/// Every tool the server serves.
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::core_router() + Self::kept_router()
}
/// The settings in force for this request (reloaded at its start, in
/// `call_tool` and `read_resource`, if their files changed).
fn settings(&self) -> Arc<Settings> {
@@ -485,10 +556,10 @@ impl BooksServer {
/// statement of members' equity.
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
if self.settings().company.members.is_empty() {
return Err(
"no members configured: add [[company.members]] (name, share, capital_account) to the settings"
.to_string(),
);
return Err(format!(
"no members configured: set {} (name, share, capital_account for each)",
self.setting_label("company.members", "[[company.members]] in the settings")
));
}
self.settings()
.company
@@ -820,6 +891,16 @@ impl BooksServer {
if self.store.stopped() {
return Err(STOPPED.to_string());
}
// Books that keep their own settings: take any change (crate::
// settings INVARIANT 1).
let timezone = {
let mut live = self.live();
live.sync(guard.ledger().settings(), self.store.currency.code());
live.current().timezone.clone()
};
// "Today" from here on is the zone in force now (a change another
// session made while this request waited included).
TIMEZONE.with(|z| *z.borrow_mut() = timezone);
Ok(StoreGuard {
guard,
shared: &self.store,
@@ -921,15 +1002,59 @@ fn parse_side(s: &str) -> Result<Side, String> {
/// what to change. A code must name an account in the chart, of the kind
/// the setting needs (cash and investing: assets; financing: liabilities;
/// capital, distributions, close_into, close_sweep: equity).
/// The live conventions that name account `code` (a posting template's
/// line, an approval rule's accounts): after a renumber, each still names
/// the old code until revised.
fn convention_mentions(ledger: &Ledger, code: &str) -> Vec<String> {
convention_codes(ledger)
.into_iter()
.filter(|(_, c)| c == code)
.map(|(id, _)| format!("convention {id} (revise it with books.set_convention)"))
.collect()
}
/// Every account code a live convention names: (convention id, code).
fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> {
let mut out = Vec::new();
for c in ledger
.conventions()
.conventions()
.filter(|c| c.retired.is_none())
{
if let Some(t) = c.template() {
for l in t.lines {
if ledger_core::conventions::slot(&l.account).is_none() {
out.push((c.id.clone(), l.account));
}
}
}
if let Some(r) = c.approval_rule() {
for a in r.accounts {
out.push((c.id.clone(), a));
}
}
}
out.dedup();
out
}
fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
let mut out = Vec::new();
// Named as the books keep them, or as the settings file spells them.
let label =
|key: &'static str, in_file: &'static str| if cfg.journaled { key } else { in_file };
let how = if cfg.journaled {
" with books.set_setting"
} else {
" in the settings"
};
let mut check = |setting: &str, code: &str, want: &[AccountType]| {
match ledger.chart().find_by_code(code) {
None => out.push(format!(
"{setting} names {code}, which isn't in the chart: add that account, or change {setting} in the settings"
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
)),
Some(a) if !want.contains(&a.account_type) => out.push(format!(
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting} in the settings",
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}",
a.name,
a.account_type,
want.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>().join(" or "),
@@ -939,34 +1064,71 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
};
use AccountType::*;
for code in &cfg.roles.cash {
check("[books] cash_accounts", code, &[Asset]);
check(
label("books.cash_accounts", "[books] cash_accounts"),
code,
&[Asset],
);
}
for code in &cfg.roles.financing {
check("[books] financing_accounts", code, &[Liability, Equity]);
check(
label("books.financing_accounts", "[books] financing_accounts"),
code,
&[Liability, Equity],
);
}
for code in &cfg.roles.investing {
check("[books] investing_accounts", code, &[Asset]);
check(
label("books.investing_accounts", "[books] investing_accounts"),
code,
&[Asset],
);
}
let c = &cfg.company;
for m in &c.members {
check(
&format!("[[company.members]] {} capital_account", m.name),
&format!(
"{} {} capital_account",
label("company.members", "[[company.members]]"),
m.name
),
&m.capital_account,
&[Equity],
);
if let Some(d) = &m.distribution_account {
check(
&format!("[[company.members]] {} distribution_account", m.name),
&format!(
"{} {} distribution_account",
label("company.members", "[[company.members]]"),
m.name
),
d,
&[Equity],
);
}
}
if let Some(code) = &c.close_into {
check("[books] close_into", code, &[Equity]);
check(
label("books.close_into", "[books] close_into"),
code,
&[Equity],
);
}
for code in &c.close_sweep {
check("[books] close_sweep", code, &[Equity]);
check(
label("books.close_sweep", "[books] close_sweep"),
code,
&[Equity],
);
}
// Conventions naming an account no longer in the chart (renumbered or
// never there): a template can't draft, a rule covers nothing.
for (id, code) in convention_codes(ledger) {
if ledger.chart().find_by_code(&code).is_none() {
out.push(format!(
"convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention"
));
}
}
out
}
@@ -1218,6 +1380,24 @@ fn resource_list() -> Vec<Resource> {
r
};
let resources = vec![
mk(
"books://start",
"start",
"Start here",
"The books in brief: rules in force, settings, conventions, open items, next steps (books.start).",
),
mk(
"books://settings",
"settings",
"Settings",
"Every setting the books know, its value and who set it (books.settings).",
),
mk(
"books://conventions",
"conventions",
"Conventions",
"How this company keeps its books: templates, rules, policies, close steps (books.conventions).",
),
mk(
"books://chart",
"chart",
@@ -1925,7 +2105,7 @@ fn party_required(what: &str) -> String {
serde_json::json!({
"code": "party_required",
"message": format!(
"these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)"
"these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)"
),
})
.to_string()
@@ -2274,7 +2454,7 @@ fn cash_flow_statement(
let cash_accounts = resolve(cash_codes, "cash")?;
if cash_accounts.is_empty() {
return Err(
"cash_accounts is required for the cash flow statement (pass it, or set [books] cash_accounts in the settings)"
"cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)"
.to_string(),
);
}
@@ -2313,10 +2493,38 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
}
}
/// Today in the books' local time zone. Not UTC: in the evening (US
/// time) UTC is already tomorrow, and "through today" must not include it.
thread_local! {
/// The company's time zone for the request running on this thread
/// (set at its start from the settings in force: one version per
/// request), so every "today" in it agrees.
static TIMEZONE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
}
/// Poll `fut` with "today" in the request's zone on this thread, each
/// time it runs: the request is driven on its own thread (run_blocking),
/// so the zone is the request's alone. It starts as `timezone`; when the
/// request locks the books the zone in force then replaces it (lock), and
/// that carries over to the request's later polls.
async fn in_timezone_async<T>(
timezone: Option<String>,
fut: impl std::future::Future<Output = T>,
) -> T {
let mut fut = std::pin::pin!(fut);
let mut zone = timezone;
std::future::poll_fn(move |cx| {
let before = TIMEZONE.with(|z| z.replace(zone.take()));
let polled = fut.as_mut().poll(cx);
zone = TIMEZONE.with(|z| z.replace(before));
polled
})
.await
}
/// Today in the company's time zone (`company.timezone`, for the request
/// running), else the computer's. Not UTC: in the evening (US time) UTC
/// is already tomorrow, and "through today" must not include it.
fn today() -> NaiveDate {
chrono::Local::now().date_naive()
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
}
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
@@ -2454,7 +2662,11 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
// Tools
// ---------------------------------------------------------------------------
#[tool_router]
#[path = "tools/kept.rs"]
mod kept;
pub use kept::SettingKeyOut;
#[tool_router(router = core_router)]
impl BooksServer {
#[tool(
name = "books.handbook",
@@ -2949,10 +3161,35 @@ impl BooksServer {
no_party_reason,
party_missing,
party_suggestions,
approvals_missing: store
.ledger()
.approvals_missing(p.entry_id)
.map_err(|e| e.to_string())?
.into_iter()
.map(|(rule, approver)| ApprovalNeededOut { rule, approver })
.collect(),
};
if !p.confirmed.unwrap_or(false) {
return Ok(Json(out));
}
if !out.approvals_missing.is_empty() {
let missing: Vec<String> = out
.approvals_missing
.iter()
.map(|a| format!("{} (rule {})", a.approver, a.rule))
.collect();
return Err(serde_json::json!({
"code": "approval_required",
"message": format!(
"entry {} needs approval before it posts (policy.approval_rules is enforce): {}. Ask each approver; when they approve, record it with books.approve_entry, then post.",
p.entry_id,
missing.join("; ")
),
"entry_id": p.entry_id,
"remedy_tool": "books.approve_entry",
})
.to_string());
}
if let Some(why) = &out.parties_error {
return Err(serde_json::json!({
"code": "party_shares",
@@ -2974,8 +3211,9 @@ impl BooksServer {
return Err(serde_json::json!({
"code": "party_required",
"message": format!(
"entry {} names no counterparty, and these books require one ([books] parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
"entry {} names no counterparty, and these books require one ({} = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
p.entry_id,
self.setting_label("policy.parties", "[books] parties"),
if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) }
),
"entry_id": p.entry_id,
@@ -4747,7 +4985,11 @@ impl BooksServer {
.map_err(|e| e.to_string())?;
Ok(Json(RenumberAccountOut {
id,
config_mentions: self.config_mentions(&previous_code),
config_mentions: self
.config_mentions(&previous_code)
.into_iter()
.chain(convention_mentions(store.ledger(), &previous_code))
.collect(),
previous_code,
code: p.code,
}))
@@ -4972,15 +5214,26 @@ impl BooksServer {
#[tool(
name = "books.settings",
title = "Settings in force",
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target), where they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads them whenever they change, with no restart; after a change, call this to confirm it loaded (reload_error says why one didn't, and the last good settings stay in force).",
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it, where they come from (source: \"books\", changed with books.set_setting; or \"file\", a settings file still read, reloaded on change: reload_error says why an edit didn't load), and problems: settings or conventions that disagree with the chart, each saying what to change.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn settings_tool(&self) -> Result<Json<SettingsOut>, String> {
// The books first: their settings sync as they lock, so the
// status and the settings read after are one version.
let store = self.lock()?;
let status = self.live().status();
let cfg = self.settings();
let store = self.lock()?;
let c = &cfg.company;
Ok(Json(SettingsOut {
source: if status.journaled { "books" } else { "file" }.to_string(),
timezone: cfg.timezone.clone(),
today: today().to_string(),
approval_rules: match store.ledger().settings().approval_rules() {
ledger_core::settings::ApprovalMode::Off => "off",
ledger_core::settings::ApprovalMode::Enforce => "enforce",
}
.to_string(),
keys: kept::keys_out(&self.settings_as_kept(&store)),
books_toml: status.books_toml.map(|p| p.display().to_string()),
books_toml_exists: status.books_toml_exists,
config_json_present: status.config_json_present,
@@ -9032,6 +9285,20 @@ impl BooksServer {
None => (uri, ""),
};
match path {
"books://start" => {
let Json(out) = self.start(Parameters(kept::StartParams::default())).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://settings" => {
let Json(out) = self.settings_tool().await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://conventions" => {
let Json(out) = self
.conventions(Parameters(kept::ConventionsParams::default()))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}
"books://chart" => {
let Json(out) = self.chart().await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
@@ -9097,12 +9364,14 @@ impl ServerHandler for BooksServer {
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
// Pick up an edited settings file before the tool runs.
self.live().refresh();
let timezone = self.settings().timezone.clone();
let server = self.clone();
let result = run_blocking(&self.store, move || {
Box::pin(async move {
let tcc =
rmcp::handler::server::tool::ToolCallContext::new(&server, request, context);
Self::tool_router().call(tcc).await
let router = Self::tool_router();
in_timezone_async(timezone, router.call(tcc)).await
})
})
.await;
@@ -9120,7 +9389,14 @@ impl ServerHandler for BooksServer {
}
fn get_info(&self) -> ServerConfig {
// A client connecting after a settings change sees the change.
// A client connecting after a settings change sees the change:
// the books' own when they keep them (never waiting for the books
// here; a request holding them syncs them when it locks), else the
// files'.
if let Ok(store) = self.store.store.try_lock() {
self.live()
.sync(store.ledger().settings(), self.store.currency.code());
}
self.live().refresh();
ServerConfig::new(
ServerCapabilities::builder()
@@ -9151,11 +9427,12 @@ impl ServerHandler for BooksServer {
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
self.live().refresh();
let timezone = self.settings().timezone.clone();
let uri = request.uri.clone();
let server = self.clone();
let at = uri.clone();
let text = run_blocking(&self.store, move || {
Box::pin(async move { server.resource_text(&at).await })
Box::pin(async move { in_timezone_async(timezone, server.resource_text(&at)).await })
})
.await?;
// A refusal quotes the URI it was given: masked like a tool's.
@@ -9393,7 +9670,7 @@ mod tests {
..Company::default()
},
);
assert_eq!(solo.config_mentions("3000"), ["[company] close_into"]);
assert_eq!(solo.config_mentions("3000"), ["[books] close_into"]);
}
/// Reports name each member with their accounts, in order.
@@ -9570,8 +9847,22 @@ mod tests {
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 94] = [
let table: [(&str, Hints); 108] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
("books.import_settings", DI),
("books.export_settings", R),
("books.conventions", R),
("books.set_convention", W),
("books.retire_convention", D),
("books.record_decision", W),
("books.decisions", R),
("books.raise_item", W),
("books.resolve_item", W),
("books.open_items", R),
("books.draft_from_template", W),
("books.approve_entry", W),
("books.chart", R),
("books.add_account", W),
("books.trial_balance", R),
@@ -9703,8 +9994,8 @@ mod tests {
"{name} has no openWorldHint false"
);
}
// docs/mcp-interface.md names the 25 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 25);
// docs/mcp-interface.md names the 28 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 28);
}
/// Every refusal an agent can be given whose text is fixed here, or
@@ -9801,6 +10092,66 @@ mod tests {
] {
out.push((what.to_string(), text.to_string()));
}
// Refusals and notes built inline, where the books' own record is
// kept: every string literal of these sources outside their tests.
for (what, src) in [
("tools/kept.rs", include_str!("tools/kept.rs")),
(
"ledger-config codec",
include_str!("../../ledger-config/src/codec.rs"),
),
(
"ledger-core settings",
include_str!("../../ledger-core/src/settings.rs"),
),
(
"ledger-core conventions",
include_str!("../../ledger-core/src/conventions.rs"),
),
(
"ledger-core ledger",
include_str!("../../ledger-core/src/ledger.rs"),
),
("settings.rs", include_str!("settings.rs")),
] {
let code = src.split("#[cfg(test)]").next().unwrap_or(src);
let literals = string_literals(code);
assert!(!literals.is_empty(), "{what}: no literals found");
out.extend(literals.into_iter().map(|l| (format!("{what} literal"), l)));
}
out
}
/// The text of every plain string literal ("...") in Rust source,
/// skipping comments (a rough scanner: enough for the lint).
fn string_literals(src: &str) -> Vec<String> {
let mut out = Vec::new();
for line in src.lines() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
let mut chars = line.chars().peekable();
let mut cur: Option<String> = None;
while let Some(c) = chars.next() {
match (&mut cur, c) {
(None, '"') => cur = Some(String::new()),
(None, '/') if chars.peek() == Some(&'/') => break,
(Some(s), '\\') => {
if let Some(n) = chars.next() {
s.push('\\');
s.push(n);
}
}
(Some(s), '"') => {
out.push(std::mem::take(s));
cur = None;
}
(Some(s), c) => s.push(c),
(None, _) => {}
}
}
}
out
}
+215 -17
View File
@@ -1,24 +1,35 @@
//! The server's settings (`wanderbooks.toml`, and the receipt policy in
//! `books-data/config.json`), reloaded whenever their files change.
//! 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.
//!
//! ```text
//! GOAL: an edit to wanderbooks.toml takes effect on the next tool call,
//! with no restart, and a broken edit never takes the server down.
//! SOURCE: DECISION (owner, 2026-10-15): the configuration is always
//! auto-reloaded.
//! 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.
//! 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.
//! INVARIANTS:
//! 1. Every request (tool call, resource read) sees the files' contents
//! as of its start, one version for the whole request: a request after
//! an edit sees the edit (compared by content, not by timestamp, so two
//! edits in one second are both seen).
//! 2. A file that fails to load leaves the last good settings in force,
//! 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.
//! 3. A settings file that doesn't exist and wasn't asked for (the
//! 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 (2).
//! 4. Only the settings reload: the data dir and the books' base currency
//! are fixed when the server starts.
//! 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).
//! ```
use crate::policy::ReceiptPolicy;
@@ -56,6 +67,11 @@ pub struct Settings {
/// `[books] 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 {
@@ -67,6 +83,26 @@ impl Settings {
.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 {
let b = ledger_config::codec::books_settings(books, currency);
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 {
cash: b.cash_accounts,
financing: b.financing_accounts,
investing: b.investing_accounts,
},
company: b.company,
journaled: true,
}
}
/// The prefix for generated asset tags ("FA" when none is set).
pub fn tag_prefix(&self) -> &str {
if self.asset_tag_prefix.is_empty() {
@@ -114,6 +150,8 @@ impl Source {
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,
@@ -133,6 +171,13 @@ pub struct Live {
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,
}
/// What books.settings reports about the files.
@@ -150,6 +195,9 @@ pub struct Status {
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 {
@@ -161,6 +209,9 @@ impl Live {
seen: (None, None),
error: None,
loads: 1,
journal_seq: None,
vault_key_file: None,
read_files: false,
}
}
@@ -175,9 +226,79 @@ impl Live {
seen,
error: None,
loads: 1,
journal_seq: None,
vault_key_file: None,
read_files: true,
})
}
/// 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,
}
}
/// 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.
pub fn sync(&mut self, books: &ledger_core::settings::Settings, currency: &str) {
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.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.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
}
/// The settings in force.
pub fn current(&self) -> Arc<Settings> {
self.current.clone()
@@ -187,12 +308,20 @@ impl Live {
/// 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(s) => {
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;
@@ -219,12 +348,18 @@ impl Live {
/// 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().map(|s| s.books_toml.clone()),
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,
}
}
}
@@ -327,4 +462,67 @@ mod tests {
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();
live.sync(&books, "USD");
assert_eq!(live.status().loads, 0, "books without settings");
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,
};
let change = |name: &str| ledger_core::settings::Change {
key: "company.name".into(),
value: Some(serde_json::json!(name)),
};
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);
live.sync(&books, "USD");
assert_eq!(live.status().loads, 1, "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());
std::fs::remove_dir_all(&d).unwrap();
}
/// 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());
}
}
File diff suppressed because it is too large. Load diff
+30 -2
View File
@@ -562,8 +562,21 @@ pub struct MemberSettingOut {
/// Result of `books.settings`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SettingsOut {
/// The wanderbooks.toml the server reads (null: settings built in, not
/// from a file).
/// 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.
pub today: String,
/// Whether approval rules block posting: "off" or "enforce".
pub approval_rules: String,
/// 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,
@@ -1192,6 +1205,21 @@ pub struct PostEntryOut {
/// with `books.set_entry_party`, or add the party with
/// `books.add_party` first.
pub party_suggestions: Vec<PartySuggestionOut>,
/// Approvals the entry still needs before it posts, when the books
/// enforce approval rules (`policy.approval_rules = enforce`): each
/// rule covering it and its approver. Empty otherwise. Record each
/// with `books.approve_entry` once the approver says yes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub approvals_missing: Vec<ApprovalNeededOut>,
}
/// An approval an entry needs.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ApprovalNeededOut {
/// The approval rule (a convention id).
pub rule: String,
/// Who approves.
pub approver: String,
}
/// A likely counterparty for an entry.