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

+25 -33
View File
@@ -5,7 +5,8 @@
//! the server, through one socket that accepts nothing else.
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
//! leases record their kind and PID; exactly five owner intents;
//! consent and a replacing restore carry the owner passphrase;
//! consent carries the owner passphrase (a replacing restore no
//! longer does: D-31, the books it replaces are kept in legacy/);
//! "Closing": the host says why on each lease; protocol versions N
//! and N-1).
//! INVARIANTS:
@@ -13,11 +14,10 @@
//! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed.
//! 2. Exactly five intents exist: close, back_up_now, new_books,
//! restore (a staged backup's id, never a path) and
//! consent_format_upgrade; any other request is refused.
//! 3. consent_format_upgrade, and a restore that replaces a book, carry
//! the owner passphrase; without one they are refused before
//! anything is done.
//! restore (a backup's address or folder, as `books restore` takes
//! it) and consent_format_upgrade; any other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done.
//! 4. Before the host ends a lease it sends the reason; a lease that
//! ends with no reason means the host is gone.
//! 5. Each side accepts its own protocol version and the one before.
@@ -120,16 +120,17 @@ pub enum Intent {
/// Their base currency.
currency: String,
},
/// Restore a staged backup, as its own book or replacing one.
/// Restore a backup, as its own book or replacing one (kept in
/// legacy/).
Restore {
/// The staged backup's id (never a path).
staged: String,
/// The book it replaces, if it replaces one.
/// The backup: a repository's address, or its folder (`<book>.git`
/// in the backup folder).
source: String,
/// The books' id.
id: String,
/// Replace the books of that id.
#[serde(default)]
replace: Option<String>,
/// The owner passphrase: needed to replace (INVARIANT 3).
#[serde(default)]
passphrase: Option<String>,
replace: bool,
},
/// Let the server upgrade a book's journal format.
ConsentFormatUpgrade {
@@ -151,13 +152,6 @@ impl Intent {
"consenting to a format upgrade needs the owner passphrase, typed in the app"
.into(),
),
Intent::Restore {
replace: Some(_),
passphrase,
..
} if missing(passphrase) => Err(
"a restore that replaces books needs the owner passphrase, typed in the app".into(),
),
_ => Ok(()),
}
}
@@ -288,7 +282,7 @@ mod tests {
json!("close"),
json!({"back_up_now": {"book": "acme"}}),
json!({"new_books": {"id": "acme", "currency": "USD"}}),
json!({"restore": {"staged": "s1"}}),
json!({"restore": {"source": "/backups/acme.git", "id": "acme"}}),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
@@ -297,7 +291,8 @@ mod tests {
for bad in [
json!({"proto": 1, "intent": "delete"}),
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1", "path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a", "path": "/x"}}}),
json!({"proto": 1, "intent": {"restore": {"staged": "s1"}}}),
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
json!({"proto": 1, "exec": "sh"}),
json!({"proto": 1}),
@@ -322,7 +317,7 @@ mod tests {
}
#[test]
fn consent_and_a_replacing_restore_need_the_passphrase() {
fn only_consent_needs_the_passphrase() {
let consent = |p: Option<&str>| Intent::ConsentFormatUpgrade {
book: "acme".into(),
passphrase: p.map(str::to_string),
@@ -330,16 +325,13 @@ mod tests {
assert!(consent(None).check_passphrase().is_err());
assert!(consent(Some("")).check_passphrase().is_err());
assert!(consent(Some("pw")).check_passphrase().is_ok());
let restore = |replace: Option<&str>, p: Option<&str>| Intent::Restore {
staged: "s1".into(),
replace: replace.map(str::to_string),
passphrase: p.map(str::to_string),
};
assert!(restore(None, None).check_passphrase().is_ok());
assert!(restore(Some("acme"), None).check_passphrase().is_err());
assert!(restore(Some("acme"), Some("")).check_passphrase().is_err());
assert!(restore(Some("acme"), Some("pw")).check_passphrase().is_ok());
for other in [
// D-31: replacing keeps the old books in legacy/; no passphrase.
Intent::Restore {
source: "/backups/acme.git".into(),
id: "acme".into(),
replace: true,
},
Intent::Close,
Intent::BackUpNow { book: "a".into() },
Intent::NewBooks {