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

+136 -9
View File
@@ -1261,7 +1261,15 @@ impl Store {
line_type: &str,
payload: Value,
) -> Result<JournalLine, StoreError> {
self.journal.append(line_type, payload)
let line = self.journal.append(line_type, payload)?;
// The command that posts the entry runs after its line is
// journaled: tell the ledger which line that is (plan P4).
if matches!(line_type, "entry.posted" | "entry.reversed")
&& let Some(id) = line.payload.get("id").and_then(Value::as_u64)
{
self.ledger.expect_posted_seq(id, line.seq);
}
Ok(line)
}
/// Add an account to the chart, with no tax category (see
@@ -1431,6 +1439,67 @@ impl Store {
Ok(())
}
/// Set where a draft stands (`entry.draft_state_set`, plan P4): still
/// being built, or awaiting its evidence with a note saying what.
/// Journaled with the feature `drafts_v2` (an older version refuses
/// the type). A state moves no figure and projects nowhere but the
/// `draft_states` table.
///
/// ```text
/// GOAL: a draft that is finished and waiting on its receipt says so,
/// durably, and replays the same.
/// SOURCE: docs/spec/rules.md B-27, B-43; plan P4.
/// SCENARIOS:
/// 1. IF the engine refuses it (unknown or posted entry, a note the
/// books won't keep): SHOULD refuse, journaling nothing.
/// 2. IF the books haven't declared drafts_v2: SHOULD need the
/// feature, journaling nothing.
/// ```
pub fn set_draft_state(
&mut self,
entry: EntryId,
state: ledger_core::DraftState,
) -> Result<(), StoreError> {
self.ensure_feature(crate::format::DRAFTS_V2)?;
self.rehearse(|l| l.set_draft_state(entry, state.clone()))?;
let line = self.append_journal(
"entry.draft_state_set",
crate::meta::draft_state_set(entry, &state),
)?;
self.ledger.set_draft_state(entry, state)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Throw a draft away (`entry.draft_discarded`, plan P4), with the
/// reason; journaled with the feature `drafts_v2`. The draft's lines
/// stay in the journal; the draft leaves the ledger. A posted entry is
/// never discarded.
///
/// ```text
/// GOAL: a draft that will never post can go, so it stops holding its
/// period open, and the journal still says why.
/// SOURCE: docs/spec/rules.md B-8, B-43; plan P4.
/// SCENARIOS:
/// 1. IF the entry is unknown or posted, or the reason is empty or
/// breaks the books' text rule: SHOULD refuse, journaling
/// nothing.
/// 2. IF the books haven't declared drafts_v2: SHOULD need the
/// feature, journaling nothing.
/// ```
pub fn discard_draft(&mut self, entry: EntryId, reason: &str) -> Result<(), StoreError> {
self.ensure_feature(crate::format::DRAFTS_V2)?;
self.rehearse(|l| l.discard_draft(entry, reason))?;
let reason = reason.trim();
let line = self.append_journal(
"entry.draft_discarded",
crate::meta::draft_discarded(entry, reason),
)?;
self.ledger.discard_draft(entry, reason)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
/// Validate, journal, and post a draft entry. An entry an enforced
/// approval rule covers posts only once its approvers approved it as
/// it stands (ledger_core::conventions INVARIANT 3).
@@ -2905,6 +2974,8 @@ impl Store {
}),
)?;
self.ledger = next;
// The closing entry was posted by this line (plan P4, posted_seq).
self.ledger.stamp_unstamped(line.seq);
apply_line_to_sqlite(&self.conn, &line)?;
Ok(closing_entry)
}
@@ -4859,6 +4930,8 @@ impl Store {
}),
)?;
self.ledger = next;
// The month's entry was posted by this line (plan P4, posted_seq).
self.ledger.stamp_unstamped(line.seq);
apply_line_to_sqlite(&self.conn, &line)?;
Ok(entry)
}
@@ -7091,7 +7164,10 @@ pub(crate) fn replay_line_into_ledger(
line: &JournalLine,
base_currency: Currency,
) -> Result<(), StoreError> {
ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency))
ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency))?;
// Whatever this line posted was posted by it (plan P4, posted_seq).
ledger.stamp_unstamped(line.seq);
Ok(())
}
/// [`replay_line_into_ledger`]'s work.
@@ -7206,6 +7282,14 @@ fn replay_line(
}
ledger.replay_post_entry(id)?;
}
"entry.draft_state_set" => {
let (id, state) = crate::meta::draft_state_set_of(line)?;
ledger.replay_set_draft_state(id, state)?;
}
"entry.draft_discarded" => {
let (id, _) = crate::meta::draft_discarded_of(p)?;
ledger.replay_discard_draft(id)?;
}
"entry.reversed" => {
let want = get_u64(p, "id")?;
let orig = get_u64(p, "reverses")?;
@@ -7994,11 +8078,16 @@ fn close_to_from_journal(line: &JournalLine, v: Option<&Value>) -> Result<CloseT
/// Record a posted entry that rides inside another event's payload (a
/// closing entry, a schedule posting) in the projection, like any posted
/// entry.
fn project_posted_entry(conn: &Connection, e: &Value) -> Result<(), StoreError> {
fn project_posted_entry(conn: &Connection, e: &Value, seq: u64) -> Result<(), StoreError> {
let eid = get_u64(e, "id")? as i64;
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)",
params![eid, get_str(e, "date")?, get_str(e, "description")?],
"INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',NULL,?)",
params![
eid,
get_str(e, "date")?,
get_str(e, "description")?,
seq as i64
],
)?;
for l in e
.get("lines")
@@ -8109,16 +8198,53 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// Draft lines are durable in the journal but never touch the
// projection until posted.
"entry.line_added" => {}
// A draft awaiting its evidence has a row; one being built (the
// default) has none; a posted or discarded draft none (plan P4).
"entry.draft_state_set" => {
let (id, state) = crate::meta::draft_state_set_of(line)?;
match state {
ledger_core::DraftState::Building => {
conn.execute(
"DELETE FROM draft_states WHERE entry_id = ?",
params![id as i64],
)?;
}
ledger_core::DraftState::AwaitingEvidence { note } => {
conn.execute(
"INSERT INTO draft_states(entry_id, state, note) VALUES (?,?,?) \
ON CONFLICT(entry_id) DO UPDATE SET state = excluded.state, note = excluded.note",
params![
id as i64,
ledger_core::DraftState::AwaitingEvidence { note: note.clone() }.key(),
(!note.is_empty()).then_some(note),
],
)?;
}
}
}
"entry.draft_discarded" => {
let (id, _) = crate::meta::draft_discarded_of(p)?;
conn.execute(
"DELETE FROM draft_states WHERE entry_id = ?",
params![id as i64],
)?;
}
"entry.posted" => {
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
"INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',?,?)",
params![
get_u64(p, "id")? as i64,
get_str(p, "date")?,
get_str(p, "description")?,
p.get("reversal_of").and_then(|v| v.as_u64()).map(|v| v as i64),
line.seq as i64,
],
)?;
// A posted entry waits on nothing (plan P4).
conn.execute(
"DELETE FROM draft_states WHERE entry_id = ?",
params![get_u64(p, "id")? as i64],
)?;
for l in p
.get("lines")
.and_then(|v| v.as_array())
@@ -8153,12 +8279,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64],
)?;
conn.execute(
"INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)",
"INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',?,?)",
params![
get_u64(p, "id")? as i64,
get_str(p, "date")?,
get_str(p, "description")?,
get_u64(p, "reverses")? as i64,
line.seq as i64,
],
)?;
for l in p
@@ -8498,7 +8625,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
"schedule.registered" | "recurring.registered" | "recurring.generated" => {
// Master data and drafts: the ledger holds them; no report rows.
}
"schedule.posted" => project_posted_entry(conn, &p["entry"])?,
"schedule.posted" => project_posted_entry(conn, &p["entry"], line.seq)?,
"receipt.unlinked" => {
conn.execute(
"UPDATE attachments SET entry_id = NULL WHERE id = ?",
@@ -8624,7 +8751,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// posted entry so the projection stays complete, then mark the
// period row closed.
if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) {
project_posted_entry(conn, &p["entry"])?;
project_posted_entry(conn, &p["entry"], line.seq)?;
}
conn.execute(
"UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?",