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

Three spec decisions from Stephen (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 df38dcccf9
commit 925c3ba523
7 files changed
+362 -12

No files matched your search

+36
View File
@@ -123,6 +123,42 @@ pub enum LedgerError {
spec: String,
},
/// A child account subdivides its parent, so it must be the same type
/// (Stephen, 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 (Stephen, 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.
#[error("entry {0} does not exist")]
UnknownEntry(u64),
+132
View File
@@ -492,6 +492,22 @@ impl Ledger {
}
/// 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 (Stephen,
/// 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(
&mut self,
code: &str,
@@ -499,6 +515,30 @@ impl Ledger {
account_type: AccountType,
parent: Option<AccountId>,
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> {
let id = self.next_account_id;
self.next_account_id += 1;
@@ -616,6 +656,32 @@ impl Ledger {
account: AccountId,
side: Side,
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 (Stephen, 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> {
if amount.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch {
@@ -648,6 +714,28 @@ impl Ledger {
foreign_currency: Currency,
foreign_amount: 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> {
if foreign_amount <= Decimal::ZERO {
return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount));
@@ -919,11 +1007,55 @@ impl Ledger {
/// Create an accounting period. The date range must be valid
/// (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 (Stephen, 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(
&mut self,
name: &str,
start: 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> {
periods::validate_period_range(start, end)?;
let id = self.next_period_id;
+112 -2
View File
@@ -242,12 +242,18 @@ fn fx_overflow_is_an_error() {
#[test]
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 mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Ghost account");
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
.add_line(id, 999_999, Side::Credit, usd(100))
.replay_add_line(id, 999_999, Side::Credit, usd(100))
.unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
@@ -263,7 +269,14 @@ fn placeholder_account_is_rejected() {
.add_account("100", "Assets header", AccountType::Asset, None, true)
.unwrap();
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
.add_line(id, c.equity, Side::Credit, usd(100))
.unwrap();
@@ -906,3 +919,100 @@ fn a_close_refuses_a_3999_that_is_not_equity() {
assert_untouched(&before, &l);
assert!(l.closed_period_containing(date(5)).is_none());
}
// ---------------------------------------------------------------------------
// Chart, periods, and lines: rules confirmed by Stephen, 2026-10-13.
// ---------------------------------------------------------------------------
/// A child account subdivides its parent, so it is the same kind of
/// account. SOURCE: rules.md B-4; DECISION (Stephen, 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 (Stephen, 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 (Stephen, 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 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> {
let mut ledger = Ledger::new(self.base_currency);
let mut stmt = self.conn.prepare(
@@ -2400,7 +2404,7 @@ impl Store {
"account {code} currency {currency} != base"
)));
}
let got = ledger.add_account(
let got = ledger.replay_add_account(
&code,
&name,
account_type,
@@ -2477,7 +2481,7 @@ impl Store {
})?;
for (idx, lr) in lrows.enumerate() {
let (account, side, amount, currency, class_id) = lr?;
ledger.add_line(
ledger.replay_add_line(
got,
account as u64,
parse_side(&side)?,
@@ -2883,7 +2887,7 @@ pub(crate) fn replay_line_into_ledger(
let want = get_u64(p, "id")?;
let account_type: AccountType = serde_json::from_value(p["account_type"].clone())?;
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, "name")?,
account_type,
@@ -2928,7 +2932,7 @@ pub(crate) fn replay_line_into_ledger(
let fx_rate: Decimal = get_str(p, "fx_rate")?
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?;
ledger.add_line_fx(
ledger.replay_add_line_fx(
id,
account,
side,
@@ -2940,7 +2944,7 @@ pub(crate) fn replay_line_into_ledger(
)?;
}
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(|| {
StoreError::JournalCorrupt("entry.posted without lines".into())
})? {
ledger.add_line(
ledger.replay_add_line(
id,
get_u64(l, "account")?,
parse_side(get_str(l, "side")?)?,
@@ -3132,7 +3136,7 @@ pub(crate) fn replay_line_into_ledger(
}
"period.created" => {
let want = get_u64(p, "id")?;
let got = ledger.create_period(
let got = ledger.replay_create_period(
get_str(p, "name")?,
parse_date(get_str(p, "start")?)?,
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
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
- 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."
**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
(Stephen, 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
`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
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 (Stephen, 2026-10-13), `Ledger::add_account`
(`ParentTypeMismatch`). **Checked:** `a_child_account_has_its_parents_type`.
## B-5 Normal balances
@@ -178,7 +183,10 @@ the entity
Periods themselves: IRS Pub. 538 (`sources/irs-periods-and-methods.md`)
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
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 (Stephen, 2026-10-13),
`Ledger::create_period` (`PeriodsOverlap`;
`periods_nest_but_never_partially_overlap`). **Enforced:** `closed_period_containing`,
`CloseOutOfOrder`, `Store::rehearse`. **Checked:** Lean `Steps.frozen`;
store property `refused_commands_never_reach_the_journal` (no draft in a
closed period).