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:
25 files changed
+808
-41
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user