P3 review: P2 can read the books until the owner consents
check / check (push) Canceled after 0s

A schedule-convention setting, the two new tax categories, a disposal
before the in-service date and a depreciation catch-up each need their
feature now (tax_categories_gain_exempt, disposal_before_service,
depreciation_catch_up), so no write before consent leaves books P2 refuses
or misreads; replayed names are read with P2's TIN detector (B-28). The
cutover runs end to end through the real P2 and P3 binaries in a test.

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 17:43:45 -06:00
1 parent 6288ead747
commit 2c5b5a7171
78 files changed
+1375 -4

No files matched your search

+133
View File
@@ -56,6 +56,9 @@ pub const KNOWN_FEATURES: &[&str] = &[
MONTH_REPOSTING,
SCHEDULE_CONVENTION,
ASSET_BASIS,
TAX_CATEGORIES_GAIN_EXEMPT,
DISPOSAL_BEFORE_SERVICE,
DEPRECIATION_CATCH_UP,
];
/// Feature: `period.closed` closes to members with `"rounding":
@@ -75,13 +78,46 @@ pub const FEATURE_REQUIRE_TAX_CATEGORY: &str = "require_tax_category";
/// The feature a `settings.set` naming `key` needs the books to have
/// declared: a key an earlier version doesn't know.
///
/// Every key P3 added is one (P3 review, 2026-10-21: `books.schedule_convention`
/// wasn't, so setting it left books P2 refuses to open, before consent).
pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
match key {
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
"books.schedule_convention" => Some(SCHEDULE_CONVENTION),
_ => None,
}
}
/// Feature: an `account.tax_set` naming a tax category P3 added
/// (`form_4797_gain_loss`, `tax_exempt_income`). An earlier version
/// refuses the category, so it can't open the books (P3 review,
/// 2026-10-21).
pub const TAX_CATEGORIES_GAIN_EXEMPT: &str = "tax_categories_gain_exempt";
/// The feature an `account.tax_set` naming `category` needs the books to
/// have declared: a category an earlier version doesn't know.
pub fn tax_category_feature(category: &str) -> Option<&'static str> {
match category {
"form_4797_gain_loss" | "tax_exempt_income" => Some(TAX_CATEGORIES_GAIN_EXEMPT),
_ => None,
}
}
/// Feature: an `asset.disposed` dated before the asset's in-service date
/// (on or after the day it was acquired, P3 defect 13). An earlier version
/// refuses a disposal before its `purchase_date`, so it can't open the
/// books (P3 review, 2026-10-21).
pub const DISPOSAL_BEFORE_SERVICE: &str = "disposal_before_service";
/// Feature: an `asset.depreciation_posted` whose amount is not its month's
/// own depreciation: earlier months skipped or reversed caught up in it, or
/// a final month left over past the life (P3's run rule, B-15). An earlier
/// version reads every run as its month's own: it would show the caught-up
/// months as not yet run, and running them post their depreciation twice
/// (P3 review, 2026-10-21).
pub const DEPRECIATION_CATCH_UP: &str = "depreciation_catch_up";
/// The feature `party.creditor_set` lines need: a party named the
/// creditor of a liability (rules.md B-35, P3 defect 24).
pub const PARTY_CREDITORS: &str = "party_creditors";
@@ -883,4 +919,101 @@ mod tests {
other => panic!("expected NewerJournal, got {other:?}"),
}
}
/// The setting keys and tax categories P2 (9c99a0cf, the last version
/// before P3) read: a `settings.set` or `account.tax_set` naming any
/// other makes P2 refuse the books, so each other needs a feature
/// (P3 review, 2026-10-21: `books.schedule_convention` and two tax
/// categories didn't have one).
const P2_SETTING_KEYS: &[&str] = &[
"company.name",
"company.entity_type",
"company.tax_classification",
"company.tax_form",
"company.fiscal_year_start",
"company.timezone",
"company.formation_state",
"company.formed",
"company.state_entity_number",
"company.ein",
"company.irs_name_control",
"company.duns",
"company.address",
"company.registered_agent",
"company.partnership_representative",
"company.cpa",
"company.members",
"books.accounting_basis",
"books.cash_accounts",
"books.financing_accounts",
"books.investing_accounts",
"books.close_into",
"books.close_sweep",
"books.asset_tag_prefix",
"policy.evidence.threshold",
"policy.evidence.required_accounts",
"policy.evidence.exempt_accounts",
"policy.parties",
"policy.approval_rules",
"backup.git_remote",
"backup.folder",
];
const P2_TAX_CATEGORIES: &[&str] = &[
"gross_receipts",
"returns_allowances",
"interest_income",
"other_income",
"cost_of_goods_sold",
"advertising",
"bad_debts",
"car_truck",
"charitable_contributions",
"commissions_fees",
"contract_labor",
"depreciation",
"employee_benefits",
"guaranteed_payments",
"insurance",
"interest_expense",
"legal_professional",
"meals",
"office_expense",
"officer_compensation",
"organization_startup",
"pension_profit_sharing",
"rent_equipment",
"rent_property",
"repairs",
"salaries_wages",
"supplies",
"taxes_licenses",
"travel",
"utilities",
"other_deductions",
"nondeductible",
];
#[test]
fn every_setting_key_and_tax_category_p2_didnt_read_needs_a_feature() {
for k in ledger_core::settings::KEYS {
let known = P2_SETTING_KEYS.contains(&k.name);
assert_eq!(
setting_feature(k.name).is_none(),
known,
"{}: P2 {} it",
k.name,
if known { "reads" } else { "refuses" }
);
}
for c in ledger_core::tax::CATEGORIES {
let known = P2_TAX_CATEGORIES.contains(&c.key);
assert_eq!(tax_category_feature(c.key).is_none(), known, "{}", c.key);
}
for f in [
setting_feature("books.schedule_convention"),
tax_category_feature("tax_exempt_income"),
] {
assert!(KNOWN_FEATURES.contains(&f.unwrap()));
}
}
}
+34
View File
@@ -1137,6 +1137,11 @@ impl Store {
})?;
self.ledger
.check_tax_category_required(code, name, account_type, placeholder, category)?;
// A category an earlier version doesn't know needs its feature:
// asked before the account's first line, never between the two.
if let Some(f) = category.and_then(crate::format::tax_category_feature) {
self.ensure_feature(f)?;
}
let id =
self.journal_account_added(code, name, account_type, parent, placeholder, |l| {
l.add_account(code, name, account_type, parent, placeholder)
@@ -2045,6 +2050,11 @@ impl Store {
if self.depreciation_period_was_run(asset_id, period) {
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
}
// More or less than the month's own (another month caught up in
// it): a run an earlier version would read as the month's own.
if amount != due.scheduled {
self.ensure_feature(crate::format::DEPRECIATION_CATCH_UP)?;
}
let entry_id = self.create_entry(period_end, &description)?;
self.add_line(entry_id, asset.expense_account, Side::Debit, money)?;
self.add_line(entry_id, asset.accum_depr_account, Side::Credit, money)?;
@@ -2517,6 +2527,13 @@ impl Store {
if depreciation.is_some() && self.depreciation_period_was_run(asset_id, &preview.period) {
self.ensure_feature(crate::format::MONTH_REPOSTING)?;
}
// Disposed before it was placed in service (acquired, not yet in
// use): an earlier version refuses that line (its floor was the
// in-service date), so it needs its feature, asked here, before
// the first line.
if date < asset.in_service_date {
self.ensure_feature(crate::format::DISPOSAL_BEFORE_SERVICE)?;
}
if let Some((description, lines)) = &depreciation {
let eid = self.create_entry(date, description)?;
for (account, side, amount) in lines {
@@ -3806,6 +3823,9 @@ impl Store {
) -> Result<(), StoreError> {
self.ledger
.check_account_tax(account, category, deductible_percent)?;
if let Some(f) = category.and_then(crate::format::tax_category_feature) {
self.ensure_feature(f)?;
}
let line = self.append_journal(
"account.tax_set",
json!({
@@ -5484,10 +5504,24 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
/// Replay one journal line into a ledger, asserting recorded ids. Used by
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
/// is the actor.
///
/// A full TIN is found as the version before P3 found one
/// (`ledger_core::parties::replaying`): a name, reason or fact an earlier
/// version took replays, however the commands' detector has widened since
/// (rules.md B-28).
pub(crate) fn replay_line_into_ledger(
ledger: &mut Ledger,
line: &JournalLine,
base_currency: Currency,
) -> Result<(), StoreError> {
ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency))
}
/// [`replay_line_into_ledger`]'s work.
fn replay_line(
ledger: &mut Ledger,
line: &JournalLine,
base_currency: Currency,
) -> Result<(), StoreError> {
let p = &line.payload;
match line.line_type.as_str() {
+24
View File
@@ -1380,3 +1380,27 @@ fn a_write_waiting_for_the_upgrade_journals_nothing() {
assert_eq!(*store.journal_format(), ledger_store::format::current());
store.verify().unwrap();
}
/// The replay fuzz target (`fuzz_store_replay`: the writer and the viewer
/// agree on every journal) draws its lines from every event type this
/// version reads, P3's included (`asset.basis_set`, `party.creditor_set`
/// were missing: the fuzzer never wrote them, so it never checked the two
/// readers agree on them).
#[test]
fn the_replay_fuzzer_writes_every_event_type() {
let target = include_str!("../../../fuzz/fuzz_targets/fuzz_store_replay.rs");
let list = target
.split("const TYPES: &[&str] = &[")
.nth(1)
.and_then(|rest| rest.split("];").next())
.expect("the fuzz target's TYPES");
let missing: Vec<&str> = EVENT_TYPES
.iter()
.copied()
.filter(|t| *t != "store.created" && !list.contains(&format!("\"{t}\"")))
.collect();
assert!(
missing.is_empty(),
"the replay fuzzer never writes {missing:?}"
);
}
+352
View File
@@ -0,0 +1,352 @@
//! Books P2 wrote, served by this version before the owner's go-ahead
//! (plan/served.md 3.9, D-39; rules.md B-27, B-28): nothing is written
//! that P2 can't read, or would read with another meaning, and every line
//! P2 wrote opens.
//!
//! Oracle: P2 itself (9c99a0cf, the revision before P3), run from end to
//! end on P2-shaped books in the P3 data-safety review (2026-10-21): each
//! write below, made by this version on books in format 2 with no
//! features, left books P2 refused to open (`books.schedule_convention`,
//! the tax categories P3 added, a disposal before the in-service date) or
//! read wrong (a depreciation run carrying another month's depreciation,
//! which P2 reads as the month's own, so its months caught up show as not
//! yet run, and running them in P2 posts them twice). And a name P2 took
//! held what P3's wider TIN detector reads as a full TIN, so P3 refused to
//! open the books.
//!
//! ```text
//! INVARIANTS:
//! 1. On books in format 2 with no features, each such write is refused
//! (FormatUpgradePending), journaling nothing; after the upgrade it is
//! made.
//! 2. The same command shaped as P2 could write it is made before the
//! upgrade (a category P2 knew, months run in order, a disposal on or
//! after the in-service date).
//! 3. A line P2 accepted replays, in the writer and the viewer, however
//! the commands' checks have widened since (B-28).
//! ```
use chrono::NaiveDate;
use ledger_core::settings::{Change, Source};
use ledger_core::{AccountType, Currency, Money, NewAsset, Side};
use ledger_store::{JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::{Path, PathBuf};
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!(
"wanderbooks-p2-compat-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(s.parse::<Decimal>().unwrap(), Currency::USD)
}
fn journal(dir: &Path) -> Vec<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
/// Books as P2 left them: format 2 (declared at their first setting), no
/// features, a chart.
fn p2_books(dir: &Path) -> Store {
let mut s = Store::create_in_format(
dir,
Currency::USD,
&JournalFormat {
format: 2,
features: Vec::new(),
},
)
.unwrap();
for (code, name, t) in [
("1000", "Bank", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("1510", "Accumulated depreciation", AccountType::Asset),
("3000", "Capital", AccountType::Equity),
("4000", "Revenue", AccountType::Revenue),
("6200", "Depreciation", AccountType::Expense),
] {
s.add_account(code, name, t, None, false).unwrap();
}
s
}
fn account(s: &Store, code: &str) -> u64 {
s.ledger().chart().find_by_code(code).unwrap().id
}
/// INVARIANT 1's refusal: waiting for the upgrade, nothing journaled.
fn assert_waits<T: std::fmt::Debug>(dir: &Path, before: &[u8], got: Result<T, StoreError>) {
match got {
Err(StoreError::FormatUpgradePending { .. }) => {}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(journal(dir), before, "a refused write journaled nothing");
}
fn source() -> Source {
Source {
decided_by: "Avery Quill".into(),
decided_on: d(2026, 10, 21),
citation: None,
note: None,
}
}
#[test]
fn the_schedule_convention_setting_waits_for_the_upgrade() {
let dir = test_dir("setting");
let mut s = p2_books(&dir);
let change = vec![Change {
key: "books.schedule_convention".into(),
value: Some(json!("monthly_even")),
}];
let before = journal(&dir);
assert_waits(&dir, &before, s.change_settings(&change, &source()));
s.upgrade_format().unwrap();
s.change_settings(&change, &source()).unwrap();
}
#[test]
fn a_tax_category_p3_added_waits_for_the_upgrade() {
let dir = test_dir("tax");
let mut s = p2_books(&dir);
let before = journal(&dir);
for cat in ["form_4797_gain_loss", "tax_exempt_income"] {
assert_waits(
&dir,
&before,
s.add_account_with_tax(
"4900",
"Gain on sale",
AccountType::Revenue,
None,
false,
Some((cat, None)),
),
);
let revenue = account(&s, "4000");
assert_waits(&dir, &before, s.set_account_tax(revenue, Some(cat), None));
}
// INVARIANT 2: a category P2 knew.
s.add_account_with_tax(
"4100",
"Consulting",
AccountType::Revenue,
None,
false,
Some(("gross_receipts", None)),
)
.unwrap();
s.upgrade_format().unwrap();
s.add_account_with_tax(
"4900",
"Gain on sale",
AccountType::Revenue,
None,
false,
Some(("form_4797_gain_loss", None)),
)
.unwrap();
}
/// An asset bought on 2026-01-10 and placed in service on 2026-03-01.
fn camera(s: &mut Store) -> u64 {
let (bank, equipment) = (account(s, "1000"), account(s, "1500"));
let e = s.create_entry(d(2026, 1, 10), "Camera").unwrap();
s.add_line(e, equipment, Side::Debit, usd("1200.00"))
.unwrap();
s.add_line(e, bank, Side::Credit, usd("1200.00")).unwrap();
s.post_entry(e).unwrap();
let id = s
.register_asset(NewAsset {
description: "Camera".into(),
in_service_date: d(2026, 3, 1),
cost: usd("1200.00"),
salvage_value: usd("0"),
useful_life_months: 24,
cost_account: equipment,
accum_depr_account: account(s, "1510"),
expense_account: account(s, "6200"),
})
.unwrap();
s.set_asset_acquisition(id, e).unwrap();
id
}
#[test]
fn a_disposal_before_the_in_service_date_waits_for_the_upgrade() {
let dir = test_dir("dispose");
let mut s = p2_books(&dir);
let id = camera(&mut s);
let (bank, revenue) = (account(&s, "1000"), account(&s, "4000"));
let before = journal(&dir);
assert_waits(
&dir,
&before,
s.dispose_asset(id, d(2026, 2, 1), usd("1000.00"), bank, revenue),
);
s.upgrade_format().unwrap();
s.dispose_asset(id, d(2026, 2, 1), usd("1000.00"), bank, revenue)
.unwrap();
}
#[test]
fn a_disposal_on_or_after_the_in_service_date_is_made_before_the_upgrade() {
let dir = test_dir("dispose-late");
let mut s = p2_books(&dir);
let id = camera(&mut s);
let (bank, revenue) = (account(&s, "1000"), account(&s, "4000"));
s.dispose_asset(id, d(2026, 3, 1), usd("1000.00"), bank, revenue)
.unwrap();
}
/// A printer in service from 2026-01-01, 1,200.00 over 12 months.
fn printer(s: &mut Store) -> u64 {
s.register_asset(NewAsset {
description: "Printer".into(),
in_service_date: d(2026, 1, 1),
cost: usd("1200.00"),
salvage_value: usd("0"),
useful_life_months: 12,
cost_account: account(s, "1500"),
accum_depr_account: account(s, "1510"),
expense_account: account(s, "6200"),
})
.unwrap()
}
#[test]
fn a_depreciation_run_that_catches_up_another_month_waits_for_the_upgrade() {
let dir = test_dir("catch-up");
let mut s = p2_books(&dir);
let id = printer(&mut s);
let jan = s.run_depreciation(id, "2026-01").unwrap();
assert_eq!(jan.amount, jan.scheduled, "its own month: made");
// February skipped: March's run would carry it.
let before = journal(&dir);
assert_waits(&dir, &before, s.run_depreciation(id, "2026-03"));
// INVARIANT 2: run in order, each its own month, as P2 ran them.
for m in ["2026-02", "2026-03"] {
let r = s.run_depreciation(id, m).unwrap();
assert_eq!(r.amount, r.scheduled, "{m}");
}
// After the upgrade a skipped month is caught up.
s.upgrade_format().unwrap();
let may = s.run_depreciation(id, "2026-05").unwrap();
assert!(may.amount > may.scheduled, "{may:?}");
}
#[test]
fn a_reversed_run_caught_up_later_waits_for_the_upgrade() {
let dir = test_dir("reversed");
let mut s = p2_books(&dir);
let id = printer(&mut s);
let jan = s.run_depreciation(id, "2026-01").unwrap();
s.run_depreciation(id, "2026-02").unwrap();
s.reverse_entry(jan.entry_id.unwrap(), d(2026, 2, 28), "wrong")
.unwrap();
let before = journal(&dir);
assert_waits(&dir, &before, s.run_depreciation(id, "2026-03"));
}
/// INVARIANT 3: P2's detector found a full TIN only across ASCII
/// separators, the no-break space and a few dashes; P3's finds one across
/// every Unicode space and dash and invisible characters. A name P2 took
/// replays; the command refuses it now.
#[test]
fn a_name_p2_took_replays_though_the_command_now_refuses_it() {
let dir = test_dir("tin-replay");
drop(p2_books(&dir));
let names = [
"Studio 400\u{ff0d}12\u{ff0d}3456",
"Studio 400\u{200b}12\u{200b}3456",
"Studio 400\u{2009}12\u{2009}3456",
];
{
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (i, name) in names.iter().enumerate() {
w.append("vendor.added", json!({"id": i as u64 + 1, "name": name}))
.unwrap();
}
w.append(
"party.added",
json!({"id": 4, "name": "Kai 400\u{301c}12\u{301c}3456", "roles": ["member"]}),
)
.unwrap();
}
let mut s = Store::open(&dir).expect("P2's books open");
s.verify().unwrap();
assert_eq!(s.ledger().list_vendors().len(), 3);
Snapshot::load(&dir).expect("and the viewer reads them");
for name in names {
assert!(s.add_vendor(name).is_err(), "the command refuses {name:?}");
}
// Outside replay the detector is P3's again.
assert!(ledger_core::parties::has_full_tin(names[0]));
}
/// After the upgrade, each write above is made, and the books replay to
/// the live ledger exactly, in the writer (reopened) and the viewer.
#[test]
fn after_the_upgrade_the_writes_replay_to_the_live_books() {
let dir = test_dir("after");
let mut s = p2_books(&dir);
let camera = camera(&mut s);
let printer = printer(&mut s);
s.run_depreciation(printer, "2026-01").unwrap();
assert!(s.upgrade_format().unwrap().is_some());
let (bank, revenue) = (account(&s, "1000"), account(&s, "4000"));
s.change_settings(
&[Change {
key: "books.schedule_convention".into(),
value: Some(json!("daily")),
}],
&source(),
)
.unwrap();
s.add_account_with_tax(
"4910",
"Exempt interest",
AccountType::Revenue,
None,
false,
Some(("tax_exempt_income", None)),
)
.unwrap();
s.run_depreciation(printer, "2026-03").unwrap();
s.dispose_asset(camera, d(2026, 2, 1), usd("1000.00"), bank, revenue)
.unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir).unwrap();
assert!(live.differs_from(s.ledger()).is_empty());
let snap = Snapshot::load(&dir).unwrap();
assert!(live.differs_from(snap.ledger()).is_empty());
}