P2 slice 7: backups and restore (D-30, D-31)

The server backs each book up itself: a per-book git archive, pushed
fast-forward only to a git remote and to a folder, when the books open,
after a quiet spell, before they close and on request; every commit,
push and drill is a record in ops/backup.jsonl. Drills clone each
target and verify the chain, the blobs, the vault and the head, weekly
and after a failure. `wanderbooks books restore` previews, then
restores beside or (with --replace) over, never moving books something
holds. The archive refuses anything but a plain bare repository and
pins git against the owner's configuration. books.backup and
books.back_up_now for the agent.

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-20 11:14:21 -06:00
1 parent 5bc0633cdd
commit 019d8d76a3
29 files changed
+6443 -136

No files matched your search

+99 -28
View File
@@ -78,6 +78,9 @@ pub struct BooksServer {
/// The files the books made for agents, waiting to be fetched (books
/// served from the data root; over stdio a file comes back inline).
exports: Option<ledger_store::exports::Exports>,
/// The books' backups (books served from the data root; none over
/// stdio).
backups: Option<Arc<crate::backup::Backups>>,
}
/// Where a book's own files live around its data dir.
@@ -146,7 +149,8 @@ impl Layout {
/// whether or not anyone still waits for its answer.
/// ```
pub struct SharedStore {
store: tokio::sync::Mutex<Store>,
/// None once let go (`let_go`): its books were replaced by a restore.
store: tokio::sync::Mutex<Option<Store>>,
stopped: std::sync::atomic::AtomicBool,
/// Closing: no new request runs (INVARIANT 3).
closing: std::sync::atomic::AtomicBool,
@@ -160,6 +164,25 @@ pub struct SharedStore {
}
impl SharedStore {
/// Run `f` on the store under its lock, for a backup (backup.rs):
/// blocking. It reads only, and runs even while the books close (a
/// last backup), never after the server stopped.
pub fn with_store<R>(&self, f: impl FnOnce(&Store) -> R) -> Result<R, String> {
if self.stopped() {
return Err(STOPPED.to_string());
}
let store = self.store.blocking_lock();
let store = store.as_ref().ok_or(STOPPED)?;
Ok(f(store))
}
/// Run `f` on the store if no one holds it now (books just opened):
/// never waits, so never blocks a runtime's thread.
pub fn try_with_store<R>(&self, f: impl FnOnce(&Store) -> R) -> Option<R> {
let store = self.store.try_lock().ok()?;
store.as_ref().map(f)
}
/// An attachment's name, type and bytes (hash-verified), for a
/// download: blocking (the store's lock, then the file).
/// None when there is no such attachment.
@@ -168,6 +191,7 @@ impl SharedStore {
return Err(STOPPED.to_string());
}
let store = self.store.blocking_lock();
let store = store.as_ref().ok_or(STOPPED)?;
let Some(meta) = store
.list_attachments(None)
.map_err(|e| e.to_string())?
@@ -189,7 +213,7 @@ impl SharedStore {
pub fn new(store: Store) -> Arc<Self> {
Arc::new(Self {
currency: store.base_currency(),
store: tokio::sync::Mutex::new(store),
store: tokio::sync::Mutex::new(Some(store)),
stopped: std::sync::atomic::AtomicBool::new(false),
closing: std::sync::atomic::AtomicBool::new(false),
running: std::sync::atomic::AtomicUsize::new(0),
@@ -220,6 +244,15 @@ impl SharedStore {
self.stopped.load(std::sync::atomic::Ordering::SeqCst)
}
/// Let go of the books for good (a restore replaces them): close as
/// [`Self::close`], stop, and drop the store, so its writer lock is
/// released while sessions still hold this.
pub async fn let_go(&self) {
self.close().await;
self.stop();
self.store.lock().await.take();
}
/// Stop taking requests (INVARIANT 1): after an internal error, or an
/// access record that couldn't be written (access.rs INVARIANT 3).
pub(crate) fn stop(&self) {
@@ -275,7 +308,8 @@ const VAULT_IN_HISTORY: &str = "the TIN vault can't store anything here yet: the
/// task is suspended. Dropped by a panic, it stops the server
/// (SharedStore INVARIANT 1).
pub struct StoreGuard<'a> {
guard: tokio::sync::MutexGuard<'a, Store>,
/// Holds a store (checked when it was taken).
guard: tokio::sync::MutexGuard<'a, Option<Store>>,
shared: &'a SharedStore,
/// The journal's head when the lock was taken.
before: (u64, String),
@@ -289,7 +323,10 @@ impl Drop for StoreGuard<'_> {
}
// A write while it was held: the head after it, for the call's
// access record (taken under the same lock, so it is this call's).
let after = self.guard.head();
let Some(store) = self.guard.as_ref() else {
return;
};
let after = store.head();
if after != self.before {
WROTE.with(|w| *w.borrow_mut() = Some(after));
}
@@ -299,13 +336,13 @@ impl Drop for StoreGuard<'_> {
impl std::ops::Deref for StoreGuard<'_> {
type Target = Store;
fn deref(&self) -> &Store {
&self.guard
self.guard.as_ref().expect("a guard holds a store")
}
}
impl std::ops::DerefMut for StoreGuard<'_> {
fn deref_mut(&mut self) -> &mut Store {
&mut self.guard
self.guard.as_mut().expect("a guard holds a store")
}
}
@@ -598,6 +635,7 @@ impl BooksServer {
access: None,
uploads: None,
exports: None,
backups: None,
}
}
@@ -613,6 +651,12 @@ impl BooksServer {
self
}
/// Back the books up through `backups` (backup.rs).
pub fn with_backups(mut self, backups: Arc<crate::backup::Backups>) -> Self {
self.backups = Some(backups);
self
}
/// Check every call against its caller's token, and record it in
/// `log` (access.rs).
pub fn with_access(mut self, log: Arc<ledger_store::access::AccessLog>) -> Self {
@@ -701,7 +745,11 @@ impl BooksServer {
/// Every tool the server serves.
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
Self::core_router() + Self::kept_router() + Self::access_router() + Self::files_router()
Self::core_router()
+ Self::kept_router()
+ Self::access_router()
+ Self::files_router()
+ Self::backup_router()
}
/// The settings in force for this request (reloaded at its start, in
@@ -1052,21 +1100,25 @@ impl BooksServer {
return Err(STOPPED.to_string());
}
let guard = self.store.store.blocking_lock();
// A request that panicked while this one waited stopped it.
// A request that panicked while this one waited stopped it; books
// let go hold no store.
if self.store.stopped() {
return Err(STOPPED.to_string());
}
let Some(store) = guard.as_ref() else {
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.sync(store.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);
let before = guard.head();
let before = store.head();
Ok(StoreGuard {
guard,
shared: &self.store,
@@ -2523,6 +2575,8 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
#[path = "tools/access.rs"]
mod access_tools;
#[path = "tools/backup.rs"]
mod backup_tools;
#[path = "tools/files.rs"]
mod files;
#[path = "tools/kept.rs"]
@@ -6982,7 +7036,7 @@ impl BooksServer {
#[tool(
name = "books.purge_tin",
title = "Purge a TIN from the vault",
description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted for good and the books journal it: the party's TIN facts (party.tin_set: purged says when and why) and W-9 facts (party.w9_set: kept_at says so) keep the last four and drop the vault record. Works without the vault key. The next CPA package then lacks that TIN (and says so).",
description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted from the vault (backups made before keep their copies) and the books journal it: the party's TIN facts (party.tin_set: purged says when and why) and W-9 facts (party.w9_set: kept_at says so) keep the last four and drop the vault record. Works without the vault key. The next CPA package then lacks that TIN (and says so).",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -7048,7 +7102,7 @@ impl BooksServer {
.or_else(|| party.tin.as_ref().map(|t| t.last4.clone())),
journaled: false,
note: format!(
"Confirming deletes {} for good{}. Make sure the CPA has what they need first.",
"Confirming deletes {}{}; backups made before keep their copies. Make sure the CPA has what they need first.",
match (held_tin, held_w9) {
(true, true) => "the TIN and the W-9 file",
(true, false) => "the TIN",
@@ -8831,8 +8885,11 @@ impl ServerHandler for BooksServer {
.unwrap_or_default();
tools.retain(|t| crate::scopes::shown(&t.name, &scopes));
} else {
// Books served over stdio keep no access log: not offered.
tools.retain(|t| t.name != "books.access_log");
// Books served over stdio keep no access log and aren't backed
// up here: not offered.
tools.retain(|t| {
!["books.access_log", "books.backup", "books.back_up_now"].contains(&&*t.name)
});
}
let hints = context
.protocol_version()
@@ -8852,7 +8909,9 @@ impl ServerHandler for BooksServer {
// 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() {
if let Ok(store) = self.store.store.try_lock()
&& let Some(store) = store.as_ref()
{
self.live()
.sync(store.ledger().settings(), self.store.currency.code());
}
@@ -9373,7 +9432,7 @@ 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); 109] = [
let table: [(&str, Hints); 111] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -9483,6 +9542,8 @@ mod tests {
("books.profit_and_loss_by_class", R),
("books.export_cpa_package", W),
("books.access_log", R),
("books.backup", R),
("books.back_up_now", WI),
];
let tools = BooksServer::tool_router().list_all();
assert_eq!(tools.len(), table.len());
@@ -9514,11 +9575,12 @@ mod tests {
for word in ["requiresuserinteraction", "requires_user_interaction"] {
assert!(!json.contains(word), "{name} asks for user interaction");
}
// The books are a closed world: no tool reaches beyond them.
// The books are a closed world: no tool reaches beyond them,
// but the one that pushes a backup to the owner's targets.
assert_eq!(
a.open_world_hint,
Some(false),
"{name} has no openWorldHint false"
Some(*name == "books.back_up_now"),
"{name}: openWorldHint"
);
}
// docs/mcp-interface.md names the 28 destructive tools.
@@ -9581,15 +9643,24 @@ mod tests {
}
assert!(n > 0, "{krate}: no error templates found");
}
// The files tool's refusals (and description): its sentences.
for line in include_str!("tools/files.rs").lines() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
for (i, text) in line.split('"').enumerate() {
if i % 2 == 1 && text.len() > 30 && text.contains(' ') {
out.push(("tools/files.rs text".into(), text.to_string()));
// The files and backup tools' refusals (and descriptions), and the
// backups' own words: their sentences. (What git itself says of a
// failed backup reaches the human as git said it: not ours.)
for (name, src) in [
("tools/files.rs", include_str!("tools/files.rs")),
("tools/backup.rs", include_str!("tools/backup.rs")),
("backup.rs", include_str!("backup.rs")),
] {
let src = src.split("#[cfg(test)]").next().unwrap_or(src);
for line in src.lines() {
let line = line.trim_start();
if line.starts_with("//") {
continue;
}
for (i, text) in line.split('"').enumerate() {
if i % 2 == 1 && text.len() > 30 && text.contains(' ') {
out.push((format!("{name} text"), text.to_string()));
}
}
}
}