feat(store): a record's source can be corrected without touching its words (P5)

Accounting reason: a convention, a decision or an open item carries who
decided it, when and on what authority. When that is recorded wrongly the
honest fix is to say so on the record, not to restate the policy: a policy
revision bumps the convention's revision and reads as a change of the rule,
and a new decision supersedes the old one. A source correction changes only
the authority and says why, so the audit trail shows the words were never
altered.

New event types convention.source_revised, decision.source_revised and
open_item.source_revised {id, source, reason} under the journal feature
source_revisions: an older version would refuse the types, or show the old
source as the record's authority, so it stops at the declaration instead
(rules.md B-27) and books that have not had the owner's upgrade refuse the
write with format_upgrade_pending, journaling nothing. Replay sets the
source and nothing else (the text, the revision and the journal line stay);
the projection updates the rows' existing source columns, so no schema
changed. A correction must be one: other words, the same source, no reason
or an unknown record is refused.

books.set_convention takes reason (the text exactly as it stands),
books.record_decision and books.raise_item take revises with reason. Without
reason the old refusal stands and now says how to correct only the source.

8.0.6: the hand-written lines of the three shapes in
books_written_under_older_rules_still_open; fixture old-books/p5_before,
written by b97234ac (a revised convention, two decisions, two open items, a
draft) through old-books-writer/src/bin/p5_before.rs, opens to that
version's figures, so no figure moved; books_p2_served_cut_over_whole
refuses the correction before consent and takes it after; the fuzzer and the
viewer's journal know the types. The live books refuse the new write until
the owner gives the go-ahead; merging does not need it.

Tests: a_source_only_revision_is_journaled,
a_source_revision_must_change_only_the_source,
source_revisions_wait_for_the_upgrade, source_revisions_is_its_own_feature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 15:48:27 -06:00
1 parent cf73d0bda5
commit 4236f03744
19 files changed
+1188 -13

No files matched your search

