feat(store): draft states and discards are journaled, posted_seq is kept (P4)

Accounting reason: a draft that waits on its receipt, and one thrown
away, are facts about the books' working papers that must survive a
restart and replay identically, or a discarded draft would come back and
hold its period open. entry.draft_state_set and entry.draft_discarded are
drafts_v2's (an older version stops at the declaration instead of
misreading them); no figure moves. Schema 19 (taken at merge as the next
after the current last): entries.posted_seq, and a draft_states table
(drafts are not rows of entries, so the plan's entries.draft_state column
has nowhere to live). posted_seq is the journal line that posted each
entry (entry.posted, entry.reversed, the line that closed a period or
posted a schedule month), identical live and on replay.

8.0.6: a hand-written line of each new shape in
books_written_under_older_rules_still_open; fixture old-books/p4_before
written by 25b52580 (hand entries posted late, a reversal, a document, a
schedule month, a recurring draft, open drafts, a close) opens to the
same figures (serialized entries omit a building state, so no golden
moved); drafts_v2_is_its_own_feature; the fuzzer and GUI journal know the
types.

Tests: draft_states_replay_and_project, drafts_v2_waits_for_the_upgrade,
posted_seq_is_the_posting_lines_seq, an_older_version_stops_at_the_draft_events.

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 13:55:17 -06:00
1 parent be8bcf340e
commit 28d46332e4
18 files changed
+1455 -24

No files matched your search

+66 -1
View File
@@ -18,7 +18,7 @@
use chrono::NaiveDate;
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::format::{DOCUMENTS_V2, EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
use ledger_store::format::{DOCUMENTS_V2, DRAFTS_V2, EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
use ledger_store::journal::{canonical_json, sha256_hex};
use ledger_store::{
JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
@@ -1586,3 +1586,68 @@ fn an_unknown_convention_kind_is_newer_for_the_writer_and_the_viewer_alike() {
(Err(e), Ok(_)) => panic!("the viewer loads books the writer refuses: {e}"),
}
}
/// Done-when (plan P4): the two draft events are this version's, they need
/// `drafts_v2`, and the books declare that feature before the first such
/// line, so a version without it stops at the declaration and reports the
/// books as newer, never corrupt, never keeping a discarded draft that
/// would block a close (rules.md B-27, B-43).
#[test]
fn an_older_version_stops_at_the_draft_events() {
assert!(EVENT_TYPES.contains(&"entry.draft_state_set"));
assert!(EVENT_TYPES.contains(&"entry.draft_discarded"));
let older = JournalFormat {
format: SUPPORTED_JOURNAL_FORMAT,
features: KNOWN_FEATURES
.iter()
.copied()
.filter(|f| *f != DRAFTS_V2)
.map(str::to_string)
.collect(),
};
assert!(
matches!(
older.require_feature(DRAFTS_V2),
Err(StoreError::FormatUpgradePending { .. })
),
"a reader without drafts_v2 waits for it"
);
let dir = test_dir("drafts-gate");
let mut store =
Store::create_in_format(&dir, Currency::USD, &JournalFormat::default()).unwrap();
let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
let draft = store.create_entry(date, "Waiting").unwrap();
store.upgrade_format().unwrap().expect("the go-ahead");
store
.set_draft_state(
draft,
ledger_core::DraftState::AwaitingEvidence {
note: "receipt".into(),
},
)
.unwrap();
store.discard_draft(draft, "not needed").unwrap();
let journal = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
let mut declared = false;
let mut seen = 0;
for line in journal.lines() {
let v: serde_json::Value = serde_json::from_str(line).unwrap();
if v["type"] == FORMAT_SET
&& v["payload"]["features"]
.as_array()
.is_some_and(|f| f.iter().any(|x| x == DRAFTS_V2))
{
declared = true;
}
if v["type"] == "entry.draft_state_set" || v["type"] == "entry.draft_discarded" {
assert!(
declared,
"drafts_v2 must be declared before the draft events"
);
seen += 1;
}
}
assert_eq!(seen, 2);
drop(store);
Store::open(&dir).unwrap().verify().unwrap();
}