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

+7 -1
View File
@@ -63,6 +63,12 @@ impl DraftState {
}
}
/// Whether this is the default, building (a serialized entry omits it,
/// so every entry an earlier version wrote reads and writes as it did).
pub fn is_building(&self) -> bool {
matches!(self, DraftState::Building)
}
/// The note an awaiting state carries; empty for building.
pub fn note(&self) -> &str {
match self {
@@ -110,7 +116,7 @@ pub struct JournalEntry {
reversal_of: Option<EntryId>,
/// Where the draft stands (`entry.draft_state_set`); meaningful while
/// `status` is `Draft`.
#[serde(default)]
#[serde(default, skip_serializing_if = "DraftState::is_building")]
draft_state: DraftState,
}
+35 -6
View File
@@ -360,6 +360,14 @@ pub struct Ledger {
/// backdated entries included. An entry the journal's own posting
/// line doesn't name (none today) has no place here.
posted_seq: std::collections::BTreeMap<EntryId, u64>,
/// The journal line already known to be an entry's posting line,
/// before the engine posts it (a store journals `entry.posted` ahead
/// of the command that posts): consumed when the entry posts. Not
/// state of its own.
expected_seq: std::collections::BTreeMap<EntryId, u64>,
/// Entries posted since the last [`Self::stamp_unstamped`], whose
/// posting line is the one being applied. Not state of its own.
unstamped: Vec<EntryId>,
}
impl Ledger {
@@ -417,6 +425,8 @@ impl Ledger {
annotations: std::collections::BTreeMap::new(),
entry_settles: std::collections::BTreeMap::new(),
posted_seq: std::collections::BTreeMap::new(),
expected_seq: std::collections::BTreeMap::new(),
unstamped: Vec::new(),
}
}
@@ -1976,6 +1986,12 @@ impl Ledger {
.get_mut(&entry)
.ok_or(LedgerError::UnknownEntry(entry))?
.mark_posted();
match self.expected_seq.remove(&entry) {
Some(seq) => {
self.posted_seq.entry(entry).or_insert(seq);
}
None => self.unstamped.push(entry),
}
self.order_pos.insert(entry, self.order.len());
self.order.push(entry);
if let Some(o) = snapshot.reversal_of() {
@@ -2086,13 +2102,26 @@ impl Ledger {
self.posted_seq.get(&entry).copied()
}
/// Record the journal line that posted `entry` (the store, live and
/// on replay, after the posting line's own command). Idempotent for
/// the same line; a second, different line is ignored (the first
/// posting stands).
/// Say that journal line `seq` will be `entry`'s posting line, ahead
/// of the command that posts it (a store, live: it journals
/// `entry.posted` or `entry.reversed` first). Consumed when the entry
/// posts.
#[doc(hidden)]
pub fn note_posted_seq(&mut self, entry: EntryId, seq: u64) {
self.posted_seq.entry(entry).or_insert(seq);
pub fn expect_posted_seq(&mut self, entry: EntryId, seq: u64) {
self.expected_seq.insert(entry, seq);
}
/// Give every entry posted since the last call (and not already told
/// its line by [`Self::expect_posted_seq`]) journal line `seq`: the
/// line whose application posted it. A store calls it after each line
/// it replays, and after a live command that journals one line and
/// posts an entry with it (a close, a schedule month). The first
/// posting stands.
#[doc(hidden)]
pub fn stamp_unstamped(&mut self, seq: u64) {
for entry in std::mem::take(&mut self.unstamped) {
self.posted_seq.entry(entry).or_insert(seq);
}
}
/// Draft entries in id order, with their state.