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

+18 -14
View File
@@ -540,12 +540,17 @@ impl Books {
let ledger = snap.ledger().clone();
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
let journal = snap.journal().to_vec();
// The server's own rule (config.json, else wanderbooks.toml), so
// the viewer flags exactly what the server would refuse.
let policy = ledger_config::effective_receipt_policy(
&snap.data_dir().join("config.json"),
&settings,
)?;
// The server's own rule (the books' settings when they keep them,
// else config.json, else wanderbooks.toml), so the viewer flags
// exactly what the server would refuse.
let policy = if ledger.settings().is_journaled() {
ledger_config::codec::receipt_policy(ledger.settings())
} else {
ledger_config::effective_receipt_policy(
&snap.data_dir().join("config.json"),
&settings,
)?
};
let receipt_threshold = policy.threshold();
let cash_accounts = cash_accounts(&ledger, &settings);
let members = settings.company.party_members(&ledger);
@@ -1516,22 +1521,21 @@ impl Books {
/// INVARIANT 1: read only on demand (a click), never on load or paint.
/// INVARIANT 2: never creates a key file; a missing key is a locked
/// vault ("restore it from your password manager").
/// INVARIANT 3: the key file in force is the server's: [vault]
/// key_file, else ~/.config/wanderbooks/vault.key; one inside the
/// books dir is refused, as the server refuses it.
/// INVARIANT 3: the key file in force is the server's: the one given
/// on the command line (--vault-key-file), else a settings file's
/// [vault] key_file, else ~/.config/wanderbooks/vault.key; one
/// inside the books dir is refused, as the server refuses it.
/// INVARIANT 4: an error never carries the digits (the vault's errors
/// are redacted); a party with no record is "not in the vault".
/// ```
pub fn tin_from_vault(&self, party: PartyId) -> Result<String, String> {
let key = self
.settings
.vault_key_file
.clone()
let key = crate::vault_key_file()
.or_else(|| self.settings.vault_key_file.clone())
.unwrap_or_else(ledger_vault::default_key_file);
let books = self.books_dir();
if resolved(&key).starts_with(resolved(&books)) {
return Err(format!(
"The vault key file {} is inside the books folder: move it out and set [vault] key_file.",
"The vault key file {} is inside the books folder: move it out, and name its new place to the server and the viewer (--vault-key-file).",
key.display()
));
}
+29 -9
View File
@@ -68,7 +68,9 @@ pub fn load_once(source: &Source) -> Result<Books, String> {
})
})?;
let integrity = Integrity::check(&snap, &mut HashSet::new());
Books::build(&snap, settings(source)?, today(source), None, integrity)
let settings = settings(source, &snap)?;
let today = today(source, &settings);
Books::build(&snap, settings, today, None, integrity)
}
/// What the viewer says when the books can't be read. Books from a newer
@@ -88,13 +90,25 @@ pub fn problem(e: &StoreError, context: impl FnOnce() -> String) -> String {
}
}
fn today(source: &Source) -> NaiveDate {
/// Today: as fixed for tests, else in the company's time zone, else the
/// computer's.
fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
source
.today
.unwrap_or_else(|| chrono::Local::now().date_naive())
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
}
fn settings(source: &Source) -> Result<BooksSettings, String> {
/// The settings: the books' own when they keep them (the settings file is
/// then never read, as the server never reads it), else the settings
/// file's.
fn settings(source: &Source, snap: &Snapshot) -> Result<BooksSettings, String> {
let books = snap.ledger().settings();
if books.is_journaled() {
return Ok(ledger_config::codec::books_settings(
books,
snap.ledger().base_currency().code(),
));
}
match &source.books_toml {
Some(p) if p.exists() => ledger_config::load(p),
_ => Ok(BooksSettings::default()),
@@ -128,9 +142,10 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
let integrity = Integrity::check(snap, &mut verified);
let disk = integrity.disk.clone();
let update = match settings(&source)
.and_then(|s| Books::build(snap, s, today(&source), prev, integrity))
{
let update = match settings(&source, snap).and_then(|s| {
let today = today(&source, &s);
Books::build(snap, s, today, prev, integrity)
}) {
Ok(b) => Update::Books(Arc::new(b)),
Err(e) => Update::Error(e),
};
@@ -181,7 +196,12 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
}
}
let mut toml_mtime = mtime(&source.books_toml);
let mut day = today(&source);
let day_now = |snap: &Snapshot| {
settings(&source, snap)
.map(|s| today(&source, &s))
.unwrap_or_else(|_| today(&source, &BooksSettings::default()))
};
let mut day = day_now(&snap);
loop {
match ev_rx.recv_timeout(Duration::from_secs(3)) {
Ok(()) => {
@@ -205,7 +225,7 @@ fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
}
};
let toml_now = mtime(&source.books_toml);
let new_day = today(&source);
let new_day = day_now(&snap);
// The writer journals first and updates its database right after:
// a "behind" database usually catches up a moment later, so look
// again rather than leave a stale warning up.
+15
View File
@@ -22,3 +22,18 @@ mod widgets;
pub use app::App;
pub use data::Source;
/// The vault key file named on the command line (`--vault-key-file`),
/// which wins over a settings file's `[vault] key_file`, as it does for
/// the server: books that keep their own settings never name it.
static VAULT_KEY_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Name the vault key file (once, at start).
pub fn set_vault_key_file(path: std::path::PathBuf) {
let _ = VAULT_KEY_FILE.set(path);
}
/// The vault key file named on the command line, if one was.
pub fn vault_key_file() -> Option<std::path::PathBuf> {
VAULT_KEY_FILE.get().cloned()
}
+7 -2
View File
@@ -1,4 +1,4 @@
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]`
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`
//!
//! Opens the books read-only and follows them live. With no flags it looks
//! for `books-data/` and `wanderbooks.toml` in the current directory.
@@ -17,11 +17,16 @@ fn main() -> eframe::Result<()> {
match a.as_str() {
"--data-dir" => data_dir = args.next().map(PathBuf::from),
"--books-toml" => books_toml = args.next().map(PathBuf::from),
"--vault-key-file" => {
if let Some(k) = args.next() {
ledger_gui::set_vault_key_file(PathBuf::from(k));
}
}
"--today" => today = args.next().and_then(|d| d.parse().ok()),
"--demo" => demo = true,
"-h" | "--help" => {
println!(
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]"
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]"
);
return Ok(());
}
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
ui,
icon::BANK,
"No cash accounts found",
"Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.",
"Name an asset account \"checking\", \"savings\", or \"cash\", or ask the agent to set the books' cash accounts.",
)
});
return;
+119 -3
View File
@@ -38,6 +38,98 @@ fn kv(ui: &mut Ui, k: &str, v: &str) {
});
}
/// Who decided a setting and when, under its value.
fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
let p = pal(ui.ctx());
ui.label(
RichText::new(format!("{by} · {}", fmt::date(on)))
.font(theme::regular(11.0))
.color(p.muted),
);
}
/// How the books are kept, as the owners decided: conventions, decisions,
/// and what is open (each only when there is something to show).
fn kept_cards(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let kept = b.ledger.conventions();
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
if !conventions.is_empty() {
ui.add_space(12.0);
widgets::titled_card(
ui,
"How the books are kept",
|_| {},
|ui| {
for c in &conventions {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(&c.title).font(theme::semibold(13.0)));
widgets::pill(ui, &c.kind.key().replace('_', " "), p.accent, p.accent_soft);
});
widgets::copyable(
ui,
RichText::new(c.summary()).font(theme::regular(12.5)),
&c.body_md,
);
provenance(ui, &c.source.decided_by, c.source.decided_on);
ui.add_space(6.0);
}
},
);
}
let decisions: Vec<_> = kept
.decisions()
.filter(|d| d.superseded_by.is_none())
.collect();
if !decisions.is_empty() {
ui.add_space(12.0);
widgets::titled_card(
ui,
"Decisions",
|_| {},
|ui| {
for d in decisions.iter().rev() {
ui.label(RichText::new(&d.new.subject).font(theme::semibold(13.0)));
widgets::copyable(
ui,
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
&d.new.decision_md,
);
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
ui.add_space(6.0);
}
},
);
}
let open: Vec<_> = kept.open_items().filter(|i| i.resolved.is_none()).collect();
if !open.is_empty() {
ui.add_space(12.0);
widgets::titled_card(
ui,
"Open items",
|_| {},
|ui| {
for i in open {
ui.horizontal_wrapped(|ui| {
ui.label(RichText::new(icon::FLAG).color(p.warning));
ui.label(RichText::new(&i.new.title).font(theme::regular(13.0)));
if let Some(due) = i.new.due {
// Late says so in words, not only in colour.
let late = due < b.today;
let when = if late { "overdue since" } else { "due" };
ui.label(
RichText::new(format!("{when} {}", fmt::date(due)))
.font(theme::regular(12.0))
.color(if late { p.warning } else { p.muted }),
);
}
});
}
},
);
}
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "company", |ui| {
@@ -54,14 +146,25 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
"Profile",
|_| {},
|ui| {
if c.profile.is_empty() {
let kept = b.ledger.settings();
// The currency is always known: facts are the rest.
if c.profile.iter().all(|(k, _)| k == "base_currency") {
ui.label(
RichText::new("No [company] settings in wanderbooks.toml.")
.color(p.muted),
RichText::new(if kept.is_journaled() {
"No company facts set yet: the agent records them as you give them."
} else {
"No company facts in the settings file yet: the agent records them as you give them."
})
.color(p.muted),
);
}
for (k, v) in &c.profile {
kv(ui, &fmt::label(k), v);
// Who set it, and when (settings kept in the
// books carry their source).
if let Some(r) = kept.get(&format!("company.{k}")) {
provenance(ui, &r.source.decided_by, r.source.decided_on);
}
}
kv(ui, "Accounting basis", &c.basis);
kv(
@@ -70,8 +173,21 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
);
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
if let Some(z) = &b.settings.timezone {
kv(ui, "Time zone", z);
}
ui.label(
RichText::new(if kept.is_journaled() {
"Kept in the books: the agent changes settings, with who decided and when."
} else {
"Read from the settings file: the agent can move it into the books."
})
.font(theme::regular(12.0))
.color(p.muted),
);
},
);
kept_cards(ui, b);
}
_ => {
widgets::titled_card(
+69
View File
@@ -834,6 +834,75 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
// The books' record of how they are kept (journal format 2).
"settings.set" => {
let keys: Vec<&str> = p["changes"]
.as_array()
.into_iter()
.flatten()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
let by = s(&p["source"], "decided_by");
ev(
icon::GEAR_SIX,
if keys.is_empty() {
format!("Settings moved into the books ({by})")
} else {
format!("Settings changed: {} ({by})", keys.join(", "))
},
Some(Route::Company),
)
}
"convention.set" => ev(
icon::SCROLL,
format!(
"Convention: {} ({}, {})",
s(p, "title"),
s(p, "kind").replace('_', " "),
s(&p["source"], "decided_by")
),
Some(Route::Company),
),
"convention.retired" => ev(
icon::SCROLL,
format!("Convention {} retired: {}", s(p, "id"), s(p, "reason")),
Some(Route::Company),
),
"decision.recorded" => ev(
icon::GAVEL,
format!(
"Decision: {} ({}, {})",
s(p, "subject"),
p["decided_by"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(" and "),
s(p, "decided_on")
),
Some(Route::Company),
),
"open_item.raised" => ev(
icon::FLAG,
format!("Open item #{id}: {}", s(p, "title")),
Some(Route::Company),
),
"open_item.resolved" => ev(
icon::CHECK_CIRCLE,
format!("Open item #{id} resolved: {}", s(p, "resolution")),
Some(Route::Company),
),
"entry.approved" => ev(
icon::SEAL_CHECK,
format!(
"Entry #{} approved by {}",
entry_id.unwrap_or(0),
s(p, "approver")
),
entry_id.map(Route::Entry),
),
other => ev(
icon::DOT,
other.to_string(),