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

+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(),