@@ -14,6 +14,9 @@
# cargo run --bin p4_before -- OUT (p4_before: run against 25b52580, the
# revision before P4: hand entries posted
# late, a reversal, open drafts, a close)
# cargo run --bin p5_before -- OUT (p5_before: run against b97234ac, the
# revision before P5: a revised
# convention, two decisions, two open items)
# cargo run --bin expect -- OUT (expected.json beside each journal)
# and copy each journal.jsonl, blobs/ and expected.json into old-books/.
# x_member_closes was written later, the same way, by main 00226a4c
@@ -0,0 +1,105 @@
//! Writes `p5_before`: books the version before P5 (main b97234ac) wrote
//! through its Store API, with the records P5's source corrections act on:
//! a convention revised once, two decisions (one superseding the other)
//! and two open items (one resolved), each with its source, beside a few
//! posted entries and a draft awaiting its evidence. No source correction
//! exists in that version; this version opens the same journal and must
//! report the same figures.
use chrono::NaiveDate;
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::Source;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::Store;
use rust_decimal::Decimal;
use std::path::PathBuf;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn usd(c: i64) -> Money {
Money::new(Decimal::new(c, 2), Currency::USD)
}
fn source(by: &str, day: u32) -> Source {
Source {
decided_by: by.into(),
decided_on: d(2026, 10, day),
citation: None,
note: None,
}
}
fn main() {
let root = PathBuf::from(std::env::args().nth(1).unwrap());
let dir = root.join("p5_before");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Store::create(&dir, Currency::USD).unwrap();
let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap();
let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap();
let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap();
let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| {
let e = s.create_entry(date, what).unwrap();
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
e
};
let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000);
s.post_entry(seed).unwrap();
let jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
s.post_entry(jan).unwrap();
let _waiting = post(&mut s, d(2026, 2, 3), "Awaiting a receipt", rent, bank, 4_000);
// How the company keeps its books: a close step, revised once.
let step = |body: &str, by: &str, day: u32| NewConvention {
id: "close-checklist".into(),
kind: ConventionKind::CloseStep,
title: "Close checklist".into(),
body_md: body.into(),
structured: None,
source: source(by, day),
};
s.set_convention(&step("Reconcile, then close.", "Owner", 20)).unwrap();
s.set_convention(&step("Reconcile, review drafts, then close.", "Owner", 22)).unwrap();
// Two decisions, the second replacing the first.
let first = s
.record_decision(&NewDecision {
subject: "Materiality".into(),
decision_md: "Five hundred dollars.".into(),
decided_by: vec!["Owner".into()],
decided_on: d(2026, 10, 20),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![],
})
.unwrap();
s.record_decision(&NewDecision {
subject: "Materiality".into(),
decision_md: "One thousand dollars.".into(),
decided_by: vec!["Owner".into(), "Pat Smith".into()],
decided_on: d(2026, 10, 25),
evidence_attachment: None,
supersedes: Some(first),
citation: Some("IRS Pub. 538".into()),
projects: vec![],
})
.unwrap();
// Two open items, one resolved.
let ask = s
.raise_open_item(&NewOpenItem {
title: "Ask the CPA about the vehicle".into(),
detail_md: None,
due: Some(d(2026, 11, 15)),
source: source("Owner", 20),
})
.unwrap();
s.raise_open_item(&NewOpenItem {
title: "Mail the annual report".into(),
detail_md: Some("Due with the state.".into()),
due: None,
source: source("Owner", 21),
})
.unwrap();
s.resolve_open_item(ask, "Answered: lease it.", &source("Pat Smith", 26)).unwrap();
s.verify().unwrap();
println!("WROTE {:?}", dir);
}
@@ -0,0 +1,154 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-01-05",
"description": "January rent",
"id": 2,
"lines": [
{
"account": 3,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-03",
"description": "Awaiting a receipt",
"id": 3,
"lines": [
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Draft"
}
],
"entry_parties": [],
"invoices": [],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "500",
"balanced": true,
"current_earnings": "-500",
"equity": "500",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "500",
"net_income": "-500",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "500",
"name": "Bank"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "6000",
"credit": "0",
"debit": "500",
"name": "Rent"
}
],
"total_credits": "1000",
"total_debits": "1000"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,23 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:39:45Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"1ec5c6e78cbef1e34d22de1c3376c7ec5051d3cf16d99d5d21fc0f62ab272574","seq":2,"ts":"2026-10-29T20:39:45Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"49c0bd7bc22f2804ad1af0a6ed1488dcaa74ffcfbfc3f1d34580574c3b5f577a","seq":3,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"e62de432ee6bbf20db6231ceb2caa523a423cb6104747d0fee9bb7caa3306a13","seq":4,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Rent","parent":null,"placeholder":false},"prev":"e718e3ee547698e7a8dba46b0fa2006579b265c8941a2a64a8780e0d6799c05e","seq":5,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"1aac79f391f066f3caa4286252c17094d5dcd78c5f1a3a5c3d26831976876581","seq":6,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"278ea897aa5a37a5198f4165d03464945379a6b3cb25070cbf7a5b6a9c65faea","seq":7,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"59535fd0d40e6820e354c3f2c2b9e0e03107c03392413e81d738c4229f6c8d40","seq":8,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fea68defea0343aa4cbc3b70c6d7c8dec9481d5df0bd6db78ddd7bf4c9a6b67","seq":9,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"40488694d734440f615cfaa57164f31f92da6c5c5133891671eba86ccbf99628","seq":10,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"ea486bc7b9029b186f156d4d8d7ad14b149fe6d24585bcaeb8707e13546a4109","seq":11,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"0836c22c91fb4ed3a0a230b49d6a8123592cd80f9630819c1f7ba957a51abf8f","seq":12,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"15cf6b35fd07465fdf86d765fbcacbbcd9fb8e378a9aca516c232a91d406cfbb","seq":13,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"}
{"payload":{"date":"2026-02-03","description":"Awaiting a receipt","id":3},"prev":"331e0f5b3610e7b6a1e20d3682c5f2be6ce1b827e182643bd0be0ae097fbf4c7","seq":14,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
{"payload":{"account":3,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"e2faf752e39dac02ea40ff2133c8d33a1fe85d84f5d3e81473b77a0284bd9b63","seq":15,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"4b4fac7a91be608d99f90c1aad38650b6e2442699cb103dd4c9f3dc2846e63d4","seq":16,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
{"payload":{"body_md":"Reconcile, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Close checklist"},"prev":"0dd1ac379bd2987f018675f0c0ccc1558ddb6c5c32cd383bb5984c163f37b2c3","seq":17,"ts":"2026-10-29T20:39:45Z","type":"convention.set"}
{"payload":{"body_md":"Reconcile, review drafts, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-22"},"title":"Close checklist"},"prev":"ac97d62664a7b399d6fe38cfd62843068470fdc46f41f19a74c0388b42d42105","seq":18,"ts":"2026-10-29T20:39:45Z","type":"convention.set"}
{"payload":{"decided_by":["Owner"],"decided_on":"2026-10-20","decision_md":"Five hundred dollars.","id":1,"subject":"Materiality"},"prev":"4c7412cba11ebe3ab93e5a4c85a5f99f29913c952149f40a91a34e818d955a8e","seq":19,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"}
{"payload":{"citation":"IRS Pub. 538","decided_by":["Owner","Pat Smith"],"decided_on":"2026-10-25","decision_md":"One thousand dollars.","id":2,"subject":"Materiality","supersedes":1},"prev":"b89addb314620a56f585d6a3f997f7f658e1596bda44c6b56ffe51b4fe737fff","seq":20,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"}
{"payload":{"due":"2026-11-15","id":1,"source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Ask the CPA about the vehicle"},"prev":"4f1ce3e87d5bd5a4d689a0ef0a61422417c76d3af4d3793c03b12fecc5a73af1","seq":21,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"}
{"payload":{"detail_md":"Due with the state.","id":2,"source":{"decided_by":"Owner","decided_on":"2026-10-21"},"title":"Mail the annual report"},"prev":"7054527c8e4184297b46b801e6ce2c8237b467684c40ebce295957d0bab372cf","seq":22,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"}
{"payload":{"id":1,"resolution":"Answered: lease it.","source":{"decided_by":"Pat Smith","decided_on":"2026-10-26"}},"prev":"3ad740fbca7d8a6e8ce669a944631bc7d406f3f6820fb4b5c31c05fcbe26fca5","seq":23,"ts":"2026-10-29T20:39:46Z","type":"open_item.resolved"}
@@ -0,0 +1,266 @@
//! Source revisions (plan P5, journal feature `source_revisions`): who
//! decided a convention, a decision or an open item can be corrected, with
//! why, without touching its words. The correction is journaled as its own
//! event, replays identically, and waits for the owner's format upgrade.
//!
//! Expected behavior comes from `docs/receipt-store.md` (the three events)
//! and rules.md B-27 (a feature is declared before its first line).
use chrono::NaiveDate;
use ledger_core::conventions::{ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem};
use ledger_core::settings::Source;
use ledger_core::Currency;
use ledger_store::format::{KNOWN_FEATURES, SOURCE_REVISIONS};
use ledger_store::{JournalFormat, SUPPORTED_JOURNAL_FORMAT, Store, StoreError};
use std::path::PathBuf;
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::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-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn lines_of(dir: &std::path::Path) -> Vec<serde_json::Value> {
std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
fn day(d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
}
fn source(by: &str, d: u32, citation: Option<&str>) -> Source {
Source {
decided_by: by.into(),
decided_on: day(d),
citation: citation.map(str::to_string),
note: None,
}
}
fn convention(src: Source) -> NewConvention {
NewConvention {
id: "close-checklist".into(),
kind: ConventionKind::CloseStep,
title: "Close checklist".into(),
body_md: "Reconcile, then close.".into(),
structured: None,
source: src,
}
}
fn decision() -> NewDecision {
NewDecision {
subject: "Materiality".into(),
decision_md: "Five hundred dollars.".into(),
decided_by: vec!["Owner".into()],
decided_on: day(20),
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![],
}
}
fn item(src: Source) -> NewOpenItem {
NewOpenItem {
title: "Ask the CPA".into(),
detail_md: None,
due: None,
source: src,
}
}
/// Done-when (plan P5 step 5): a call that changes only the source is
/// journaled as the `*.source_revised` event, the words and the revision
/// stay, the live books and a replay agree, and the reopened books verify.
#[test]
fn a_source_only_revision_is_journaled() {
let dir = test_dir("source-revised");
let mut s = Store::create(&dir, Currency::USD).unwrap();
s.set_convention(&convention(source("Owner", 20, None))).unwrap();
let decision_id = s.record_decision(&decision()).unwrap();
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
let before = lines_of(&dir).len();
// A convention: the same words, a corrected source.
let cpa = source("Pat Smith", 21, Some("IRS Pub. 538"));
s.revise_convention_source(&convention(cpa.clone()), "the CPA decided it")
.unwrap();
s.revise_decision_source(
decision_id,
&DecisionSource {
decided_by: vec!["Owner".into(), "Pat Smith".into()],
decided_on: day(21),
citation: Some("IRS Pub. 538".into()),
},
"both decided it",
)
.unwrap();
s.revise_open_item_source(item_id, &cpa, "raised by the CPA")
.unwrap();
let lines = lines_of(&dir);
let types: Vec<&str> = lines[before..]
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect();
assert_eq!(
types,
[
"convention.source_revised",
"decision.source_revised",
"open_item.source_revised"
]
);
assert_eq!(lines[before]["payload"]["reason"], "the CPA decided it");
assert_eq!(lines[before]["payload"]["source"]["decided_by"], "Pat Smith");
// The words and the revision are as they were; the source is new.
let c = s.ledger().conventions().convention("close-checklist").unwrap();
assert_eq!((c.body_md.as_str(), c.revision), ("Reconcile, then close.", 1));
assert_eq!(c.source.decided_by, "Pat Smith");
let d = s.ledger().conventions().decision(decision_id).unwrap();
assert_eq!(d.new.decision_md, "Five hundred dollars.");
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!(
s.ledger().conventions().open_item(item_id).unwrap().new.source,
cpa
);
// Replay equals the live books, and the database file agrees.
let live = s.ledger().clone();
drop(s);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
drop(reopened);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let rebuilt = Store::open(&dir).unwrap();
rebuilt.verify().unwrap();
assert!(live.differs_from(rebuilt.ledger()).is_empty());
}
/// A correction must be one: other words, the same source, a retired
/// convention or a missing reason is refused and journals nothing.
#[test]
fn a_source_revision_must_change_only_the_source() {
let dir = test_dir("source-refused");
let mut s = Store::create(&dir, Currency::USD).unwrap();
s.set_convention(&convention(source("Owner", 20, None))).unwrap();
let id = s.record_decision(&decision()).unwrap();
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
let len = lines_of(&dir).len();
let mut other_words = convention(source("Pat Smith", 21, None));
other_words.body_md = "Close first.".into();
for refused in [
// other words
s.revise_convention_source(&other_words, "why"),
// the same source
s.revise_convention_source(&convention(source("Owner", 20, None)), "why"),
// no reason
s.revise_convention_source(&convention(source("Pat Smith", 21, None)), " "),
// no such convention, decision, item
s.revise_convention_source(
&NewConvention {
id: "nonesuch".into(),
..convention(source("Pat Smith", 21, None))
},
"why",
),
s.revise_decision_source(99, &decision().source(), "why"),
s.revise_decision_source(id, &decision().source(), "same source"),
s.revise_decision_source(
id,
&DecisionSource {
decided_by: vec![],
decided_on: day(21),
citation: None,
},
"why",
),
s.revise_open_item_source(99, &source("Pat Smith", 21, None), "why"),
s.revise_open_item_source(item_id, &source("Owner", 20, None), "same source"),
] {
assert!(
matches!(refused, Err(StoreError::Ledger(_))),
"{refused:?}"
);
}
assert_eq!(lines_of(&dir).len(), len, "a refusal journals nothing");
}
/// R-A step 3 (plan P5): books that have not declared `source_revisions`
/// refuse the three corrections, journaling nothing, with the format
/// upgrade pending; after the owner's go-ahead they journal.
#[test]
fn source_revisions_wait_for_the_upgrade() {
let dir = test_dir("source-wait");
// The version before P5: format 2, every feature but this one.
let before = JournalFormat {
format: SUPPORTED_JOURNAL_FORMAT,
features: KNOWN_FEATURES
.iter()
.copied()
.filter(|f| *f != SOURCE_REVISIONS)
.map(str::to_string)
.collect(),
};
let mut s = Store::create_in_format(&dir, Currency::USD, &before).unwrap();
s.set_convention(&convention(source("Owner", 20, None))).unwrap();
let id = s.record_decision(&decision()).unwrap();
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
let head = s.head();
let len = lines_of(&dir).len();
let cpa = source("Pat Smith", 21, None);
for refused in [
s.revise_convention_source(&convention(cpa.clone()), "why"),
s.revise_decision_source(
id,
&DecisionSource {
decided_by: vec!["Pat Smith".into()],
decided_on: day(21),
citation: None,
},
"why",
),
s.revise_open_item_source(item_id, &cpa, "why"),
] {
match refused {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature source_revisions")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
}
assert_eq!(s.head(), head, "a refusal journals nothing");
assert_eq!(lines_of(&dir).len(), len);
assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead");
s.revise_convention_source(&convention(cpa.clone()), "why").unwrap();
s.revise_open_item_source(item_id, &cpa, "why").unwrap();
let live = s.ledger().clone();
drop(s);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
}
+56
View File
@@ -2140,6 +2140,47 @@ fn books_written_under_older_rules_still_open() {
json!({"id": 9, "reason": "entered twice"}),
)
.unwrap();
// P5's new shapes, hand-written as this version writes them: a
// convention, a decision and an open item, each with its source
// corrected (`source_revisions`). This reader takes them; the version
// before it stopped at the declaration.
let owner = json!({"decided_by": "Owner", "decided_on": "2026-10-26"});
w.append(
"convention.set",
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
"body_md": "Reconcile, then close.", "source": owner}),
)
.unwrap();
w.append(
"decision.recorded",
json!({"id": 1, "subject": "Materiality", "decision_md": "Five hundred dollars.",
"decided_by": ["Owner"], "decided_on": "2026-10-26"}),
)
.unwrap();
w.append(
"open_item.raised",
json!({"id": 1, "title": "Ask the CPA", "source": owner}),
)
.unwrap();
let cpa = json!({"decided_by": "Pat Smith", "decided_on": "2026-10-27",
"citation": "IRS Pub. 538"});
w.append(
"convention.source_revised",
json!({"id": "close-checklist", "source": cpa, "reason": "the CPA decided it"}),
)
.unwrap();
w.append(
"decision.source_revised",
json!({"id": 1, "source": {"decided_by": ["Owner", "Pat Smith"],
"decided_on": "2026-10-27", "citation": "IRS Pub. 538"},
"reason": "both decided it"}),
)
.unwrap();
w.append(
"open_item.source_revised",
json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}),
)
.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()));
@@ -2221,6 +2262,21 @@ fn books_written_under_older_rules_still_open() {
);
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
assert_eq!(bill.lines[0].account, Some(6));
// P5's source corrections replay as written; the text stays as it was.
let c = store
.ledger()
.conventions()
.convention("close-checklist")
.expect("the convention");
assert_eq!(c.source.decided_by, "Pat Smith");
assert_eq!(c.source.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!((c.body_md.as_str(), c.revision), ("Reconcile, then close.", 1));
let d = store.ledger().conventions().decision(1).expect("the decision");
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!(d.new.decision_md, "Five hundred dollars.");
let i = store.ledger().conventions().open_item(1).expect("the item");
assert_eq!(i.new.source.decided_by, "Pat Smith");
// P4b S5's send replays as written, and moves no figure.
let sends = &store.ledger().get_invoice(1).expect("the invoice").sends;
assert_eq!(sends.len(), 1);