fix(core,store): child accounts share their parent's type; periods nest; lines checked when added

Three spec decisions from the owner (2026-10-13), each test-first:

- A child account has its parent's type (ParentTypeMismatch): a child
  subdivides its parent. Test: a_child_account_has_its_parents_type.
- Periods nest (a month in a quarter in a year) but never partially
  overlap (PeriodsOverlap; a refusal uses no id). Test:
  periods_nest_but_never_partially_overlap.
- A line's account must exist and take postings when the line is added,
  not only at post (UnknownAccount / PlaceholderAccount from add_line
  and add_line_fx); posting still re-checks. Two older tests that pinned
  the post-time refusal now check both (the post-time check is reached
  through the unchecked replay path, as old journals can).

Compatibility: replay reads history through new Ledger::replay_*
commands (add_account, add_line, add_line_fx, create_period) that skip
checks added later, so books written before these rules still open.
The contract test books_written_under_older_rules_still_open writes such
a journal directly and found, on its first run, that the SQLite
cross-check rebuild used the checked commands and would have failed
verification ("sqlite projection can't be read back"); it now replays
too. Documented in docs/spec/README.md; decisions recorded in rules.md
(B-1, B-4, B-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 14:16:51 -06:00
1 parent 4eaac20311
commit 4647611f96
7 files changed
+362 -12

No files matched your search

+36
View File
@@ -123,6 +123,42 @@ pub enum LedgerError {
spec: String, spec: String,
}, },
/// A child account subdivides its parent, so it must be the same type
/// (owner, 2026-10-13).
#[error(
"account {code} is {found:?}, but its parent {parent_code} is {expected:?}; a child account must be the same type as its parent"
)]
ParentTypeMismatch {
/// The new account's code.
code: String,
/// The parent's code.
parent_code: String,
/// The parent's type, which the child must share.
expected: crate::AccountType,
/// The type the child was given.
found: crate::AccountType,
},
/// Periods nest (a month in a quarter in a year) but never partially
/// overlap (owner, 2026-10-13).
#[error(
"period {name} ({start} to {end}) partially overlaps period {other} ({other_start} to {other_end}); periods may nest, but not overlap"
)]
PeriodsOverlap {
/// The new period's name.
name: String,
/// Its first day.
start: chrono::NaiveDate,
/// Its last day.
end: chrono::NaiveDate,
/// The existing period's name.
other: String,
/// The existing period's first day.
other_start: chrono::NaiveDate,
/// The existing period's last day.
other_end: chrono::NaiveDate,
},
/// The referenced journal entry does not exist. /// The referenced journal entry does not exist.
#[error("entry {0} does not exist")] #[error("entry {0} does not exist")]
UnknownEntry(u64), UnknownEntry(u64),
+132
View File
@@ -492,6 +492,22 @@ impl Ledger {
} }
/// Add an account to the chart. Returns its id. /// Add an account to the chart. Returns its id.
///
/// ```text
/// GOAL: register an account under a unique code.
/// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (owner,
/// 2026-10-13): a child has its parent's type.
/// SCENARIOS:
/// 1. IF the code is empty or taken: SHOULD refuse.
/// 2. IF the parent doesn't exist: SHOULD refuse.
/// 3. IF the parent is another type:
/// - SHOULD refuse with ParentTypeMismatch
/// - NEVER add the account
/// BECAUSE a child subdivides its parent.
/// ```
///
/// Replay of journals written before the parent rule uses
/// [`Self::replay_add_account`].
pub fn add_account( pub fn add_account(
&mut self, &mut self,
code: &str, code: &str,
@@ -499,6 +515,30 @@ impl Ledger {
account_type: AccountType, account_type: AccountType,
parent: Option<AccountId>, parent: Option<AccountId>,
placeholder: bool, placeholder: bool,
) -> Result<AccountId, LedgerError> {
if let Some(p) = parent.and_then(|p| self.chart.get(p))
&& p.account_type != account_type
{
return Err(LedgerError::ParentTypeMismatch {
code: code.to_string(),
parent_code: p.code.clone(),
expected: p.account_type,
found: account_type,
});
}
self.replay_add_account(code, name, account_type, parent, placeholder)
}
/// [`Self::add_account`] without the rules added on 2026-10-13 (a
/// child's type), for replaying journals written before them. Never
/// for new work.
pub fn replay_add_account(
&mut self,
code: &str,
name: &str,
account_type: AccountType,
parent: Option<AccountId>,
placeholder: bool,
) -> Result<AccountId, LedgerError> { ) -> Result<AccountId, LedgerError> {
let id = self.next_account_id; let id = self.next_account_id;
self.next_account_id += 1; self.next_account_id += 1;
@@ -616,6 +656,32 @@ impl Ledger {
account: AccountId, account: AccountId,
side: Side, side: Side,
amount: Money, amount: Money,
) -> Result<(), LedgerError> {
self.check_line_account(account)?;
self.replay_add_line(entry, account, side, amount)
}
/// A line's account must exist and take postings, checked when the
/// line is added (owner, 2026-10-13); posting checks again.
fn check_line_account(&self, account: AccountId) -> Result<(), LedgerError> {
let a = self
.chart
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
if a.placeholder {
return Err(LedgerError::PlaceholderAccount(a.code.clone()));
}
Ok(())
}
/// [`Self::add_line`] without the account check added on 2026-10-13,
/// for replaying journals written before it. Never for new work.
pub fn replay_add_line(
&mut self,
entry: EntryId,
account: AccountId,
side: Side,
amount: Money,
) -> Result<(), LedgerError> { ) -> Result<(), LedgerError> {
if amount.currency != self.base_currency { if amount.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch { return Err(LedgerError::CurrencyMismatch {
@@ -648,6 +714,28 @@ impl Ledger {
foreign_currency: Currency, foreign_currency: Currency,
foreign_amount: Decimal, foreign_amount: Decimal,
fx_rate: Decimal, fx_rate: Decimal,
) -> Result<(), LedgerError> {
self.check_line_account(account)?;
self.replay_add_line_fx(
entry,
account,
side,
foreign_currency,
foreign_amount,
fx_rate,
)
}
/// [`Self::add_line_fx`] without the account check added on
/// 2026-10-13, for replaying journals written before it.
pub fn replay_add_line_fx(
&mut self,
entry: EntryId,
account: AccountId,
side: Side,
foreign_currency: Currency,
foreign_amount: Decimal,
fx_rate: Decimal,
) -> Result<(), LedgerError> { ) -> Result<(), LedgerError> {
if foreign_amount <= Decimal::ZERO { if foreign_amount <= Decimal::ZERO {
return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount)); return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount));
@@ -919,11 +1007,55 @@ impl Ledger {
/// Create an accounting period. The date range must be valid /// Create an accounting period. The date range must be valid
/// (end >= start). Returns the period id. /// (end >= start). Returns the period id.
///
/// ```text
/// GOAL: name a span of days that can later be closed.
/// SOURCE: rules.md B-12 (IRS Pub. 538: tax years, calendar or fiscal);
/// DECISION (owner, 2026-10-13): periods nest, never
/// partially overlap.
/// SCENARIOS:
/// 1. IF end is before start: SHOULD refuse.
/// 2. IF it lies inside, or around, every period it touches: SHOULD
/// accept (a month in a quarter in a year).
/// 3. IF it partially overlaps a period:
/// - SHOULD refuse with PeriodsOverlap
/// - NEVER create it, or use up an id
/// BECAUSE a span split across two periods could close twice or
/// never.
/// ```
pub fn create_period( pub fn create_period(
&mut self, &mut self,
name: &str, name: &str,
start: NaiveDate, start: NaiveDate,
end: NaiveDate, end: NaiveDate,
) -> Result<PeriodId, LedgerError> {
periods::validate_period_range(start, end)?;
let nested = |a: (NaiveDate, NaiveDate), b: (NaiveDate, NaiveDate)| {
(a.0 >= b.0 && a.1 <= b.1) || (b.0 >= a.0 && b.1 <= a.1)
};
for q in self.list_periods() {
let touches = start <= q.end && q.start <= end;
if touches && !nested((start, end), (q.start, q.end)) {
return Err(LedgerError::PeriodsOverlap {
name: name.to_string(),
start,
end,
other: q.name.clone(),
other_start: q.start,
other_end: q.end,
});
}
}
self.replay_create_period(name, start, end)
}
/// [`Self::create_period`] without the nesting rule added on
/// 2026-10-13, for replaying journals written before it.
pub fn replay_create_period(
&mut self,
name: &str,
start: NaiveDate,
end: NaiveDate,
) -> Result<PeriodId, LedgerError> { ) -> Result<PeriodId, LedgerError> {
periods::validate_period_range(start, end)?; periods::validate_period_range(start, end)?;
let id = self.next_period_id; let id = self.next_period_id;
+112 -2
View File
@@ -242,12 +242,18 @@ fn fx_overflow_is_an_error() {
#[test] #[test]
fn unknown_account_is_rejected() { fn unknown_account_is_rejected() {
// Refused when the line is added (since 2026-10-13); a line that got
// in anyway (replayed from an older journal) is still refused at post.
let c = fixture_company(); let c = fixture_company();
let mut ledger = c.ledger; let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Ghost account"); let id = ledger.create_entry(date(21), "Ghost account");
ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap();
assert_eq!(
ledger.add_line(id, 999_999, Side::Credit, usd(100)),
Err(LedgerError::UnknownAccount(999_999))
);
ledger ledger
.add_line(id, 999_999, Side::Credit, usd(100)) .replay_add_line(id, 999_999, Side::Credit, usd(100))
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
ledger.post_entry(id).unwrap_err(), ledger.post_entry(id).unwrap_err(),
@@ -263,7 +269,14 @@ fn placeholder_account_is_rejected() {
.add_account("100", "Assets header", AccountType::Asset, None, true) .add_account("100", "Assets header", AccountType::Asset, None, true)
.unwrap(); .unwrap();
let id = ledger.create_entry(date(21), "Post to header"); let id = ledger.create_entry(date(21), "Post to header");
ledger.add_line(id, header, Side::Debit, usd(100)).unwrap(); assert_eq!(
ledger.add_line(id, header, Side::Debit, usd(100)),
Err(LedgerError::PlaceholderAccount("100".to_string()))
);
// Got in anyway (an older journal): still refused at post.
ledger
.replay_add_line(id, header, Side::Debit, usd(100))
.unwrap();
ledger ledger
.add_line(id, c.equity, Side::Credit, usd(100)) .add_line(id, c.equity, Side::Credit, usd(100))
.unwrap(); .unwrap();
@@ -906,3 +919,100 @@ fn a_close_refuses_a_3999_that_is_not_equity() {
assert_untouched(&before, &l); assert_untouched(&before, &l);
assert!(l.closed_period_containing(date(5)).is_none()); assert!(l.closed_period_containing(date(5)).is_none());
} }
// ---------------------------------------------------------------------------
// Chart, periods, and lines: rules confirmed by owner, 2026-10-13.
// ---------------------------------------------------------------------------
/// A child account subdivides its parent, so it is the same kind of
/// account. SOURCE: rules.md B-4; DECISION (owner, 2026-10-13).
#[test]
fn a_child_account_has_its_parents_type() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, true)
.unwrap();
l.add_account("1010", "Checking", AccountType::Asset, Some(cash), false)
.unwrap();
let before = l.chart().len();
assert_eq!(
l.add_account("1020", "Rent", AccountType::Expense, Some(cash), false),
Err(LedgerError::ParentTypeMismatch {
code: "1020".into(),
parent_code: "1000".into(),
expected: AccountType::Asset,
found: AccountType::Expense,
})
);
assert_eq!(l.chart().len(), before);
assert!(l.chart().find_by_code("1020").is_none());
}
/// Periods nest (a month in a quarter in a year) but never partially
/// overlap. DECISION (owner, 2026-10-13).
#[test]
fn periods_nest_but_never_partially_overlap() {
let d = |m: u32, day: u32| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let mut l = Ledger::new(Currency::USD);
l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap();
// A quarter around both months, and a year around everything.
l.create_period("Q1 2026", d(1, 1), d(3, 31)).unwrap();
l.create_period("2026", d(1, 1), d(12, 31)).unwrap();
// A week inside January nests too.
l.create_period("week", d(1, 5), d(1, 11)).unwrap();
let next = l.next_period_id();
// Mid-January to mid-February straddles two months: refused.
assert!(matches!(
l.create_period("straddle", d(1, 15), d(2, 15)),
Err(LedgerError::PeriodsOverlap { .. })
));
// So is a quarter that starts inside Q1.
assert!(matches!(
l.create_period("odd quarter", d(2, 1), d(4, 30)),
Err(LedgerError::PeriodsOverlap { .. })
));
assert_eq!(l.next_period_id(), next, "refused means no id burned");
// Disjoint is fine.
l.create_period(
"2027-01",
NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(),
)
.unwrap();
}
/// A line names a real, postable account when it is added, not only when
/// the entry posts. DECISION (owner, 2026-10-13); rules.md B-1.
#[test]
fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() {
let c = fixture_company();
let mut l = c.ledger.clone();
let header = l
.add_account("1900", "Other assets", AccountType::Asset, None, true)
.unwrap();
let e = l.create_entry(date(21), "Lines");
assert_eq!(
l.add_line(e, 999_999, Side::Debit, usd(10)),
Err(LedgerError::UnknownAccount(999_999))
);
assert_eq!(
l.add_line(e, header, Side::Debit, usd(10)),
Err(LedgerError::PlaceholderAccount("1900".into()))
);
assert!(matches!(
l.add_line_fx(
e,
header,
Side::Debit,
Currency::EUR,
Decimal::from(10),
Decimal::from(2)
),
Err(LedgerError::PlaceholderAccount(_))
));
assert!(
l.get_entry(e).unwrap().lines().is_empty(),
"refused lines aren't added"
);
}
+11 -7
View File
@@ -2374,6 +2374,10 @@ impl Store {
} }
/// Rebuild a ledger purely from the SQLite projection (used by verify). /// Rebuild a ledger purely from the SQLite projection (used by verify).
/// Rebuild the ledger from the SQLite projection, for the cross-check.
/// Like journal replay, it reads history, so it uses the engine's
/// `replay_*` commands: rules added later never refuse books written
/// before them.
fn load_ledger_from_sqlite(&self) -> Result<Ledger, StoreError> { fn load_ledger_from_sqlite(&self) -> Result<Ledger, StoreError> {
let mut ledger = Ledger::new(self.base_currency); let mut ledger = Ledger::new(self.base_currency);
let mut stmt = self.conn.prepare( let mut stmt = self.conn.prepare(
@@ -2400,7 +2404,7 @@ impl Store {
"account {code} currency {currency} != base" "account {code} currency {currency} != base"
))); )));
} }
let got = ledger.add_account( let got = ledger.replay_add_account(
&code, &code,
&name, &name,
account_type, account_type,
@@ -2477,7 +2481,7 @@ impl Store {
})?; })?;
for (idx, lr) in lrows.enumerate() { for (idx, lr) in lrows.enumerate() {
let (account, side, amount, currency, class_id) = lr?; let (account, side, amount, currency, class_id) = lr?;
ledger.add_line( ledger.replay_add_line(
got, got,
account as u64, account as u64,
parse_side(&side)?, parse_side(&side)?,
@@ -2883,7 +2887,7 @@ pub(crate) fn replay_line_into_ledger(
let want = get_u64(p, "id")?; let want = get_u64(p, "id")?;
let account_type: AccountType = serde_json::from_value(p["account_type"].clone())?; let account_type: AccountType = serde_json::from_value(p["account_type"].clone())?;
let parent = p.get("parent").and_then(|v| v.as_u64()); let parent = p.get("parent").and_then(|v| v.as_u64());
let got = ledger.add_account( let got = ledger.replay_add_account(
get_str(p, "code")?, get_str(p, "code")?,
get_str(p, "name")?, get_str(p, "name")?,
account_type, account_type,
@@ -2928,7 +2932,7 @@ pub(crate) fn replay_line_into_ledger(
let fx_rate: Decimal = get_str(p, "fx_rate")? let fx_rate: Decimal = get_str(p, "fx_rate")?
.parse() .parse()
.map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?; .map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?;
ledger.add_line_fx( ledger.replay_add_line_fx(
id, id,
account, account,
side, side,
@@ -2940,7 +2944,7 @@ pub(crate) fn replay_line_into_ledger(
)?; )?;
} }
None => { None => {
ledger.add_line(id, account, side, Money::new(amount, currency))?; ledger.replay_add_line(id, account, side, Money::new(amount, currency))?;
} }
} }
} }
@@ -2957,7 +2961,7 @@ pub(crate) fn replay_line_into_ledger(
for l in p.get("lines").and_then(|v| v.as_array()).ok_or_else(|| { for l in p.get("lines").and_then(|v| v.as_array()).ok_or_else(|| {
StoreError::JournalCorrupt("entry.posted without lines".into()) StoreError::JournalCorrupt("entry.posted without lines".into())
})? { })? {
ledger.add_line( ledger.replay_add_line(
id, id,
get_u64(l, "account")?, get_u64(l, "account")?,
parse_side(get_str(l, "side")?)?, parse_side(get_str(l, "side")?)?,
@@ -3132,7 +3136,7 @@ pub(crate) fn replay_line_into_ledger(
} }
"period.created" => { "period.created" => {
let want = get_u64(p, "id")?; let want = get_u64(p, "id")?;
let got = ledger.create_period( let got = ledger.replay_create_period(
get_str(p, "name")?, get_str(p, "name")?,
parse_date(get_str(p, "start")?)?, parse_date(get_str(p, "start")?)?,
parse_date(get_str(p, "end")?)?, parse_date(get_str(p, "end")?)?,
+52
View File
@@ -1000,3 +1000,55 @@ mod refusals {
} }
} }
} }
/// Contract (2026-10-13): rules added to the engine never lock anyone out
/// of books written before them. A journal holding what the new rules
/// refuse (a child account of another type, partially overlapping periods,
/// a draft line on a missing account) must still open and verify; replay
/// goes through Ledger::replay_*.
#[test]
fn books_written_under_older_rules_still_open() {
use serde_json::json;
let dir = test_dir("older-rules");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, true)
.unwrap();
drop(store);
// What an older wanderbooks could have written.
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"account.added",
json!({"id": 2, "code": "1010", "name": "Rent", "account_type": "Expense",
"parent": cash, "placeholder": false, "currency": "USD"}),
)
.unwrap();
w.append(
"period.created",
json!({"id": 1, "name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
)
.unwrap();
w.append(
"period.created",
json!({"id": 2, "name": "straddle", "start": "2026-01-15", "end": "2026-02-15"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 1, "date": "2026-03-01", "description": "draft"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 1, "account": 999, "side": "debit", "amount": "10.00", "currency": "USD"}),
)
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap();
assert_eq!(store.ledger().chart().len(), 2);
assert_eq!(store.ledger().list_periods().len(), 2);
assert_eq!(store.ledger().get_entry(1).unwrap().lines().len(), 1);
}
+8
View File
@@ -31,6 +31,14 @@ SOURCE: CONVENTION (QuickBooks aging); nearest authority ASC 326-20-55-38 (illus
A rule whose only source is CONVENTION or DECISION is a candidate for a A rule whose only source is CONVENTION or DECISION is a candidate for a
human to confirm or overturn; nothing else is. human to confirm or overturn; nothing else is.
## Rules added later never lock out older books
When a rule is tightened (a check added to a command), books written
before it must still open. Journal replay and the SQLite cross-check read
history through the engine's `replay_*` commands, which skip checks added
later; new commands go through the checked ones. Pinned by
`books_written_under_older_rules_still_open` (ledger-store).
## Vendoring rules ## Vendoring rules
- Public-domain texts are copied **verbatim** (whitespace normalized), each - Public-domain texts are copied **verbatim** (whitespace normalized), each
+11 -3
View File
@@ -27,7 +27,10 @@ Spec blocks in the code cite these as `SOURCE: rules.md B-n`.
total credits after you post the journal entries to the ledger accounts." total credits after you post the journal entries to the ledger accounts."
**Enforced:** `kernel::check_lines` via `JournalEntry::validate` (two or **Enforced:** `kernel::check_lines` via `JournalEntry::validate` (two or
more lines, debits = credits exactly). **Checked:** Kani more lines, debits = credits exactly). A line's account must exist and
take postings when the line is added, not only at post: DECISION
(the owner, 2026-10-13), `Ledger::add_line`
(`a_line_on_an_unknown_or_placeholder_account_is_refused_when_added`). **Checked:** Kani
`check_lines_is_exact`, `accepted_entry_nets_to_zero`; Lean `check_lines_is_exact`, `accepted_entry_nets_to_zero`; Lean
`Accepted.net_zero`; property `prop_posted_entries_balance`. `Accepted.net_zero`; property `prop_posted_entries_balance`.
@@ -74,7 +77,9 @@ its liabilities … from delivering or producing goods, rendering services,
or carrying out other activities." E81 expenses "are outflows or other or carrying out other activities." E81 expenses "are outflows or other
using up of assets … or incurrences of its liabilities …". using up of assets … or incurrences of its liabilities …".
**Enforced:** `AccountType` (five variants). **Checked:** by type. **Enforced:** `AccountType` (five variants). A child account has its
parent's type: DECISION (the owner, 2026-10-13), `Ledger::add_account`
(`ParentTypeMismatch`). **Checked:** `a_child_account_has_its_parents_type`.
## B-5 Normal balances ## B-5 Normal balances
@@ -178,7 +183,10 @@ the entity
Periods themselves: IRS Pub. 538 (`sources/irs-periods-and-methods.md`) Periods themselves: IRS Pub. 538 (`sources/irs-periods-and-methods.md`)
defines the tax year, calendar and fiscal. Locking a closed period and defines the tax year, calendar and fiscal. Locking a closed period and
closing in order are practice (and follow from B-8: a closed period's closing in order are practice (and follow from B-8: a closed period's
book is not rewritten). **Enforced:** `closed_period_containing`, book is not rewritten). Periods nest (a month in a quarter in a year)
and never partially overlap: DECISION (the owner, 2026-10-13),
`Ledger::create_period` (`PeriodsOverlap`;
`periods_nest_but_never_partially_overlap`). **Enforced:** `closed_period_containing`,
`CloseOutOfOrder`, `Store::rehearse`. **Checked:** Lean `Steps.frozen`; `CloseOutOfOrder`, `Store::rehearse`. **Checked:** Lean `Steps.frozen`;
store property `refused_commands_never_reach_the_journal` (no draft in a store property `refused_commands_never_reach_the_journal` (no draft in a
closed period). closed period).