feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes

Store::verify compares the whole SQLite projection, every table row for
row, against a fresh projection of the journal; verify_and_heal rebuilds a
drifted database (keeping the old file) at every server start and via the
new books.verify tool, which also re-hashes every blob. The GUI checks the
server's database read-only and every stored file, and shows the result.
rebuild() also drops stale WAL/SHM files.

site/: the wanderbooks website (just site, just publish). install.sh:
OS detection (Windows points to WSL2), C compiler and git checks,
WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup.
Repo justfile: check, build, install, demo, site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 17:05:05 -07:00
1 parent 714867ed58
commit 9694d2a89a
25 files changed
+808 -41

No files matched your search

+9 -5
View File
@@ -115,7 +115,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
};
let company = settings.company;
let store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
Store::open(&data_dir)?
} else {
let code = config.base_currency.as_deref().unwrap_or("USD");
@@ -123,11 +123,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("ledger-mcp: creating new store (base currency {code})");
Store::create(&data_dir, currency)?
};
// The journal is the source of truth: verify the chain and the
// projection before serving a single tool call.
store
.verify()
// 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})");
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
+37
View File
@@ -1375,6 +1375,43 @@ impl BooksServer {
Ok(Json(SearchEntriesOut { entries }))
}
#[tool(
name = "books.verify",
description = "Check the books end to end: the journal's hash chain; a fresh replay against the live ledger; every table of the SQLite database, row for row, against a fresh projection of the journal (rebuilding the database from the journal if it drifted, keeping the old file); and, by default, every stored receipt and document against its SHA-256. Run it whenever a number looks wrong or a human asks whether the books are intact."
)]
async fn verify(&self, params: Parameters<VerifyParams>) -> Result<Json<VerifyOut>, String> {
let mut store = self.lock()?;
let report = store.verify_and_heal().map_err(|e| e.to_string())?;
let blobs = if params.0.blobs {
Some(store.verify_blobs().map_err(|e| e.to_string())?)
} else {
None
};
let mut checked = vec![
format!("journal hash chain ({} events)", report.events),
"fresh replay matches the live ledger (every statement, assets, classes, payroll)"
.to_string(),
"SQLite database matches a fresh projection of the journal, every table, row for row"
.to_string(),
];
if let Some(n) = blobs {
checked.push(format!("{n} stored files match their SHA-256"));
}
Ok(Json(VerifyOut {
status: if report.healed.is_some() {
"healed"
} else {
"ok"
}
.to_string(),
events: report.events,
head: report.head,
healed: report.healed,
blobs_verified: blobs,
checked,
}))
}
#[tool(
name = "books.digest",
description = "What changed since a date: entries posted, per-account movement, and receipt coverage. Built for human review.",
+29
View File
@@ -375,6 +375,35 @@ pub struct Vendor1099Out {
pub notes: Vec<String>,
}
/// Parameters for `books.verify`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct VerifyParams {
/// Also re-hash every stored receipt and document (default true).
#[serde(default = "default_true")]
pub blobs: bool,
}
fn default_true() -> bool {
true
}
/// Result of `books.verify`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct VerifyOut {
/// "ok", or "healed" when the database was rebuilt from the journal.
pub status: String,
/// Journal events whose hash chain verified.
pub events: u64,
/// SHA-256 of the last journal line: the verified head.
pub head: String,
/// Why the database was rebuilt, if it was.
pub healed: Option<String>,
/// Stored files re-hashed, when checked.
pub blobs_verified: Option<u64>,
/// What was checked, in words.
pub checked: Vec<String>,
}
/// Parameters for `books.chart_template`.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ChartTemplateParams {
+6 -1
View File
@@ -139,7 +139,7 @@ fn mcp_end_to_end_over_stdio() {
// Tool listing: 23 narrow tools, chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 64, "expected 64 tools, got {}", tools.len());
assert_eq!(tools.len(), 65, "expected 65 tools, got {}", tools.len());
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
assert_eq!(chart["annotations"]["readOnlyHint"], true);
assert!(
@@ -1548,4 +1548,9 @@ fn mcp_vendor_1099_over_stdio() {
assert_eq!(r["vendors"][0]["paid"], "0.00");
let vendors = mcp.call("books.list_vendors", json!({}));
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
// The books check out end to end, receipt included.
let v = mcp.call("books.verify", json!({}));
assert_eq!(v["status"], "ok");
assert_eq!(v["blobs_verified"], 1);
assert!(v["events"].as_u64().unwrap() > 10);
}