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:
62 files changed
+5309
-628
No files matched your search
@@ -1945,6 +1945,30 @@ fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
|
||||
preview["lines"],
|
||||
json!(["Debit 4000 0.02", "Credit 3001 0.02"])
|
||||
);
|
||||
// Books an older version wrote: the close counted from its regime's
|
||||
// start waits for the owner's go-ahead (D-39), which no tool gives.
|
||||
let refused = mcp.call_err(
|
||||
"books.close_period",
|
||||
json!({"period_id": dec["period_id"], "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
refused.starts_with("format_upgrade_pending:") && refused.contains("members_cumulative"),
|
||||
"{refused}"
|
||||
);
|
||||
// The owner's go-ahead, as the app gives it (ledger-gui owner.rs, after
|
||||
// the passphrase and a verified backup): the books move to this
|
||||
// version's format.
|
||||
drop(mcp);
|
||||
ledger_store::Store::open(&books)
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("old books wait for the upgrade");
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&books,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
init(&mut mcp);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
json!({"period_id": dec["period_id"], "confirmed": true}),
|
||||
@@ -3124,11 +3148,9 @@ fn rename_account_end_to_end_defect_15() {
|
||||
);
|
||||
let after = mcp.call("books.verify", json!({"blobs": false}));
|
||||
assert_eq!(after["status"], "ok");
|
||||
assert_eq!(
|
||||
after["events"],
|
||||
json!(events + 2),
|
||||
"the feature and the rename"
|
||||
);
|
||||
// New books declare account_renamed (with the rest of this version's
|
||||
// format) as they are created: the rename journals only itself.
|
||||
assert_eq!(after["events"], json!(events + 1), "the rename");
|
||||
let chart = mcp.call("books.chart", json!({}));
|
||||
let acct = chart["accounts"]
|
||||
.as_array()
|
||||
|
||||
@@ -356,7 +356,25 @@ const CHART: &[(&str, &str, &str)] = &[
|
||||
|
||||
/// Books with the chart; cash is 1000, financing 2100.
|
||||
fn books(name: &str) -> Mcp {
|
||||
books_in(name, false)
|
||||
}
|
||||
|
||||
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
|
||||
/// P3's features, books an older version wrote, waiting for their format
|
||||
/// upgrade (rules.md B-27).
|
||||
fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
features: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let toml = books_toml(&dir, "Spec Co", "financing_accounts = [\"2100\"]\n");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
@@ -365,6 +383,23 @@ fn books(name: &str) -> Mcp {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
|
||||
/// after the passphrase and a verified backup; no tool can): the
|
||||
/// server stopped, the books moved to this version's format, and
|
||||
/// served again.
|
||||
fn upgraded(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
ledger_store::Store::open(&dir.join("books-data"))
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("P2's books wait for the upgrade");
|
||||
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
|
||||
/// The same books, served again (a restart).
|
||||
fn restart(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
@@ -553,7 +588,11 @@ fn set_vendor_1099_says_what_a_decision_needs() {
|
||||
assert!(why.contains(needle), "{args}: {why}");
|
||||
}
|
||||
assert_eq!(m.count("vendor.1099_set"), 0);
|
||||
assert_eq!(m.count("journal.format_set"), 0, "nothing declared either");
|
||||
assert_eq!(
|
||||
m.count("journal.format_set"),
|
||||
1,
|
||||
"nothing declared either: only the books' own format, from their creation"
|
||||
);
|
||||
}
|
||||
|
||||
/// P3 brief 1.5 and review R8: tin_on_file omitted keeps the W-9 flag;
|
||||
@@ -646,30 +685,37 @@ fn a_members_method_off_the_financing_accounts_warns() {
|
||||
assert!(out["warnings"].as_array().unwrap().is_empty(), "{out}");
|
||||
}
|
||||
|
||||
/// B-27 and the format-upgrade gate (D-39): books.set_w9 on books that
|
||||
/// never used the vendor_1099_decisions feature journals the W-9 and then
|
||||
/// the vendor's W-9 flag, a line of that feature, so the feature is
|
||||
/// declared before the command's first line, never between its lines
|
||||
/// (where a refused upgrade would leave the W-9 recorded and the flag
|
||||
/// not).
|
||||
/// B-27 and the format-upgrade gate (D-39): books.set_w9 journals the W-9
|
||||
/// and then the vendor's W-9 flag, a line of the vendor_1099_decisions
|
||||
/// feature, so it asks for the feature before the command's first line,
|
||||
/// never between its lines. In books waiting for their format upgrade
|
||||
/// (P2's) the whole command is refused, journaling nothing (never the W-9
|
||||
/// recorded and the flag not); once the owner upgrades them, as in books
|
||||
/// this version made, it journals the W-9, then the flag, and declares
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn set_w9_declares_the_w9_flags_feature_before_its_first_line() {
|
||||
let mut m = books("set-w9-feature");
|
||||
fn set_w9_asks_for_the_w9_flags_feature_before_its_first_line() {
|
||||
let w9 = json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"});
|
||||
let mut m = books_in("set-w9-feature", true);
|
||||
m.add_party("Harbor Design LLC", &["contractor"]);
|
||||
m.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
let before = m.journal_types().len();
|
||||
m.ok(
|
||||
"books.set_w9",
|
||||
json!({"party": "Harbor Design LLC", "received_on": "2026-03-02",
|
||||
"entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
let why = m.refuse("books.set_w9", w9.clone());
|
||||
assert!(
|
||||
why.starts_with("format_upgrade_pending:") && why.contains("vendor_1099_decisions"),
|
||||
"{why}"
|
||||
);
|
||||
let added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(
|
||||
added,
|
||||
["journal.format_set", "party.w9_set", "vendor.w9_flag_set"],
|
||||
"the declaration comes first"
|
||||
);
|
||||
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
|
||||
assert_eq!(m.count("party.w9_set"), 0, "nothing half done");
|
||||
let mut fresh = books("set-w9-feature-new");
|
||||
fresh.add_party("Harbor Design LLC", &["contractor"]);
|
||||
fresh.ok("books.add_vendor", json!({"name": "Harbor Design LLC"}));
|
||||
for mut m in [m.upgraded(), fresh] {
|
||||
let before = m.journal_types().len();
|
||||
m.ok("books.set_w9", w9.clone());
|
||||
let added = m.journal_types()[before..].to_vec();
|
||||
assert_eq!(added, ["party.w9_set", "vendor.w9_flag_set"]);
|
||||
assert_eq!(decision(&mut m, "Harbor Design LLC")["tin_on_file"], true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Defect 12 end to end, restarting between steps (P3 brief 8.2): payment
|
||||
|
||||
@@ -340,7 +340,7 @@ impl Mcp {
|
||||
walk(&p, out);
|
||||
} else if name.ends_with("-wal") || name.ends_with("-shm") {
|
||||
continue;
|
||||
} else if name == ledger_store::DB_FILE {
|
||||
} else if ledger_store::is_database_file(&name) {
|
||||
out.insert(p.clone(), db_digest(&p));
|
||||
} else {
|
||||
let bytes = std::fs::read(&p).unwrap_or_default();
|
||||
|
||||
@@ -361,7 +361,25 @@ const CHART: &[(&str, &str, &str)] = &[
|
||||
/// financing 2100; the chart; both members and the card's issuer in the
|
||||
/// directory.
|
||||
fn books(name: &str) -> Mcp {
|
||||
books_in(name, false)
|
||||
}
|
||||
|
||||
/// [`books`], as P2 left them when `p2`: journal format 2 with none of
|
||||
/// P3's features, books an older version wrote, waiting for their format
|
||||
/// upgrade (rules.md B-27).
|
||||
fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
features: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let toml = books_toml(
|
||||
&dir,
|
||||
"Fernhill Labs LLC",
|
||||
@@ -378,6 +396,25 @@ fn books(name: &str) -> Mcp {
|
||||
m
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The owner's go-ahead, as the app gives it (ledger-gui owner.rs,
|
||||
/// after the passphrase and a verified backup; no tool can): the
|
||||
/// server stopped, the books moved to this version's format, and
|
||||
/// served again.
|
||||
fn upgraded(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
ledger_store::Store::open(&dir.join("books-data"))
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("P2's books wait for the upgrade");
|
||||
let mut m = Mcp::start(&dir, &dir.join("wanderbooks.toml"));
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// A party's part of an entry as books.get_entry gives it: (resolved,
|
||||
/// direction, cash, in_kind).
|
||||
fn part(m: &mut Mcp, entry: u64, party: &str) -> (Value, Value, Value, Value) {
|
||||
@@ -560,7 +597,8 @@ fn defect14_party_totals_take_a_basis() {
|
||||
|
||||
/// Defect 24 over the MCP: `books.update_party` `creditor_of` names the
|
||||
/// liabilities the company owes a party itself on (journaled as
|
||||
/// party.creditor_set, the journal's feature declared first), replaces
|
||||
/// party.creditor_set, under the journal feature the books declared when
|
||||
/// they were created, so nothing is declared on the way), replaces
|
||||
/// the list, refuses what isn't a liability with nothing journaled; and a
|
||||
/// fee on the issuer's own card then reads neither, so the issuer is paid
|
||||
/// what the card payment paid, once.
|
||||
@@ -595,8 +633,8 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
assert_eq!(m.count("party.creditor_set"), 1);
|
||||
assert_eq!(
|
||||
m.count("journal.format_set"),
|
||||
before + 1,
|
||||
"the feature declared once"
|
||||
before,
|
||||
"nothing declared on the way"
|
||||
);
|
||||
// The same list again changes nothing.
|
||||
let journal = m.journal();
|
||||
@@ -633,31 +671,40 @@ fn defect24_creditor_of_round_trips_and_a_card_fee_is_not_a_payment() {
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!([]));
|
||||
assert_eq!(m.count("party.creditor_set"), 4);
|
||||
assert_eq!(m.count("journal.format_set"), before + 1);
|
||||
assert_eq!(m.count("journal.format_set"), before);
|
||||
let p = m.read_only("books.party", json!({"party": "Tobin Harlow"}));
|
||||
assert_eq!(p["party"]["creditor_of"], json!(["2100"]));
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// rules.md B-27: `books.update_party` declares the `party_creditors`
|
||||
/// feature before the first line it writes, never between them: a name
|
||||
/// change with the first creditor is journaled as the declaration, then
|
||||
/// the name, then the creditor (a refused format upgrade then leaves
|
||||
/// nothing half done).
|
||||
/// rules.md B-27: `books.update_party` asks for the `party_creditors`
|
||||
/// feature before the first line it writes, never between them. In books
|
||||
/// waiting for their format upgrade (P2's), a name change with the first
|
||||
/// creditor is refused whole, journaling nothing (never the notes changed
|
||||
/// and the creditor not); once the owner upgrades them, as in books this
|
||||
/// version made, it journals the change, then the creditor, and declares
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn update_party_declares_the_creditor_feature_before_its_first_line() {
|
||||
let mut m = books("feature-first");
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok(
|
||||
"books.update_party",
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]}),
|
||||
fn update_party_asks_for_the_creditor_feature_before_its_first_line() {
|
||||
let change =
|
||||
json!({"party": "Harbor Bank", "notes": "the card's issuer", "creditor_of": ["2050"]});
|
||||
let mut m = books_in("feature-first", true);
|
||||
let why = m.refuse("books.update_party", change.clone());
|
||||
assert!(
|
||||
why.starts_with("format_upgrade_pending:") && why.contains("party_creditors"),
|
||||
"{why}"
|
||||
);
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["journal.format_set", "party.updated", "party.creditor_set"],
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
assert_eq!(m.count("party.updated"), 0, "nothing half done");
|
||||
for mut m in [m.upgraded(), books("feature-first-new")] {
|
||||
let start = m.journal_types().len();
|
||||
let p = m.ok("books.update_party", change.clone());
|
||||
assert_eq!(p["creditor_of"], json!(["2050"]));
|
||||
assert_eq!(
|
||||
m.journal_types()[start..].to_vec(),
|
||||
["party.updated", "party.creditor_set"],
|
||||
);
|
||||
assert_eq!(m.ok("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
}
|
||||
|
||||
/// What `books.post_entry` previews is what it posts: each party's
|
||||
|
||||
@@ -1378,8 +1378,8 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
);
|
||||
seen.push(f.file_name().unwrap().to_string_lossy().into_owned());
|
||||
}
|
||||
for must in ["journal.jsonl", "wanderbooks.db"] {
|
||||
assert!(seen.iter().any(|s| s == must), "{must} scanned: {seen:?}");
|
||||
for must in ["journal.jsonl".to_string(), ledger_store::db_file()] {
|
||||
assert!(seen.contains(&must), "{must} scanned: {seen:?}");
|
||||
}
|
||||
// The vault holds them encrypted only.
|
||||
let vault_files = files_under(&m.vault());
|
||||
|
||||
Reference in new issue
Block a user