P3: the books' format moves only with the owner's go-ahead (D-39)

Books an update could write a newer format for open on their old format
and are asked at every open; anything needing a new feature is refused
until the owner agrees with the passphrase, after a verified backup that
includes the keys. New books start on the current format. Each projection
schema has its own database file, so an older version still opens the
books until the owner agrees.

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-21 14:04:11 -06:00
1 parent b0a9fd8ad4
commit 3a9aa92315
62 files changed
+5309 -628

No files matched your search

+169 -6
View File
@@ -1,6 +1,7 @@
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.3, 3.9): how
//! the command line asks the books server to list, create, adopt and
//! restore books, and to make, list and revoke agents' tokens.
//! restore books, to make, list and revoke agents' tokens, and how the
//! host hands it the owner's go-ahead for a format upgrade.
//!
//! ```text
//! GOAL: the command line makes and adopts books without ever touching
@@ -26,6 +27,9 @@
//! 6. A token's text is in exactly one answer, TokenCreated, to the
//! request that made it; nothing else carries it (auth.rs
//! INVARIANT 1).
//! 7. A format upgrade is asked with the owner passphrase, which the
//! server checks before anything is done (plan 3.9, D-39); no answer
//! carries it, and Debug never shows it.
//! ```
use serde::{Deserialize, Serialize};
@@ -140,6 +144,16 @@ pub enum Request {
},
/// The live tokens.
TokenList,
/// The owner's go-ahead for the format upgrade of books waiting for
/// one (plan 3.9, D-39): a verified backup with the keys, then this
/// version's journal format declared. The host sends it, for the
/// lease socket's `consent_format_upgrade`.
UpgradeFormat {
/// The books.
id: String,
/// The owner passphrase, checked by the server (INVARIANT 7).
passphrase: Passphrase,
},
/// End a token.
TokenRevoke {
/// Its books.
@@ -163,6 +177,73 @@ impl std::fmt::Debug for TokenText {
}
}
/// The owner passphrase in a request (and in the lease socket's consent,
/// which the host hands on as this): Debug never shows it (INVARIANT 7),
/// and it is wiped from memory when dropped, as the command line wipes
/// what is typed.
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Passphrase(pub String);
impl std::fmt::Debug for Passphrase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Passphrase(…)")
}
}
impl Drop for Passphrase {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.0);
}
}
/// A format upgrade books wait for (plan 3.9, D-39): written by an older
/// version, they open on their old format until the owner says go.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormatWaiting {
/// The journal format they are in.
pub format: u32,
/// The features they declared.
pub features: Vec<String>,
/// What the upgrade adds, in words ("journal format 2").
pub adds: Vec<String>,
}
impl From<&ledger_store::FormatUpgrade> for FormatWaiting {
fn from(u: &ledger_store::FormatUpgrade) -> Self {
Self {
format: u.from.format,
features: u.from.features.clone(),
adds: u.adds(),
}
}
}
/// What a format upgrade did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FormatUpgraded {
/// The books.
pub id: String,
/// What it added, in words; empty when the books were already in this
/// version's format (nothing was done).
pub added: Vec<String>,
/// The verified backup made first: the books as they were.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backup: Option<UpgradeBackup>,
/// The books' head now.
pub head: (u64, String),
}
/// The backup a format upgrade made first, proven to hold the books as
/// they were, their vault and keys too.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpgradeBackup {
/// Its commit in the books' own backup repository.
pub commit: String,
/// The head it holds.
pub head: (u64, String),
}
/// A token, as listed (never its text).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenInfo {
@@ -200,6 +281,17 @@ pub struct BookStatus {
/// Why it couldn't be opened, if it couldn't.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
/// The format upgrade it waits for, if it does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatWaiting>,
/// The projection database's file in use (a name, no folder), if it
/// opened (ledger_store::db_file_for).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database: Option<String>,
/// Other schemas' projection files beside it, left as they are for the
/// versions that wrote them (never deleted).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub other_databases: Vec<String>,
}
/// A setting an adoption would move in.
@@ -259,6 +351,10 @@ pub struct Adopted {
pub settings_ignored: bool,
/// The source had no lock file, so one was made beside its journal.
pub source_lock_created: bool,
/// The copy was moved to this version's journal format (the source's
/// is older, and keeps its own).
#[serde(default)]
pub format_upgraded: bool,
/// Why the adopted books didn't open, if they didn't (they are kept;
/// closing the books and opening them again tries again).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -359,6 +455,8 @@ pub enum Response {
Tokens(Vec<TokenInfo>),
/// A token ended.
TokenRevoked(TokenInfo),
/// A format upgrade made (or none needed).
FormatUpgraded(FormatUpgraded),
/// Refused, with why (no path in the data root: INVARIANT 3).
Refused(String),
}
@@ -409,6 +507,9 @@ pub fn call_within(
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
Some("`wanderbooks token list` shows the tokens there are")
}
Request::UpgradeFormat { .. } => {
Some("`wanderbooks books list` says whether the books still wait for it")
}
};
let unknown = move |why: String| match look {
None => CallError::Unknown(why),
@@ -555,11 +656,37 @@ mod tests {
#[test]
fn answers_round_trip() {
for r in [
Response::Books(vec![BookStatus {
id: "acme".into(),
open: false,
problem: Some("damaged".into()),
}]),
Response::Books(vec![
BookStatus {
id: "acme".into(),
open: false,
problem: Some("damaged".into()),
format_upgrade: None,
database: None,
other_databases: Vec::new(),
},
BookStatus {
id: "beta".into(),
open: true,
problem: None,
format_upgrade: Some(FormatWaiting {
format: 1,
features: Vec::new(),
adds: vec!["journal format 2".into()],
}),
database: Some("wanderbooks.v13.db".into()),
other_databases: vec!["wanderbooks.db".into()],
},
]),
Response::FormatUpgraded(FormatUpgraded {
id: "beta".into(),
added: vec!["journal format 2".into()],
backup: Some(UpgradeBackup {
commit: "c".repeat(40),
head: (7, "ab".into()),
}),
head: (8, "cd".into()),
}),
Response::Created {
id: "acme".into(),
problem: None,
@@ -598,6 +725,42 @@ mod tests {
}
}
/// INVARIANT 7: the go-ahead for a format upgrade carries the owner
/// passphrase as written, and Debug never shows it; a book listed by
/// an earlier server (no format_upgrade) reads as waiting for none.
#[test]
fn an_upgrade_request_carries_the_passphrase_and_never_shows_it() {
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "upgrade_format",
"id": "acme", "passphrase": "correct horse battery"}))
.unwrap();
assert_eq!(
r.request,
Request::UpgradeFormat {
id: "acme".into(),
passphrase: Passphrase("correct horse battery".into()),
}
);
let shown = format!("{r:?}");
assert!(!shown.contains("horse"), "{shown}");
assert!(shown.contains("Passphrase(…)"), "{shown}");
assert!(
serde_json::from_value::<Envelope>(
json!({"proto": 1, "op": "upgrade_format", "id": "acme"})
)
.is_err(),
"an upgrade names the passphrase"
);
let listed: BookStatus =
serde_json::from_value(json!({"id": "acme", "open": true})).unwrap();
assert_eq!(listed.format_upgrade, None);
let up = ledger_store::FormatUpgrade {
from: ledger_store::JournalFormat::default(),
to: ledger_store::format::current(),
};
let waiting = FormatWaiting::from(&up);
assert_eq!((waiting.format, waiting.adds), (1, up.adds()));
}
#[test]
fn token_requests_read_as_written() {
let r: Envelope = serde_json::from_value(json!({"proto": 1, "op": "token_create",
+201 -1
View File
@@ -9,7 +9,9 @@
//! and the owner (and agent) can see that it did, or why not.
//! SOURCE: plan 3.7 (triggers, "ops/backup.jsonl", books.backup, Needs
//! attention for a target more than 24 h behind, drills), D-30 (a
//! git repository and a folder), D-31 (plain git).
//! git repository and a folder), D-31 (plain git), 3.9 and D-39
//! (a verified local backup with the keys before a format
//! upgrade).
//! INVARIANTS:
//! 1. A backup is committed under the store's lock (ledger_store::backup
//! INVARIANT 1; its stored files staged before, outside it) and
@@ -41,6 +43,11 @@
//! theirs): it is paused for them, and said, until another is named.
//! 8. A damaged backup record never keeps the books shut: it is set
//! aside and a new one begun.
//! 9. A format upgrade (plan 3.9, D-39) is made only after the books are
//! committed to their archive and the archive proven to hold them
//! exactly as they are (journal, stored files, vault and keys), all
//! under the store's lock, so nothing is written in between; a backup
//! that can't be made or proven upgrades nothing. Both are records.
//! ```
use crate::server::SharedStore;
@@ -74,6 +81,76 @@ pub struct Backups {
running: tokio::sync::Mutex<()>,
}
/// What a format upgrade did ([`Backups::upgrade_format`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upgraded {
/// The upgrade; None when the books were already in this version's
/// format (nothing was done).
pub upgrade: Option<ledger_store::FormatUpgrade>,
/// The verified backup made first (None when nothing was upgraded).
pub backup: Option<UpgradeBackup>,
/// The books' head now.
pub head: (u64, String),
}
/// The backup a format upgrade made first: the books as they were.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpgradeBackup {
/// Whether it was committed now (false: the last backup already held
/// the books as they were).
pub made: bool,
/// Its commit in the books' archive.
pub commit: String,
/// The head it holds: the books before the upgrade.
pub head: (u64, String),
}
/// Why a format upgrade wasn't made: nothing was upgraded.
struct UpgradeFailed {
/// The backup committed on the way, if one was.
made: Option<backup::Made>,
/// Why, in words.
why: String,
}
impl UpgradeFailed {
/// The backup couldn't be made.
fn backup(e: BackupError) -> Self {
Self {
made: None,
why: format!(
"the backup the upgrade makes first couldn't be made ({e}): nothing was upgraded"
),
}
}
/// The backup `made` (now, or earlier) didn't prove whole.
fn proof(made: Option<backup::Made>, e: BackupError) -> Self {
Self {
made,
why: format!(
"the backup the upgrade makes first didn't prove whole ({e}): nothing was upgraded"
),
}
}
/// Proven, and the books refused the upgrade.
fn store(made: Option<backup::Made>, e: ledger_store::StoreError) -> Self {
Self {
made,
why: format!("{e}: nothing was upgraded"),
}
}
/// The books server stopped, or the books are closing.
fn stopped(why: String) -> Self {
Self {
made: None,
why: format!("{why}: nothing was upgraded"),
}
}
}
/// One target's standing (INVARIANT 3).
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TargetStatus {
@@ -345,6 +422,129 @@ impl Backups {
Ok(self.status().await)
}
/// The format upgrade, after a verified local backup with the keys
/// (plan/served.md 3.9, D-39; INVARIANT 9). The caller has the owner's
/// go-ahead (the passphrase, checked by the server).
///
/// Under the store's lock, so nothing is written between the backup
/// and the upgrade: the books committed to their archive (made
/// whatever targets the settings name, or none), the archive cloned
/// and proven to hold the books exactly as they are, journal, stored
/// files, vault and keys ([`backup::prove_archive`]); only then this
/// version's journal format declared (ledger_store
/// `Store::upgrade_format`). Books already in it: nothing is done.
/// Both the backup and the upgrade are records of `ops/backup.jsonl`;
/// the targets take them at the next backup, the books having moved.
pub async fn upgrade_format(&self) -> Result<Upgraded, String> {
let _one = self.running.lock().await;
let (store, dir, book) = (self.store.clone(), self.dir.clone(), self.book.clone());
let done = tokio::task::spawn_blocking(move || {
// Books that wait for nothing are left as they are: not even
// their archive is made or staged (asked again under the lock
// below; only an upgrade moves the format, one at a time).
match store.with_store(|s| (s.journal_format().upgrade().is_none(), s.head())) {
Ok((true, head)) => {
return Ok(Upgraded {
upgrade: None,
backup: None,
head,
});
}
Ok((false, _)) => {}
Err(why) => return Err(UpgradeFailed::stopped(why)),
}
// Stored files staged ahead, outside the lock (as a backup's).
let git = Git::find();
if let Ok(git) = &git {
let _ = backup::stage_blobs(git, &dir);
}
store
.with_store_mut(|s| -> Result<Upgraded, UpgradeFailed> {
let head = s.head();
let Some(waiting) = s.journal_format().upgrade() else {
return Ok(Upgraded {
upgrade: None,
backup: None,
head,
});
};
let git = git.map_err(UpgradeFailed::backup)?;
let made = backup::commit(&git, &dir, &book, head.clone())
.map_err(UpgradeFailed::backup)?;
let scratch = ledger_store::ops::ops_dir(&dir).join("upgrade-check");
let _ = std::fs::remove_dir_all(&scratch);
let proven = backup::prove_archive(&git, &dir, &head, &scratch)
.and_then(|p| match &made {
// The commit proven is the one just made.
Some(m) if m.commit != p.commit => Err(BackupError::Damaged(
"the backup it proved isn't the one it made".into(),
)),
_ => Ok(p),
})
.map_err(|e| UpgradeFailed::proof(made.clone(), e))?;
let commit = proven.commit;
let upgrade = match s.upgrade_format() {
Ok(upgrade) => upgrade,
// Journaled, and what follows the line failed (its
// database row, a full disk): the books are in this
// version's format all the same, and are never told
// otherwise (the database catches up as the books
// next open).
Err(e) if s.journal_format().upgrade().is_none() => {
eprintln!(
"wanderbooks server: the format upgrade was journaled, then: {}",
hide(&e.to_string(), &dir)
);
Some(waiting)
}
Err(e) => return Err(UpgradeFailed::store(made.clone(), e)),
};
Ok(Upgraded {
upgrade,
backup: Some(UpgradeBackup {
made: made.is_some(),
commit,
head,
}),
head: s.head(),
})
})
.map_err(UpgradeFailed::stopped)
.and_then(|r| r)
})
.await
.unwrap_or_else(|_| Err(UpgradeFailed::stopped("the upgrade stopped".into())));
match done {
Ok(u) => {
if let (Some(b), Some(up)) = (&u.backup, &u.upgrade) {
if b.made {
self.record(
json!({"kind": "commit", "why": "format_upgrade", "ok": true,
"commit": b.commit, "head": {"seq": b.head.0, "hash": b.head.1}}),
);
}
self.record(json!({"kind": "format_upgrade", "ok": true,
"backup": {"commit": b.commit, "head": {"seq": b.head.0, "hash": b.head.1}},
"from": {"format": up.from.format, "features": up.from.features},
"to": {"format": up.to.format, "features": up.to.features},
"head": {"seq": u.head.0, "hash": u.head.1}}));
}
Ok(u)
}
Err(failed) => {
if let Some(m) = &failed.made {
self.record(
json!({"kind": "commit", "why": "format_upgrade", "ok": true,
"commit": m.commit, "head": {"seq": m.head.0, "hash": m.head.1}}),
);
}
let why = hide(&failed.why, &self.dir);
self.record(json!({"kind": "format_upgrade", "ok": false, "error": why}));
Err(why)
}
}
}
/// Whether a drill is due (INVARIANT 5).
pub async fn drill_due(&self) -> bool {
let records = self.records();
+4
View File
@@ -83,6 +83,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
),
("viewer", include_str!("../handbook/viewer.md")),
("backups", include_str!("../handbook/backups.md")),
(
"format-upgrade",
include_str!("../handbook/format-upgrade.md"),
),
(
"rules-that-change",
include_str!("../handbook/rules-that-change.md"),
+3 -2
View File
@@ -5,8 +5,9 @@
//!
//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH]`
//!
//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db`
//! (the disposable SQLite projection), `blobs/` (content-addressed receipts),
//! The data dir holds `journal.jsonl` (the source of truth),
//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each
//! schema: ledger_store::db_file_for), `blobs/` (content-addressed receipts),
//! and optionally `config.json`. If no store exists, one is created with the
//! configured base currency. Company settings (name, fiscal year start,
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
+411
View File
@@ -136,6 +136,13 @@ enum Book {
exports: ledger_store::exports::Exports,
/// Its backups (backup.rs).
backups: Arc<crate::backup::Backups>,
/// The format upgrade it waits for, if any (plan 3.9, D-39): read
/// from its journal as it opened, and cleared by the upgrade (only
/// the upgrade moves an open book's format).
waiting: Arc<Mutex<Option<ledger_store::FormatUpgrade>>>,
/// Its projection database's file, and other schemas' beside it,
/// by name, as it opened (`wanderbooks doctor` says them).
database: (String, Vec<String>),
},
/// Not opened, with why (INVARIANT 3).
Failed(String),
@@ -289,6 +296,8 @@ impl Books {
}
let mut live = Live::own();
live.sync(store.ledger().settings(), store.base_currency().code());
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
let database = (store.database_file(), store.other_databases());
let layout = Layout::Root(self.root.path().to_path_buf());
let access = Arc::new(
AccessLog::open(store.data_dir())
@@ -347,6 +356,8 @@ impl Books {
uploads,
exports,
backups,
waiting,
database,
})
}
@@ -386,6 +397,22 @@ impl Books {
Book::Open { .. } => None,
Book::Failed(why) => Some(why.clone()),
},
format_upgrade: match book {
Book::Open { waiting, .. } => waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.map(admin::FormatWaiting::from),
Book::Failed(_) => None,
},
database: match book {
Book::Open { database, .. } => Some(database.0.clone()),
Book::Failed(_) => None,
},
other_databases: match book {
Book::Open { database, .. } => database.1.clone(),
Book::Failed(_) => Vec::new(),
},
})
.collect(),
),
@@ -490,6 +517,7 @@ impl Books {
confirmed,
expect_head,
} => self.restore(id, source, replace, confirmed, expect_head),
Request::UpgradeFormat { id, passphrase } => self.upgrade_format(id, &passphrase),
Request::BackUpNow { id, new_disk } => {
let id = match BookId::parse(&id) {
Ok(id) => id,
@@ -655,6 +683,7 @@ impl Books {
settings_imported: r.settings_imported,
settings_ignored: r.settings_ignored,
source_lock_created: r.source_lock_created,
format_upgraded: r.format_upgraded,
problem,
})
}
@@ -666,6 +695,74 @@ impl Books {
}
impl Books {
/// The owner's go-ahead for a format upgrade (plan 3.9, D-39; admin
/// INVARIANT 7): the owner passphrase checked (Argon2id, as the human
/// pages check it) before anything is done; then a verified backup
/// with the keys and the upgrade, under the books' lock
/// (backup::Backups::upgrade_format). Books already in this version's
/// format: nothing is done, and the answer says so.
fn upgrade_format(&self, id: String, passphrase: &admin::Passphrase) -> Response {
let id = match BookId::parse(&id) {
Ok(id) => id,
Err(e) => return Response::Refused(self.hide(e.to_string())),
};
let (backups, waiting) = match self.held().get(id.as_str()) {
Some(Book::Open {
backups, waiting, ..
}) => (backups.clone(), waiting.clone()),
Some(Book::Failed(why)) => {
return Response::Refused(format!("those books didn't open: {why}"));
}
None => {
return Response::Refused(format!(
"there are no books called {:?}: `wanderbooks books list` lists them",
id.as_str()
));
}
};
let auth = match self.root.auth_dir() {
Ok(dir) => dir,
Err(e) => return Response::Refused(self.hide(e.to_string())),
};
match ledger_store::passphrase::verify(&auth, &passphrase.0) {
Ok(true) => {}
Ok(false) => {
return Response::Refused(
"that isn't the owner passphrase: nothing was changed".into(),
);
}
Err(e) => {
return Response::Refused(format!(
"{}: nothing was changed",
self.hide(e.to_string())
));
}
}
let rt = tokio::runtime::Handle::current();
match rt.block_on(backups.upgrade_format()) {
Ok(done) => {
// The books are in this version's format now (or were).
*waiting.lock().unwrap_or_else(|e| e.into_inner()) = None;
if let Some(up) = &done.upgrade {
eprintln!(
"wanderbooks server: the books {id} were upgraded to this version's journal format ({}), after a verified backup",
up.adds().join(", ")
);
}
Response::FormatUpgraded(admin::FormatUpgraded {
id: id.to_string(),
added: done.upgrade.map(|u| u.adds()).unwrap_or_default(),
backup: done.backup.map(|b| admin::UpgradeBackup {
commit: b.commit,
head: b.head,
}),
head: done.head,
})
}
Err(why) => Response::Refused(self.hide(why)),
}
}
/// Restore a backup (admin INVARIANT 4; ledger_store::root INVARIANT
/// 7): a preview, or the books restored and opened. Books it replaces
/// are let go first, and opened again if the restore fails.
@@ -2976,4 +3073,318 @@ mod tests {
);
assert_eq!(bound(&books).len(), MAX_SESSIONS);
}
/// The owner passphrase the upgrade tests set.
const PASS: &str = "correct horse battery";
/// Books `id` as an older version wrote them (journal format 1), with
/// a chart, an entry and a vault key, held by the server.
fn old_books(books: &Books, id: &str) -> PathBuf {
let id = BookId::parse(id).unwrap();
let dir = books.root.book_dir(&id);
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::os::unix::fs::PermissionsExt::from_mode(0o700))
.unwrap();
let mut s =
Store::create_in_format(&dir, ledger_core::Currency::USD, &Default::default()).unwrap();
let cash = s
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
let cap = s
.add_account(
"3000",
"Capital",
ledger_core::AccountType::Equity,
None,
false,
)
.unwrap();
let e = s
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "In")
.unwrap();
let m =
ledger_core::Money::new(rust_decimal::Decimal::from(500), ledger_core::Currency::USD);
s.add_line(e, cash, ledger_core::Side::Debit, m).unwrap();
s.add_line(e, cap, ledger_core::Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
drop(s);
let keys = dir.join(ledger_store::root::KEYS_DIR);
std::fs::create_dir(&keys).unwrap();
std::fs::write(
keys.join(ledger_store::root::LEGACY_KEY_FILE),
"ab".repeat(32),
)
.unwrap();
books.hold(&id, books.open(&id));
dir
}
/// The books `id` as listed.
fn listed(books: &Books, id: &str) -> admin::BookStatus {
match books.admin(Request::List) {
Response::Books(list) => list.into_iter().find(|b| b.id == id).unwrap(),
other => panic!("{other:?}"),
}
}
/// Ask for the upgrade of `id` with `pass`, off the runtime (as the
/// admin socket does).
async fn upgrade(books: &Arc<Books>, id: &str, pass: &str) -> Response {
let (books, id, pass) = (books.clone(), id.to_string(), pass.to_string());
tokio::task::spawn_blocking(move || {
books.admin(Request::UpgradeFormat {
id,
passphrase: admin::Passphrase(pass),
})
})
.await
.unwrap()
}
/// git, on the books' own archive: what it said.
fn archive_git(dir: &Path, args: &[&str]) -> String {
let out = std::process::Command::new("git")
.arg(format!("--git-dir={}", dir.join("archive.git").display()))
.args(args)
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
String::from_utf8(out.stdout).unwrap()
}
/// plan/served.md 3.9's `format_upgrade_waits_for_consent`, the
/// server's part: books an older version wrote are served on their old
/// format and listed as waiting; the go-ahead is refused without the
/// owner passphrase (checked before anything is done: no backup, no
/// line), and the books stay served as they were; with it, a backup of
/// the books as they were, their keys in it, is committed and proven,
/// then the whole format is declared in one line; asked again, nothing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn format_upgrade_waits_for_consent() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
let dir = old_books(&books, "acme");
let journal = || std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let before = journal();
let waiting = listed(&books, "acme").format_upgrade.expect("it waits");
// The database file it uses: this version's schema's own.
assert_eq!(
listed(&books, "acme").database.as_deref(),
Some(ledger_store::db_file().as_str())
);
// Format 1 waits for this version's whole format: format 2 and
// every feature it knows.
let adds = ledger_store::JournalFormat::default()
.upgrade()
.unwrap()
.adds();
assert_eq!(adds[0], "journal format 2");
assert_eq!(waiting.format, 1);
assert_eq!(waiting.adds, adds);
// No passphrase set yet, then a wrong one, then none: refused,
// nothing done.
match upgrade(&books, "acme", PASS).await {
Response::Refused(why) => {
assert!(why.contains("no owner passphrase is set yet"), "{why}");
assert!(why.contains("nothing was changed"), "{why}");
}
other => panic!("{other:?}"),
}
let auth = books.root.auth_dir().unwrap();
ledger_store::passphrase::set(&auth, PASS, None).unwrap();
for wrong in ["not the passphrase at all", ""] {
match upgrade(&books, "acme", wrong).await {
Response::Refused(why) => {
assert_eq!(why, "that isn't the owner passphrase: nothing was changed")
}
other => panic!("{other:?}"),
}
}
assert_eq!(journal(), before);
assert!(
!dir.join("archive.git").exists(),
"no backup before consent"
);
assert!(listed(&books, "acme").format_upgrade.is_some());
assert_eq!(books.held()["acme"].problem(), None, "still served");
// Books that don't exist, or didn't open.
assert!(matches!(
upgrade(&books, "nobody", PASS).await,
Response::Refused(_)
));
// The go-ahead.
let done = match upgrade(&books, "acme", PASS).await {
Response::FormatUpgraded(u) => u,
other => panic!("{other:?}"),
};
assert_eq!(done.id, "acme");
assert_eq!(done.added, adds);
let backup = done.backup.clone().expect("backed up first");
let after = journal();
assert!(after.starts_with(&before));
let added: Vec<serde_json::Value> = String::from_utf8(after[before.len()..].to_vec())
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(added.len(), 1, "one line");
assert_eq!(added[0]["type"], "journal.format_set");
let c = ledger_store::format::current();
assert_eq!(
added[0]["payload"],
serde_json::json!({"format": c.format, "features": c.features})
);
assert_eq!(done.head.0, backup.head.0 + 1);
// The backup: the books as they were, keys and all, the latest
// commit of their archive.
assert_eq!(
archive_git(&dir, &["rev-parse", "HEAD"]).trim(),
backup.commit
);
assert_eq!(
archive_git(&dir, &["show", &format!("{}:journal.jsonl", backup.commit)]).as_bytes(),
before.as_slice()
);
assert_eq!(
archive_git(
&dir,
&["show", &format!("{}:keys/legacy.key", backup.commit)]
),
"ab".repeat(32)
);
// Both on the backup record.
let record =
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
.unwrap();
assert!(record.contains(r#""kind":"format_upgrade""#), "{record}");
assert!(record.contains(&backup.commit), "{record}");
assert!(
!ledger_store::ops::ops_dir(&dir)
.join("upgrade-check")
.exists()
);
// Waiting for nothing now; asked again, nothing is done.
assert_eq!(listed(&books, "acme").format_upgrade, None);
match upgrade(&books, "acme", PASS).await {
Response::FormatUpgraded(u) => {
assert!(u.added.is_empty() && u.backup.is_none(), "{u:?}");
}
other => panic!("{other:?}"),
}
assert_eq!(journal(), after);
// Books this version made wait for nothing, and asking for their
// upgrade changes nothing of them: not even a backup repository.
create(&books, "beta");
assert_eq!(listed(&books, "beta").format_upgrade, None);
let beta = books.root.book_dir(&BookId::parse("beta").unwrap());
match upgrade(&books, "beta", PASS).await {
Response::FormatUpgraded(u) => {
assert!(u.added.is_empty() && u.backup.is_none(), "{u:?}");
}
other => panic!("{other:?}"),
}
assert!(!beta.join("archive.git").exists(), "nothing was changed");
}
/// The backup is trusted only once proven, and only the one just made:
/// an archive whose head names a branch other than the one a backup's
/// clone takes (a damaged archive) takes the new commit where no
/// clone finds it, so what is proven isn't what was made: nothing is
/// upgraded, and it is recorded. Once the archive is right, its latest
/// commit (which already holds the books) is proven and the upgrade
/// made.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_upgrade_unless_the_backup_just_made_is_proven() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
let dir = old_books(&books, "acme");
ledger_store::passphrase::set(&books.root.auth_dir().unwrap(), PASS, None).unwrap();
let store = match &books.held()["acme"] {
Book::Open { store, .. } => store.clone(),
Book::Failed(why) => panic!("{why}"),
};
let (d, s) = (dir.clone(), store.clone());
let first = tokio::task::spawn_blocking(move || {
let git = ledger_store::backup::Git::find().unwrap();
let head = s.with_store(|s| s.head()).unwrap();
ledger_store::backup::commit(&git, &d, "acme", head).unwrap()
})
.await
.unwrap()
.expect("a first backup");
archive_git(&dir, &["symbolic-ref", "HEAD", "refs/heads/elsewhere"]);
// A second later: the commit made now, of the same files, isn't
// byte for byte the first (git dates commits to the second).
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
let before = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
match upgrade(&books, "acme", PASS).await {
Response::Refused(why) => {
assert!(why.contains("didn't prove whole"), "{why}");
assert!(why.contains("nothing was upgraded"), "{why}");
}
other => panic!("{other:?}"),
}
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
before
);
assert!(listed(&books, "acme").format_upgrade.is_some());
let record = || {
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
.unwrap()
};
assert!(
record().contains(r#""why":"format_upgrade""#),
"the commit made: {}",
record()
);
assert!(record().contains(r#""ok":false"#), "{}", record());
// The archive put right: its latest commit holds the books.
archive_git(&dir, &["symbolic-ref", "HEAD", "refs/heads/main"]);
match upgrade(&books, "acme", PASS).await {
Response::FormatUpgraded(u) => {
let b = u.backup.clone().expect("proven");
assert_eq!(b.commit, first.commit, "{u:?}");
assert_eq!(b.head, first.head);
assert_eq!(u.head.0, first.head.0 + 1);
}
other => panic!("{other:?}"),
}
assert_eq!(listed(&books, "acme").format_upgrade, None);
}
/// A backup that can't be made upgrades nothing: the books stay on
/// their old format, served and waiting, and the failure is recorded.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_upgrade_without_a_backup() {
let home = tempfile::tempdir().unwrap();
let books = books_in(home.path());
let dir = old_books(&books, "acme");
ledger_store::passphrase::set(&books.root.auth_dir().unwrap(), PASS, None).unwrap();
// A file no book keeps: the backup refuses it.
std::fs::write(dir.join(".gitattributes"), "* -text\n").unwrap();
let before = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
match upgrade(&books, "acme", PASS).await {
Response::Refused(why) => {
assert!(why.contains("couldn't be made"), "{why}");
assert!(why.contains("nothing was upgraded"), "{why}");
assert!(!why.contains(&home.path().display().to_string()), "{why}");
}
other => panic!("{other:?}"),
}
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
before
);
assert!(listed(&books, "acme").format_upgrade.is_some());
let record =
std::fs::read_to_string(ledger_store::ops::ops_dir(&dir).join(crate::backup::LOG_FILE))
.unwrap();
assert!(
record.contains(r#""kind":"format_upgrade","ok":false"#)
|| (record.contains(r#""kind":"format_upgrade""#)
&& record.contains(r#""ok":false"#)),
"{record}"
);
}
}
+61 -14
View File
@@ -176,6 +176,41 @@ impl SharedStore {
Ok(f(store))
}
/// Run `f` on the store under its lock, writing: the owner's format
/// upgrade, after its backup (backup.rs). Blocking. Refused once the
/// books are closing or the server stopped (INVARIANTS 2, 3), counted
/// as a request while it runs, and one that panics stops the server
/// (INVARIANT 1).
pub fn with_store_mut<R>(
self: &Arc<Self>,
f: impl FnOnce(&mut Store) -> R,
) -> Result<R, String> {
if self.stopped() {
return Err(STOPPED.to_string());
}
// Counted before the closing check, as a tool's request is.
let _running = Running::new(self);
if self.closing() {
return Err("the books are closing".into());
}
let mut store = self.store.blocking_lock();
if self.stopped() {
return Err(STOPPED.to_string());
}
let store = store.as_mut().ok_or(STOPPED)?;
/// Stops the server if `f` panics while it holds the store.
struct StopOnPanic<'a>(&'a SharedStore);
impl Drop for StopOnPanic<'_> {
fn drop(&mut self) {
if std::thread::panicking() {
self.0.stop();
}
}
}
let _stop = StopOnPanic(self);
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> {
@@ -3078,10 +3113,10 @@ impl BooksServer {
));
}
}
// A payment method exists only under the payment_instruments
// feature (books.add_instrument declared it), so tagging the draft
// with one below declares nothing after the draft's first line
// (B-27).
// A payment method exists only in books that declare the
// payment_instruments feature (books.add_instrument needs it), so
// tagging the draft with one below can't be refused for the format
// after the draft's first line (B-27).
let id = store
.create_entry(date, &p.description)
.map_err(|e| e.to_string())?;
@@ -4301,8 +4336,10 @@ impl BooksServer {
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(),
format!(
"SQLite database ({}) matches a fresh projection of the journal, every table, row for row",
report.database
),
];
if let Some(n) = blobs {
checked.push(format!("{n} stored files match their SHA-256"));
@@ -4318,6 +4355,8 @@ impl BooksServer {
head: report.head,
healed: report.healed,
blobs_verified: blobs,
database: report.database,
other_databases: report.other_databases,
checked,
}))
}
@@ -7169,10 +7208,10 @@ impl BooksServer {
.set_party_creditor(id, *a, *on)
.map_err(|e| e.to_string())?;
}
// The creditor lines' journal feature, declared before this
// command's first line, never between its lines (rules.md B-27: a
// refused format upgrade must not leave the name changed and the
// creditors not).
// The creditor lines' journal feature, asked for before this
// command's first line, never between its lines (rules.md B-27:
// books waiting for their format upgrade must not be left with the
// name changed and the creditors not).
if !creditor.is_empty() {
store
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
@@ -7473,9 +7512,9 @@ impl BooksServer {
};
let on_file = facts.is_some();
// The vendor record's W-9 flag follows (below): its feature is
// declared before this command's first line, never after it
// (B-27: a refusal to upgrade the format must not leave the W-9
// recorded and the flag not).
// asked for before this command's first line, never after it
// (B-27: books waiting for their format upgrade must not be left
// with the W-9 recorded and the flag not).
let vendor = store.ledger().get_party(id).and_then(|p| p.vendor);
if vendor
.and_then(|v| store.ledger().get_vendor(v))
@@ -10486,6 +10525,13 @@ mod tests {
};
out.push(("NewerJournal".into(), newer(true).to_string()));
out.push(("NewerJournal unverified".into(), newer(false).to_string()));
out.push((
"FormatUpgradePending".into(),
ledger_store::StoreError::FormatUpgradePending {
need: "journal format 2".into(),
}
.to_string(),
));
out.push((
"RuleNotKnown".into(),
ledger_core::form1099::default_threshold(9999)
@@ -11139,7 +11185,8 @@ mod tests {
let server = BooksServer::new(store(&books), ReceiptPolicy::default());
rt.block_on(async {
let seq: Body = |s| s.lock().map(|st| st.journal_seq());
assert_eq!(run(&server, seq).await.unwrap(), Ok(1), "{how}");
// New books: store.created, then their journal format.
assert_eq!(run(&server, seq).await.unwrap(), Ok(2), "{how}");
let died = run(&server, body).await.unwrap_err();
assert_eq!(died.message, STOPPED, "{how}");
// Every later request is refused, before it runs.
+37 -1
View File
@@ -194,6 +194,19 @@ pub struct SectionOut {
pub title: String,
}
/// A format upgrade the books wait for.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FormatUpgradeOut {
/// The guard: format_upgrade_pending (what a refused change says).
pub guard: String,
/// The journal format the books are in.
pub format: u32,
/// What the upgrade adds, in words.
pub adds: Vec<String>,
/// Who can give the go-ahead, and how.
pub how: String,
}
/// Something to do next.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NextStepOut {
@@ -235,6 +248,12 @@ pub struct StartOut {
pub truncated: Vec<String>,
/// The handbook's sections (books.handbook {section}).
pub handbook_sections: Vec<SectionOut>,
/// The format upgrade these books wait for, if they do (plan 3.9,
/// D-39): an older version wrote them, so they work on their old
/// journal format, and whatever needs the new one is refused, until
/// the owner gives the go-ahead. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatUpgradeOut>,
/// What to do next, in order.
pub next_steps: Vec<NextStepOut>,
/// How the books are kept from agents: "same_user (advisory)", hidden
@@ -778,6 +797,17 @@ impl BooksServer {
why: why.into(),
};
let mut next_steps = Vec::new();
// Books an older version wrote wait for the owner's go-ahead to
// upgrade their format: first, at every start until it is given
// (D-39). Only the owner can give it.
let upgrade = store.journal_format().upgrade();
if upgrade.is_some() {
next_steps.push(step(
"books.handbook",
Some(serde_json::json!({"section": "format-upgrade"})),
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
));
}
// Books that read a settings file move it in before anything else
// changes a setting (books.set_setting refuses until then).
if !journaled {
@@ -897,6 +927,12 @@ impl BooksServer {
title: handbook::title(md).to_string(),
})
.collect(),
format_upgrade: upgrade.map(|u| FormatUpgradeOut {
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
format: u.from.format,
adds: u.adds(),
how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(),
}),
next_steps,
isolation: "same_user (advisory)".to_string(),
};
@@ -968,7 +1004,7 @@ impl BooksServer {
#[tool(
name = "books.start",
title = "Start here",
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, and next steps. Read-only; at most 8 KB.",
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
annotations(read_only_hint = true, open_world_hint = false)
)]
pub(super) async fn start(
+9 -1
View File
@@ -677,6 +677,14 @@ pub struct VerifyOut {
pub healed: Option<String>,
/// Stored files re-hashed, when checked.
pub blobs_verified: Option<u64>,
/// The books' database file verified (a file for each schema, named for
/// it: "wanderbooks.v13.db", or the older "wanderbooks.db" when it holds
/// this version's schema).
pub database: String,
/// Other schemas' database files beside it: left as they are for the
/// versions that use them, never deleted.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub other_databases: Vec<String>,
/// What was checked, in words.
pub checked: Vec<String>,
}
@@ -4012,7 +4020,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams {
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
#[serde(default)]
pub section: Option<String>,
}