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

+99 -48
View File
@@ -1,18 +1,22 @@
//! A party named the creditor of a liability (rules.md B-35, P3 defect
//! 24), in the store: journaled as `party.creditor_set {id, account_id,
//! on}`, after the journal declares the feature it needs
//! (`party_creditors`) the first time, and never otherwise.
//! on}`, which needs the journal feature `party_creditors`: books this
//! version made declared it when they were created; books an older
//! version wrote refuse it until their format upgrade (rules.md B-27).
//!
//! Oracles: `Store::set_party_creditor`'s doc, `format::KNOWN_FEATURES`
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (nothing journaled
//! when the feature is in force; books that never use it never change),
//! and `Ledger::set_party_creditor`'s SCENARIOS. Expected values by hand.
//! and `PARTY_CREDITORS`, `Store::ensure_feature`'s doc (refused,
//! journaling nothing, unless the books declared it; books that never use
//! it never change), and `Ledger::set_party_creditor`'s SCENARIOS.
//! Expected values by hand.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS};
use ledger_store::format::{FORMAT_SET, KNOWN_FEATURES, PARTY_CREDITORS, current};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Snapshot, Store};
use ledger_store::{
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, db_file,
};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -55,7 +59,13 @@ fn bytes(dir: &Path) -> Vec<u8> {
/// Books with a card, the members' financing account, a member, and a
/// posted entry.
fn books(dir: &Path) -> (Store, u64, u64, u64, u64) {
let mut s = Store::create(dir, Currency::USD).unwrap();
books_in(dir, &current())
}
/// [`books`] in the journal format `format`: format 1
/// ([`JournalFormat::default`]) is books an older version wrote.
fn books_in(dir: &Path, format: &JournalFormat) -> (Store, u64, u64, u64, u64) {
let mut s = Store::create_in_format(dir, Currency::USD, format).unwrap();
let cash = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
@@ -103,40 +113,48 @@ fn party_creditors_is_a_known_feature() {
assert!(KNOWN_FEATURES.contains(&PARTY_CREDITORS));
}
/// Store::set_party_creditor doc: the first one declares the feature (one
/// journal.format_set keeping the format, at least 2, and every feature
/// already declared), then journals party.creditor_set; later ones journal
/// one line each; the books replay to the same directory, through the
/// store and the viewer's snapshot alike.
/// Store::set_party_creditor doc: books this version made declared the
/// feature when they were created (their second line, the only
/// declaration), so each creditor named journals one line,
/// party.creditor_set, and nothing else; the books replay to the same
/// directory, through the store and the viewer's snapshot alike.
#[test]
fn naming_a_creditor_declares_its_feature_once() {
fn naming_a_creditor_journals_one_line_each_time() {
let dir = test_dir("declare");
let (mut s, card, due, issuer, tobin) = books(&dir);
let before = journal(&dir).len();
let j = journal(&dir);
assert_eq!(j[1].0, FORMAT_SET);
assert!(
j[1].1["features"]
.as_array()
.unwrap()
.contains(&json!("party_creditors"))
);
let before = j.len();
s.set_party_creditor(issuer, card, true).unwrap();
let j = journal(&dir);
assert_eq!(j.len(), before + 2);
assert_eq!(j[before].0, FORMAT_SET);
assert_eq!(j.len(), before + 1);
assert_eq!(
j[before].1,
json!({"format": 2, "features": ["party_creditors"]})
);
assert_eq!(
j[before + 1],
j[before],
(
"party.creditor_set".to_string(),
json!({"id": issuer, "account_id": card, "on": true})
)
);
assert_eq!(s.journal_format().features, vec!["party_creditors"]);
s.set_party_creditor(tobin, due, true).unwrap();
s.set_party_creditor(tobin, due, false).unwrap();
let j = journal(&dir);
assert_eq!(j.len(), before + 4, "the feature is declared once");
assert_eq!(j.len(), before + 3, "one line each");
assert_eq!(
j[before + 3].1,
j[before + 2].1,
json!({"id": tobin, "account_id": due, "on": false})
);
assert_eq!(
j.iter().filter(|(t, _)| t == FORMAT_SET).count(),
1,
"nothing declared on the way"
);
assert_eq!(s.journal_format(), &current());
s.set_party_creditor(tobin, due, true).unwrap();
let live = s.ledger().clone();
s.verify().unwrap();
@@ -148,15 +166,40 @@ fn naming_a_creditor_declares_its_feature_once() {
drop(s);
let snap = Snapshot::load(&dir).unwrap();
assert!(*snap.ledger() == live);
assert_eq!(snap.journal_format().features, vec!["party_creditors"]);
assert_eq!(snap.journal_format(), &current());
// Rebuilt from the journal alone.
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
let s = Store::open(&dir).unwrap();
assert!(*s.ledger() == live);
}
/// Books an older version wrote (format 1, declaring nothing) refuse a
/// creditor while they wait for their format upgrade, journaling nothing,
/// not the line and not the feature; once the owner upgrades them, the
/// same command journals its one line.
#[test]
fn a_creditor_in_older_books_waits_for_the_format_upgrade() {
let dir = test_dir("older");
let (mut s, card, _, issuer, _) = books_in(&dir, &JournalFormat::default());
let was = bytes(&dir);
assert!(matches!(
s.set_party_creditor(issuer, card, true),
Err(StoreError::FormatUpgradePending { need }) if need.contains("party_creditors")
));
assert_eq!(bytes(&dir), was, "nothing journaled");
assert!(s.ledger().creditors_of(card).is_empty());
assert!(s.upgrade_format().unwrap().is_some());
let before = journal(&dir).len();
s.set_party_creditor(issuer, card, true).unwrap();
let j = journal(&dir);
assert_eq!(j.len(), before + 1);
assert_eq!(j[before].0, "party.creditor_set");
s.verify().unwrap();
assert_eq!(s.ledger().creditors_of(card), vec![issuer]);
}
/// A refusal journals nothing: not the line, and not the feature either.
#[test]
fn a_refused_creditor_journals_nothing() {
@@ -164,34 +207,42 @@ fn a_refused_creditor_journals_nothing() {
let (mut s, _, _, issuer, _) = books(&dir);
let cash = s.ledger().chart().find_by_code("1000").unwrap().id;
let was = bytes(&dir);
let format = s.journal_format().clone();
for (party, account) in [(issuer, cash), (999, cash), (issuer, 999)] {
assert!(s.set_party_creditor(party, account, true).is_err());
assert_eq!(bytes(&dir), was);
}
assert!(s.journal_format().features.is_empty());
assert_eq!(s.journal_format(), &format);
}
/// Books that never name a creditor never declare the feature: opening,
/// verifying and rebuilding them leaves the journal byte-identical, in the
/// format it was in.
/// Books that never name a creditor are never changed by the feature:
/// opening, verifying and rebuilding them leaves the journal
/// byte-identical, in the format it was in, this version's or an older
/// one's (which declares nothing and stays so).
#[test]
fn books_without_creditors_are_never_changed_by_the_feature() {
let dir = test_dir("untouched");
let (s, ..) = books(&dir);
let format = s.journal_format().clone();
drop(s);
let was = bytes(&dir);
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
s.rebuild().unwrap();
assert_eq!(s.journal_format(), &format);
drop(s);
assert_eq!(bytes(&dir), was);
assert!(
journal(&dir)
.iter()
.all(|(t, p)| t != FORMAT_SET || p["features"] == json!([]))
);
for (name, format) in [
("untouched", current()),
("untouched-old", JournalFormat::default()),
] {
let dir = test_dir(name);
let (s, ..) = books_in(&dir, &format);
assert_eq!(s.journal_format(), &format);
drop(s);
let was = bytes(&dir);
let mut s = Store::open(&dir).unwrap();
s.verify().unwrap();
s.rebuild().unwrap();
assert_eq!(s.journal_format(), &format, "{name}");
drop(s);
assert_eq!(bytes(&dir), was, "{name}");
let j = journal(&dir);
assert!(j.iter().all(|(t, _)| t != "party.creditor_set"), "{name}");
assert!(
j.iter().filter(|(t, _)| t == FORMAT_SET).count() <= 1,
"{name}: nothing declared but at creation"
);
}
}
/// A line that can't replay is refused on open, with the database and
@@ -224,7 +275,7 @@ fn corrupt_creditor_lines_are_refused_on_open() {
drop(w);
assert!(Store::open(&dir).is_err(), "{name}: {payload} opened");
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
let _ = std::fs::remove_file(dir.join(format!("{}{suffix}", db_file())));
}
assert!(
Store::open(&dir).is_err(),