diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index d9a62c3..574e52b 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -252,6 +252,8 @@ open_at_start = "ask" const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl. books-data/wanderbooks.db books-data/wanderbooks.db-* +# Held while an agent's MCP server has the books open: one writer at a time. +books-data/wanderbooks.lock # Generated exports can always be regenerated from the journal. exports/ # The background viewer's log (`just gui`). diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index dab4c83..05cf044 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -65,6 +65,12 @@ pub enum StoreError { #[error("store already exists at {0}")] AlreadyExists(String), + /// Another process has these books open for writing. One writer at a + /// time: two would each extend the hash chain from the same place and + /// break it (found by the max-testing pass, 2026-10-13). + #[error("{0}")] + Locked(String), + /// `open` called where no store exists. #[error("store not found at {0}")] NotFound(String), diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 3e2a2c9..37a97df 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -32,5 +32,5 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; pub use snapshot::{DiskCheck, Snapshot}; pub use store::{ Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, - PayrollLineInput, Store, VerifyReport, + LOCK_FILE, PayrollLineInput, Store, VerifyReport, }; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 8af53c1..205331b 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -29,6 +29,40 @@ use std::path::{Path, PathBuf}; pub const JOURNAL_FILE: &str = "journal.jsonl"; /// File names inside the data directory. pub const DB_FILE: &str = "wanderbooks.db"; + +/// The lock file a writer holds while the books are open. Readers (the +/// viewer's [`crate::Snapshot`]) never take it. +pub const LOCK_FILE: &str = "wanderbooks.lock"; + +/// Take the books' writer lock, or say who has it. +/// +/// ```text +/// GOAL: one writer at a time. +/// SOURCE: R-11 (one append-only log); docs/receipt-store.md (single write +/// path). +/// SCENARIOS: +/// 1. IF another process (or another Store in this one) has the books +/// open for writing: +/// - SHOULD refuse with Locked, saying so +/// - NEVER let two writers extend the journal +/// BECAUSE each would chain from the same last line and break it. +/// ``` +fn lock_books(data_dir: &Path) -> Result { + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(data_dir.join(LOCK_FILE))?; + match file.try_lock() { + Ok(()) => Ok(file), + Err(std::fs::TryLockError::WouldBlock) => Err(StoreError::Locked(format!( + "another wanderbooks process has the books in {} open for writing (an agent session, \ + or its MCP server); close it first. The viewer is fine: it only reads", + data_dir.display() + ))), + Err(std::fs::TryLockError::Error(e)) => Err(StoreError::Io(e)), + } +} pub(crate) const BLOBS_DIR: &str = "blobs"; /// One row of an entry listing. @@ -133,6 +167,10 @@ pub type PayrollLineInput = PayrollLine; /// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`). /// There is exactly one writer per data directory. pub struct Store { + /// The exclusive lock on [`LOCK_FILE`], held while the store is open: + /// one writer at a time. Released when dropped, or when the process + /// exits, even after a crash. + _lock: std::fs::File, data_dir: PathBuf, db_path: PathBuf, journal: JournalWriter, @@ -165,12 +203,14 @@ impl Store { return Err(StoreError::AlreadyExists(data_dir.display().to_string())); } std::fs::create_dir_all(data_dir)?; + let lock = lock_books(data_dir)?; let db_path = data_dir.join(DB_FILE); let conn = Self::open_conn(&db_path)?; Self::migrate(&conn)?; Self::write_meta(&conn, "base_currency", base_currency.code())?; Self::write_meta(&conn, "last_applied_seq", "0")?; let mut store = Self { + _lock: lock, data_dir: data_dir.to_path_buf(), db_path, journal: JournalWriter::open(&journal_path)?, @@ -195,6 +235,8 @@ impl Store { if !journal_path.exists() { return Err(StoreError::NotFound(data_dir.display().to_string())); } + // The writer lock first: nothing is read until no one else writes. + let lock = lock_books(data_dir)?; // Single verified scan; the writer reuses it. let lines = read_and_verify_journal(&journal_path)?; let journal = JournalWriter::from_lines(&journal_path, &lines)?; @@ -253,6 +295,7 @@ impl Store { cur }; let mut store = Self { + _lock: lock, data_dir: data_dir.to_path_buf(), db_path, journal, diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 953a940..3eae5c0 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1088,3 +1088,29 @@ fn a_journal_line_with_a_float_is_refused() { canon ); } + +/// Regression (max-testing pass on the store, 2026-10-13): nothing stopped +/// two processes (say, two agent sessions, each with its own MCP server) +/// from writing the same books. One write from each broke the hash chain +/// ("seq gap") and the books would not open. Oracle (rank 1, R-11: one +/// append-only log, one writer): a second writer is refused while the +/// first is open, and allowed once it closes; the books always reopen. +#[test] +fn a_second_writer_is_refused_while_the_first_has_the_books() { + let dir = test_dir("two-writers"); + drop(Store::create(&dir, Currency::USD).unwrap()); + let mut first = Store::open(&dir).unwrap(); + match Store::open(&dir) { + Err(StoreError::Locked(why)) => assert!(why.contains("another"), "{why}"), + Err(e) => panic!("expected Locked, got {e}"), + Ok(_) => panic!("a second writer was allowed"), + } + first + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + drop(first); + // Released on close: the next writer gets in, and the books are whole. + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert_eq!(reopened.ledger().chart().len(), 1); +}