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
1276 lines
49 KiB
Rust
1276 lines
49 KiB
Rust
//! The format gate: what this version of wanderbooks can read.
|
|
//!
|
|
//! Every journal line has a type, and this version knows a fixed set of
|
|
//! them ([`EVENT_TYPES`]). Books written by a newer wanderbooks can hold a
|
|
//! type this version has never seen, or declare a newer journal format
|
|
//! (`journal.format_set`) before the first event that needs it. Either way
|
|
//! the line is whole and chained: the books are fine, and this reader is
|
|
//! old. It says so ([`StoreError::NewerJournal`]) instead of calling the
|
|
//! books corrupt, and stops there: it never skips a line it can't read.
|
|
//!
|
|
//! The gate runs as each line is read, after its parse, seq and hash
|
|
//! checks ([`crate::journal::read_journal_and_format`], and
|
|
//! [`crate::Snapshot`] line by line).
|
|
//!
|
|
//! What every format shares is frozen (rules.md B-27): a line is the
|
|
//! envelope `{seq, ts, type, payload, prev}` and nothing beside it, and
|
|
//! its hash is the SHA-256 of its canonical JSON (keys sorted, no
|
|
//! whitespace). A newer format adds its fields inside `payload`. So this
|
|
//! version can always follow the chain one line past the first line it
|
|
//! can't read, and say whether that line is intact.
|
|
//!
|
|
//! A line an earlier version can't read is announced by a feature
|
|
//! ([`KNOWN_FEATURES`]): the books declare it (`journal.format_set`)
|
|
//! before the first line that needs it, so the earlier reader stops at the
|
|
//! declaration.
|
|
//!
|
|
//! The other way round, books an older version wrote are in an older
|
|
//! format: this version reads them as they are, and writes nothing that
|
|
//! needs a format or feature they haven't declared until the owner says
|
|
//! go (the format upgrade, rules.md B-27, plan/served.md 3.9, D-39:
|
|
//! [`JournalFormat::upgrade`], [`current`]). New books declare this
|
|
//! version's whole format as they are created.
|
|
|
|
use crate::error::StoreError;
|
|
use crate::journal::{JournalLine, ReadLine};
|
|
use serde_json::{Value, json};
|
|
|
|
/// The newest journal format this version reads and writes.
|
|
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
|
|
|
|
/// Optional journal features this version reads. Each marks lines an
|
|
/// older version can't read: a new event type (which it would refuse as
|
|
/// unknown), or a field or value on an event type it knows (which it
|
|
/// would misread silently). The books declare it (`journal.format_set`)
|
|
/// before their first such line, so the older version stops there instead
|
|
/// (rules.md B-27). Every one is declared at once: by new books as they
|
|
/// are created, and by older books at their format upgrade ([`current`]);
|
|
/// until then a write that needs one is refused.
|
|
pub const KNOWN_FEATURES: &[&str] = &[
|
|
MEMBERS_CUMULATIVE,
|
|
FEATURE_ACCOUNT_RENAMED,
|
|
FEATURE_REQUIRE_TAX_CATEGORY,
|
|
PARTY_CREDITORS,
|
|
PAYMENT_INSTRUMENTS,
|
|
VENDOR_1099_DECISIONS,
|
|
MONTH_REPOSTING,
|
|
SCHEDULE_CONVENTION,
|
|
ASSET_BASIS,
|
|
TAX_CATEGORIES_GAIN_EXEMPT,
|
|
DISPOSAL_BEFORE_SERVICE,
|
|
DEPRECIATION_CATCH_UP,
|
|
PROJECTS,
|
|
DOCUMENT_LINE_PROJECTS,
|
|
DOCUMENT_STANDING,
|
|
DOCUMENTS_V2,
|
|
ATTACHMENT_TEXT,
|
|
DRAFTS_V2,
|
|
];
|
|
|
|
/// Feature: `period.closed` closes to members with `"rounding":
|
|
/// "cumulative_since"` and `since_seq`, the count of members closes
|
|
/// starting at the first since the members or shares last changed (P3
|
|
/// defect 17, rules.md B-13). An older version would call the rounding
|
|
/// unknown, or (before the format gate) replay the close under the run
|
|
/// rule, which can split it differently.
|
|
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
|
|
|
|
/// Feature: `account.renamed`, a new event type (P3, defect 15).
|
|
pub const FEATURE_ACCOUNT_RENAMED: &str = "account_renamed";
|
|
|
|
/// Feature: a `settings.set` naming `policy.require_tax_category`, a key
|
|
/// an earlier version doesn't know (P3, defect 8).
|
|
pub const FEATURE_REQUIRE_TAX_CATEGORY: &str = "require_tax_category";
|
|
|
|
/// The feature a `settings.set` naming `key` needs the books to have
|
|
/// declared: a key an earlier version doesn't know.
|
|
///
|
|
/// Every key P3 added is one (P3 review, 2026-10-21: `books.schedule_convention`
|
|
/// wasn't, so setting it left books P2 refuses to open, before consent).
|
|
pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
|
match key {
|
|
"policy.require_tax_category" => Some(FEATURE_REQUIRE_TAX_CATEGORY),
|
|
"books.schedule_convention" => Some(SCHEDULE_CONVENTION),
|
|
// P4b S4.2: the invoicing and bills settings, and the company's
|
|
// contact details an invoice's seller snapshot reads.
|
|
"invoicing.number_prefix"
|
|
| "invoicing.first_number"
|
|
| "invoicing.default_terms"
|
|
| "invoicing.payment_methods"
|
|
| "invoicing.footer"
|
|
| "invoicing.unsent_days"
|
|
| "invoicing.reminder_days"
|
|
| "bills.remind_days"
|
|
| "bills.discount_warn_days"
|
|
| "company.email"
|
|
| "company.website" => Some(DOCUMENTS_V2),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Feature: an `account.tax_set` naming a tax category P3 added
|
|
/// (`form_4797_gain_loss`, `tax_exempt_income`). An earlier version
|
|
/// refuses the category, so it can't open the books (P3 review,
|
|
/// 2026-10-21).
|
|
pub const TAX_CATEGORIES_GAIN_EXEMPT: &str = "tax_categories_gain_exempt";
|
|
|
|
/// The feature an `account.tax_set` naming `category` needs the books to
|
|
/// have declared: a category an earlier version doesn't know.
|
|
pub fn tax_category_feature(category: &str) -> Option<&'static str> {
|
|
match category {
|
|
"form_4797_gain_loss" | "tax_exempt_income" => Some(TAX_CATEGORIES_GAIN_EXEMPT),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Feature: an `asset.disposed` dated before the asset's in-service date
|
|
/// (on or after the day it was acquired, P3 defect 13). An earlier version
|
|
/// refuses a disposal before its `purchase_date`, so it can't open the
|
|
/// books (P3 review, 2026-10-21).
|
|
pub const DISPOSAL_BEFORE_SERVICE: &str = "disposal_before_service";
|
|
|
|
/// Feature: an `asset.depreciation_posted` whose amount is not its month's
|
|
/// own depreciation: earlier months skipped or reversed caught up in it, or
|
|
/// a final month left over past the life (P3's run rule, B-15). An earlier
|
|
/// version reads every run as its month's own: it would show the caught-up
|
|
/// months as not yet run, and running them post their depreciation twice
|
|
/// (P3 review, 2026-10-21).
|
|
pub const DEPRECIATION_CATCH_UP: &str = "depreciation_catch_up";
|
|
|
|
/// The feature `party.creditor_set` lines need: a party named the
|
|
/// creditor of a liability (rules.md B-35, P3 defect 24).
|
|
pub const PARTY_CREDITORS: &str = "party_creditors";
|
|
|
|
/// Payment methods (`instrument.added`) and the entries tagged with one
|
|
/// (`entry.instrument_set`): plan P3, defect 12.
|
|
pub const PAYMENT_INSTRUMENTS: &str = "payment_instruments";
|
|
|
|
/// 1099 decisions kept as decisions (`vendor.1099_set` with
|
|
/// `not_reportable`, its reason, the date it takes effect and who decided:
|
|
/// plan P3, defects 18 and 23, D-40) and W-9 flags kept apart from them
|
|
/// (`vendor.w9_flag_set`).
|
|
pub const VENDOR_1099_DECISIONS: &str = "vendor_1099_decisions";
|
|
|
|
/// Feature: a schedule month or a depreciation period posted again after a
|
|
/// reversal un-posted it. An older version would refuse the second
|
|
/// `schedule.posted` as posted twice, and its projection would ignore the
|
|
/// second `asset.depreciation_posted` (P3 depreciation review R3).
|
|
pub const MONTH_REPOSTING: &str = "month_reposting";
|
|
|
|
/// Feature: `schedule.registered` carries `convention` (`daily` or
|
|
/// `monthly_even`). An older version would read a daily schedule as equal
|
|
/// months (P3 defect 9, plan/served.md D-11).
|
|
pub const SCHEDULE_CONVENTION: &str = "schedule_convention";
|
|
|
|
/// Feature: `asset.basis_set`, a new event type: an asset's basis facts
|
|
/// with the value at contribution and the §704(c) method (P3, defect 13).
|
|
pub const ASSET_BASIS: &str = "asset_basis";
|
|
|
|
/// Feature: projects (rules.md B-37): `project.set`, a new event type (a
|
|
/// class's project facts); `attachment.linked` to the subject kind
|
|
/// `project` (an older version would call it corrupt); and
|
|
/// `decision.recorded` with `projects` (an older version would drop them
|
|
/// silently).
|
|
pub const PROJECTS: &str = "projects";
|
|
|
|
/// Feature: an `invoice.created` or `bill.created` line naming its project
|
|
/// (`class_id`, rules.md B-37 item 8). An older version would restore the
|
|
/// document without its lines' projects, silently. Its own feature, not
|
|
/// `projects`': books that consented to projects before it existed must
|
|
/// consent again before a document names one.
|
|
pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects";
|
|
|
|
/// Feature: an `invoice.payment_received` or `bill.paid` accepted only
|
|
/// because an earlier payment on its document was reversed (plan P4b S1,
|
|
/// D-45; rules.md B-38): this version reads a document's figures from the
|
|
/// ledger lines that stand, so a reversed payment no longer counts and the
|
|
/// right one is taken. An older version adds every payment event to the
|
|
/// document's paid amount, standing or not: it would replay the new
|
|
/// payment on top of the reversed one, paid above the total, silently.
|
|
/// Every other payment is written as before and needs no feature.
|
|
pub const DOCUMENT_STANDING: &str = "document_standing";
|
|
|
|
/// Feature: `documents_v2` (D-52; plan P4b S4 to S7): an `invoice.created`
|
|
/// or `bill.created` carrying a number, terms, snapshots, references or
|
|
/// line accounts; a `party.updated` with an email or default terms; a
|
|
/// `settings.set` of the invoicing or bills keys or the company's contact
|
|
/// details. S4.2 writes the settings and party fields; S4.3 to S7 add the
|
|
/// document fields, sends, settlements and write-offs. An older version
|
|
/// would drop the line accounts (posting to the role accounts), the
|
|
/// discount and the fee, or refuse the unknown values, silently.
|
|
pub const DOCUMENTS_V2: &str = "documents_v2";
|
|
|
|
/// Feature: the evidence pipeline (plan P6): `attachment.text_extracted`
|
|
/// (a file's text, journaled as an extraction record), `attachment.annotated`
|
|
/// (the facts a human read off a file), `entry.settles` (which entries a
|
|
/// payment pays back), and `attachment.withdrawn` carrying `duplicate_of`.
|
|
/// An older version doesn't know those event types, or would withdraw a
|
|
/// duplicate without saying what it duplicates; it stops at the
|
|
/// declaration instead (rules.md B-27, B-44).
|
|
pub const ATTACHMENT_TEXT: &str = "attachment_text";
|
|
|
|
/// Feature: `drafts_v2` (plan P4, D-52): `entry.draft_state_set` (a draft
|
|
/// building or awaiting its evidence) and `entry.draft_discarded` (a
|
|
/// draft thrown away, with why). An older version doesn't know those
|
|
/// event types: it would refuse them as unknown, or, replaying around
|
|
/// them, keep a discarded draft that blocks a period's close and call a
|
|
/// waiting draft one being built; it stops at the declaration instead
|
|
/// (rules.md B-27, B-43).
|
|
pub const DRAFTS_V2: &str = "drafts_v2";
|
|
|
|
/// The line type that declares the journal format.
|
|
pub const FORMAT_SET: &str = "journal.format_set";
|
|
|
|
/// Every journal line type this version reads. Each has a replay arm and a
|
|
/// projection arm in `store.rs` (`tests/format.rs` checks both ways).
|
|
pub const EVENT_TYPES: &[&str] = &[
|
|
"account.added",
|
|
"account.renamed",
|
|
"account.renumbered",
|
|
"account.tax_set",
|
|
"asset.acquisition_linked",
|
|
"asset.basis_set",
|
|
"asset.corrected",
|
|
"asset.depreciation_posted",
|
|
"asset.described",
|
|
"asset.disposed",
|
|
"asset.registered",
|
|
"asset.serial_set",
|
|
"asset.tagged",
|
|
"asset.tax_basis_set",
|
|
"attachment.annotated",
|
|
"attachment.linked",
|
|
"attachment.text_extracted",
|
|
"attachment.unlinked",
|
|
"attachment.withdrawn",
|
|
"bill.created",
|
|
"bill.paid",
|
|
"class.added",
|
|
"convention.retired",
|
|
"convention.set",
|
|
"customer.added",
|
|
"decision.recorded",
|
|
"document.filed",
|
|
"entry.approved",
|
|
"entry.created",
|
|
"entry.draft_discarded",
|
|
"entry.draft_state_set",
|
|
"entry.instrument_set",
|
|
"entry.line_added",
|
|
"entry.line_class_set",
|
|
"entry.no_party",
|
|
"entry.parties_set",
|
|
"entry.party_set",
|
|
"entry.posted",
|
|
"entry.reversed",
|
|
"entry.settles",
|
|
"entry.vendor_set",
|
|
"fx.rate_set",
|
|
"invoice.created",
|
|
"invoice.payment_received",
|
|
"invoice.sent",
|
|
"invoice.written_off",
|
|
"instrument.added",
|
|
FORMAT_SET,
|
|
"journal.tail_repaired",
|
|
"line.cleared",
|
|
"open_item.raised",
|
|
"open_item.resolved",
|
|
"party.added",
|
|
"party.creditor_set",
|
|
"party.role_set",
|
|
"party.tin_set",
|
|
"party.updated",
|
|
"party.w4_set",
|
|
"party.w9_set",
|
|
"payroll.run",
|
|
"payroll.taxes_remitted",
|
|
"period.closed",
|
|
"period.created",
|
|
"project.set",
|
|
"receipt.attached",
|
|
"receipt.linked",
|
|
"receipt.unlinked",
|
|
"reconciliation.completed",
|
|
"reconciliation.voided",
|
|
"recurring.generated",
|
|
"recurring.registered",
|
|
"role.assigned",
|
|
"schedule.posted",
|
|
"schedule.registered",
|
|
"settings.set",
|
|
"statement.recorded",
|
|
"statement.voided",
|
|
"store.created",
|
|
"vendor.1099_set",
|
|
"vendor.added",
|
|
"vendor.w9_flag_set",
|
|
];
|
|
|
|
/// The journal format in force: the last `journal.format_set` read, or
|
|
/// format 1 with no features for books that never declared one.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct JournalFormat {
|
|
/// Format number: 1 for every journal written before the gate.
|
|
pub format: u32,
|
|
/// Optional features in use, as declared.
|
|
pub features: Vec<String>,
|
|
}
|
|
|
|
impl Default for JournalFormat {
|
|
fn default() -> Self {
|
|
Self {
|
|
format: 1,
|
|
features: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl JournalFormat {
|
|
/// Pass one verified line through the gate, before it is replayed.
|
|
///
|
|
/// ```text
|
|
/// GOAL: an older wanderbooks never misreads newer books, and never
|
|
/// calls them corrupt.
|
|
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
|
|
/// INVARIANTS:
|
|
/// 1. Only lines that parsed, chained and kept their seq reach the
|
|
/// gate: damage stays JournalCorrupt.
|
|
/// 2. NEVER admits a line this version can't replay.
|
|
/// SCENARIOS:
|
|
/// 1. IF the type is one this version doesn't know: SHOULD refuse
|
|
/// with NewerJournal
|
|
/// BECAUSE skipping it would show books that differ from the
|
|
/// record without saying so.
|
|
/// 2. IF it is a journal.format_set newer than
|
|
/// SUPPORTED_JOURNAL_FORMAT, or with a feature not in
|
|
/// KNOWN_FEATURES: SHOULD refuse with NewerJournal.
|
|
/// 3. IF it is a journal.format_set this version reads: SHOULD
|
|
/// record it as the format in force.
|
|
/// 4. IF a journal.format_set payload is malformed: SHOULD refuse
|
|
/// with JournalCorrupt.
|
|
/// ```
|
|
pub fn admit(&mut self, line: &JournalLine) -> Result<(), StoreError> {
|
|
if line.line_type == FORMAT_SET {
|
|
*self = declared(line)?;
|
|
Ok(())
|
|
} else if EVENT_TYPES.contains(&line.line_type.as_str()) {
|
|
Ok(())
|
|
} else {
|
|
Err(unknown_type(line))
|
|
}
|
|
}
|
|
|
|
/// Check a declaration this version is about to write: a format it
|
|
/// reads, not lower than the one in force, with features it knows.
|
|
/// Returns the journal payload.
|
|
pub(crate) fn declaration(&self, format: u32, features: &[&str]) -> Result<Value, StoreError> {
|
|
if format == 0 || format > SUPPORTED_JOURNAL_FORMAT {
|
|
return Err(StoreError::FormatRefused(format!(
|
|
"journal format {format}: this version writes 1 through {SUPPORTED_JOURNAL_FORMAT}"
|
|
)));
|
|
}
|
|
if format < self.format {
|
|
return Err(StoreError::FormatRefused(format!(
|
|
"journal format {format}: the books already use format {}, and a format is never lowered",
|
|
self.format
|
|
)));
|
|
}
|
|
if let Some(f) = features.iter().find(|f| !KNOWN_FEATURES.contains(f)) {
|
|
return Err(StoreError::FormatRefused(format!(
|
|
"journal feature {f:?}: this version doesn't know it"
|
|
)));
|
|
}
|
|
let mut features: Vec<&str> = features.to_vec();
|
|
features.sort_unstable();
|
|
features.dedup();
|
|
Ok(json!({ "format": format, "features": features }))
|
|
}
|
|
}
|
|
|
|
/// The journal format this version writes: the newest format it reads,
|
|
/// with every feature it knows, sorted. New books declare it as they are
|
|
/// created ([`crate::Store::create`]), and books in an older format move
|
|
/// to it in one step at their format upgrade
|
|
/// ([`crate::Store::upgrade_format`]).
|
|
pub fn current() -> JournalFormat {
|
|
let mut features: Vec<String> = KNOWN_FEATURES.iter().map(|f| f.to_string()).collect();
|
|
features.sort_unstable();
|
|
features.dedup();
|
|
JournalFormat {
|
|
format: SUPPORTED_JOURNAL_FORMAT,
|
|
features,
|
|
}
|
|
}
|
|
|
|
/// A format upgrade books wait for: from the format they are in to this
|
|
/// version's ([`current`]).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct FormatUpgrade {
|
|
/// The format the books are in.
|
|
pub from: JournalFormat,
|
|
/// The format the upgrade declares: this version's.
|
|
pub to: JournalFormat,
|
|
}
|
|
|
|
impl FormatUpgrade {
|
|
/// What the upgrade adds, in words: a newer format number, then each
|
|
/// feature not yet declared.
|
|
pub fn adds(&self) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
if self.to.format > self.from.format {
|
|
out.push(format!("journal format {}", self.to.format));
|
|
}
|
|
out.extend(
|
|
self.to
|
|
.features
|
|
.iter()
|
|
.filter(|f| !self.from.has_feature(f))
|
|
.map(|f| format!("the journal feature {f}")),
|
|
);
|
|
out
|
|
}
|
|
}
|
|
|
|
impl JournalFormat {
|
|
/// Whether the books declared the feature `feature`.
|
|
pub fn has_feature(&self, feature: &str) -> bool {
|
|
self.features.iter().any(|f| f == feature)
|
|
}
|
|
|
|
/// Refuse a write that needs journal format `format` (or a later one)
|
|
/// in books that haven't declared it: they wait for their format
|
|
/// upgrade (FormatUpgradePending; [`crate::Store`]'s writes ask this
|
|
/// before they journal anything).
|
|
pub fn require_format(&self, format: u32) -> Result<(), StoreError> {
|
|
if self.format >= format {
|
|
Ok(())
|
|
} else {
|
|
Err(StoreError::FormatUpgradePending {
|
|
need: format!("journal format {format}"),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Refuse a write that needs the feature `feature` in books that
|
|
/// haven't declared it ([`Self::require_format`]'s kin).
|
|
pub fn require_feature(&self, feature: &str) -> Result<(), StoreError> {
|
|
if self.has_feature(feature) {
|
|
Ok(())
|
|
} else {
|
|
Err(StoreError::FormatUpgradePending {
|
|
need: format!("the journal feature {feature}"),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The format upgrade books in this format wait for, if any (rules.md
|
|
/// B-27, plan/served.md 3.9, D-39).
|
|
///
|
|
/// ```text
|
|
/// GOAL: books an older version wrote are never moved to a newer
|
|
/// format, which that version can't open, without the owner's
|
|
/// go-ahead, and whether they wait for one is a fact of their
|
|
/// journal alone.
|
|
/// SOURCE: plan/served.md 3.9 ("Format changes are the server's gate,
|
|
/// not the updater's": a server that would write a newer
|
|
/// journal format than a book has opens it on its old format
|
|
/// until the owner consents); D-39 (asked at every open until
|
|
/// given).
|
|
/// INVARIANTS:
|
|
/// 1. Some exactly when this version writes a higher format number,
|
|
/// or knows a feature the books haven't declared.
|
|
/// 2. Its `to` is this version's format (current()): every feature
|
|
/// it knows, so a book's format moves exactly once per version.
|
|
/// SCENARIOS:
|
|
/// 1. IF the books declared nothing (format 1, written before the
|
|
/// format gate): SHOULD wait for the upgrade to format 2.
|
|
/// 2. IF they declared this version's format and every feature it
|
|
/// knows: SHOULD wait for none.
|
|
/// 3. IF they declared this format without a feature this version
|
|
/// knows: SHOULD wait for one that adds it.
|
|
/// ```
|
|
pub fn upgrade(&self) -> Option<FormatUpgrade> {
|
|
self.upgrade_to(current())
|
|
}
|
|
|
|
/// [`Self::upgrade`] for a version whose format is `to`.
|
|
fn upgrade_to(&self, to: JournalFormat) -> Option<FormatUpgrade> {
|
|
let behind = self.format < to.format || to.features.iter().any(|f| !self.has_feature(f));
|
|
behind.then(|| FormatUpgrade {
|
|
from: self.clone(),
|
|
to,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Read a `journal.format_set` line, refusing a format or feature this
|
|
/// version doesn't read.
|
|
pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError> {
|
|
declared_knowing(line, KNOWN_FEATURES)
|
|
}
|
|
|
|
/// [`declared`] by a version that knows the features `known`: what an
|
|
/// older version, which knew fewer, does with the same line.
|
|
fn declared_knowing(line: &JournalLine, known: &[&str]) -> Result<JournalFormat, StoreError> {
|
|
let p = &line.payload;
|
|
let format = p
|
|
.get("format")
|
|
.and_then(Value::as_u64)
|
|
.filter(|&f| f > 0)
|
|
.ok_or_else(|| {
|
|
StoreError::JournalCorrupt(format!("seq {}: {FORMAT_SET} format", line.seq))
|
|
})?;
|
|
// A newer format may shape the rest of its declaration differently:
|
|
// it is newer, whatever its features look like.
|
|
if format > u64::from(SUPPORTED_JOURNAL_FORMAT) {
|
|
return Err(newer(line, format!("journal format {format}")));
|
|
}
|
|
let format = u32::try_from(format).unwrap_or(SUPPORTED_JOURNAL_FORMAT);
|
|
let features = p
|
|
.get("features")
|
|
.and_then(Value::as_array)
|
|
.and_then(|a| {
|
|
a.iter()
|
|
.map(|f| f.as_str().map(str::to_string))
|
|
.collect::<Option<Vec<_>>>()
|
|
})
|
|
.ok_or_else(|| {
|
|
StoreError::JournalCorrupt(format!("seq {}: {FORMAT_SET} features", line.seq))
|
|
})?;
|
|
let unknown: Vec<&str> = features
|
|
.iter()
|
|
.map(String::as_str)
|
|
.filter(|f| !known.contains(f))
|
|
.collect();
|
|
if !unknown.is_empty() {
|
|
return Err(newer(
|
|
line,
|
|
format!("journal features {}", unknown.join(", ")),
|
|
));
|
|
}
|
|
Ok(JournalFormat { format, features })
|
|
}
|
|
|
|
/// The refusal for a line of a type this version knows that holds a
|
|
/// value only a newer version writes: a setting key, a convention kind
|
|
/// (`need` says which). Never "corrupt": the books are fine, this reader
|
|
/// is old (rules.md B-27).
|
|
pub(crate) fn newer_content(line: &JournalLine, need: String) -> StoreError {
|
|
newer(line, need)
|
|
}
|
|
|
|
/// The refusal for a line whose type this version doesn't know.
|
|
pub(crate) fn unknown_type(line: &JournalLine) -> StoreError {
|
|
newer(line, "an event type this version doesn't know".into())
|
|
}
|
|
|
|
/// The gate for one line as read: a line with a key beside the envelope's
|
|
/// is newer (rules.md B-27: this version never drops a key it can't
|
|
/// read), and any other goes to `admit` (JournalFormat::admit).
|
|
pub(crate) fn gate(
|
|
read: &ReadLine,
|
|
admit: &mut impl FnMut(&JournalLine) -> Result<(), StoreError>,
|
|
) -> Result<(), StoreError> {
|
|
if read.unknown_keys.is_empty() {
|
|
admit(&read.line)
|
|
} else {
|
|
Err(newer(
|
|
&read.line,
|
|
format!(
|
|
"journal line fields this version doesn't know ({})",
|
|
read.unknown_keys.join(", ")
|
|
),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// The gate's refusal, unverified: only the reader knows whether a later
|
|
/// line checks this one's hash ([`settle`]).
|
|
fn newer(line: &JournalLine, need: String) -> StoreError {
|
|
StoreError::NewerJournal {
|
|
seq: line.seq,
|
|
event_type: line.line_type.clone(),
|
|
need,
|
|
verified: false,
|
|
}
|
|
}
|
|
|
|
/// Settle the gate's refusal of a line whose hash is `hash`, given the raw
|
|
/// journal line after it (`next`, None when the refused line is the last).
|
|
///
|
|
/// ```text
|
|
/// GOAL: damage is never reported as books from a newer wanderbooks, and
|
|
/// books from a newer wanderbooks never as damage.
|
|
/// SOURCE: rules.md B-27 (the envelope and its hash are frozen across
|
|
/// formats); docs/receipt-store.md "Line format" (a line's hash is
|
|
/// checked only by the next line's prev).
|
|
/// INVARIANTS:
|
|
/// 1. Only a NewerJournal refusal is settled; anything else passes
|
|
/// through unchanged.
|
|
/// 2. Of the next line only `prev` is read, leniently (any JSON object
|
|
/// with a string `prev`): it is written in a format this version
|
|
/// may not read, with floats, a string seq or keys of its own.
|
|
/// SCENARIOS:
|
|
/// 1. IF the next line has a prev that isn't `hash`: SHOULD be
|
|
/// JournalCorrupt
|
|
/// BECAUSE the refused line changed after it was written (a flipped
|
|
/// bit in its type reads like a type this version doesn't know).
|
|
/// 2. IF the next line's prev is `hash`: SHOULD be NewerJournal,
|
|
/// verified: the refused line is intact (the lines after it are the
|
|
/// newer version's to check).
|
|
/// 3. IF there is no next line, or it has no prev this version can
|
|
/// read: SHOULD be NewerJournal, unverified
|
|
/// BECAUSE nothing checks the refused line's own bytes, so the
|
|
/// message says "newer or damaged", never "intact".
|
|
/// ```
|
|
pub(crate) fn settle(refusal: StoreError, hash: &str, next: Option<&[u8]>) -> StoreError {
|
|
let StoreError::NewerJournal {
|
|
seq,
|
|
event_type,
|
|
need,
|
|
..
|
|
} = refusal
|
|
else {
|
|
return refusal;
|
|
};
|
|
let prev = next
|
|
.and_then(|raw| serde_json::from_slice::<Value>(raw).ok())
|
|
.and_then(|v| v.get("prev").and_then(Value::as_str).map(str::to_string));
|
|
match prev {
|
|
Some(prev) if prev != hash => {
|
|
StoreError::JournalCorrupt(format!("hash chain broken at seq {}", seq + 1))
|
|
}
|
|
found => StoreError::NewerJournal {
|
|
seq,
|
|
event_type,
|
|
need,
|
|
verified: found.is_some(),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::store::{Store, apply_line_to_sqlite, replay_line_into_ledger};
|
|
use ledger_core::{Currency, Ledger};
|
|
|
|
fn line(line_type: &str, payload: Value) -> JournalLine {
|
|
JournalLine {
|
|
seq: 7,
|
|
ts: "2026-10-16T00:00:00Z".into(),
|
|
line_type: line_type.into(),
|
|
payload,
|
|
prev: String::new(),
|
|
}
|
|
}
|
|
|
|
fn is_newer(r: &Result<(), StoreError>) -> bool {
|
|
matches!(r, Err(StoreError::NewerJournal { .. }))
|
|
}
|
|
|
|
/// The list and the code agree: every type the gate admits has a
|
|
/// replay arm and a projection arm (a known type with an empty payload
|
|
/// fails as corrupt, never as newer).
|
|
#[test]
|
|
fn every_known_event_type_has_replay_and_projection_arms() {
|
|
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
|
Store::migrate(&conn).unwrap();
|
|
for &t in EVENT_TYPES {
|
|
let l = line(t, json!({}));
|
|
let mut ledger = Ledger::new(Currency::USD);
|
|
let replayed = replay_line_into_ledger(&mut ledger, &l, Currency::USD);
|
|
assert!(!is_newer(&replayed), "{t}: no replay arm");
|
|
assert!(
|
|
!is_newer(&apply_line_to_sqlite(&conn, &l)),
|
|
"{t}: no projection arm"
|
|
);
|
|
}
|
|
// A type a later phase adds (plan/served.md 5.2, P8).
|
|
let l = line("period.attested", json!({}));
|
|
let mut ledger = Ledger::new(Currency::USD);
|
|
assert!(is_newer(&replay_line_into_ledger(
|
|
&mut ledger,
|
|
&l,
|
|
Currency::USD
|
|
)));
|
|
assert!(is_newer(&apply_line_to_sqlite(&conn, &l)));
|
|
}
|
|
|
|
/// A declaration never lowers the format in force, and never names a
|
|
/// format or feature this version can't read back.
|
|
#[test]
|
|
fn a_declared_format_is_never_lowered_or_unreadable() {
|
|
let at1 = JournalFormat::default();
|
|
assert_eq!(
|
|
at1.declaration(1, &[]).unwrap(),
|
|
json!({"format": 1, "features": []})
|
|
);
|
|
for (format, features) in [(0, &[][..]), (3, &[][..]), (1, &["x"][..])] {
|
|
assert!(
|
|
matches!(
|
|
at1.declaration(format, features),
|
|
Err(StoreError::FormatRefused(_))
|
|
),
|
|
"{format} {features:?}"
|
|
);
|
|
}
|
|
let at2 = JournalFormat {
|
|
format: 2,
|
|
features: Vec::new(),
|
|
};
|
|
assert!(matches!(
|
|
at2.declaration(1, &[]),
|
|
Err(StoreError::FormatRefused(m)) if m.contains("never lowered")
|
|
));
|
|
}
|
|
|
|
/// What the gate records, and what it refuses, line by line.
|
|
#[test]
|
|
fn the_gate_records_a_readable_format_and_refuses_a_newer_one() {
|
|
let mut f = JournalFormat::default();
|
|
f.admit(&line(FORMAT_SET, json!({"format": 1, "features": []})))
|
|
.unwrap();
|
|
assert_eq!(f, JournalFormat::default());
|
|
f.admit(&line("entry.posted", json!({}))).unwrap();
|
|
f.admit(&line(FORMAT_SET, json!({"format": 2, "features": []})))
|
|
.unwrap();
|
|
assert_eq!(f.format, 2, "format 2 is this version's");
|
|
match f.admit(&line(FORMAT_SET, json!({"format": 3, "features": []}))) {
|
|
Err(StoreError::NewerJournal {
|
|
seq,
|
|
event_type,
|
|
need,
|
|
verified,
|
|
}) => {
|
|
// The gate alone never vouches for the line's bytes.
|
|
assert!(!verified);
|
|
assert_eq!((seq, event_type.as_str()), (7, FORMAT_SET));
|
|
assert_eq!(need, "journal format 3");
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
match f.admit(&line(
|
|
FORMAT_SET,
|
|
json!({"format": 1, "features": ["a", "b"]}),
|
|
)) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features a, b")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
// A refused line leaves the format in force alone.
|
|
assert_eq!(f.format, 2);
|
|
for bad in [
|
|
json!({"features": []}),
|
|
json!({"format": 0, "features": []}),
|
|
json!({"format": "1", "features": []}),
|
|
json!({"format": 1}),
|
|
json!({"format": 1, "features": [1]}),
|
|
json!({"format": 1, "features": "a"}),
|
|
] {
|
|
assert!(
|
|
matches!(
|
|
f.admit(&line(FORMAT_SET, bad.clone())),
|
|
Err(StoreError::JournalCorrupt(_))
|
|
),
|
|
"{bad}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// P3's features (schedule_convention, month_reposting): this version
|
|
/// reads them; a version before them (KNOWN_FEATURES empty, as through
|
|
/// P2) stops at their declaration with NewerJournal, never misreading a
|
|
/// daily schedule as equal months or a second run of a month as none.
|
|
#[test]
|
|
fn an_older_reader_stops_at_p3s_features() {
|
|
for f in [SCHEDULE_CONVENTION, MONTH_REPOSTING] {
|
|
assert!(KNOWN_FEATURES.contains(&f));
|
|
let l = line(FORMAT_SET, json!({"format": 2, "features": [f]}));
|
|
assert_eq!(
|
|
declared(&l).unwrap(),
|
|
JournalFormat {
|
|
format: 2,
|
|
features: vec![f.to_string()]
|
|
}
|
|
);
|
|
match declared_knowing(&l, &[]) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, format!("journal features {f}"))
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
}
|
|
// Declared together, sorted.
|
|
assert_eq!(
|
|
JournalFormat::default()
|
|
.declaration(2, &[SCHEDULE_CONVENTION, MONTH_REPOSTING])
|
|
.unwrap(),
|
|
json!({"format": 2, "features": ["month_reposting", "schedule_convention"]})
|
|
);
|
|
}
|
|
|
|
/// The version the live books run (every feature through `projects`)
|
|
/// stops at a declaration of `document_line_projects` as newer, never
|
|
/// restoring an invoice or bill without its lines' projects; and books
|
|
/// that declared `projects` alone still wait for it (B-27, D-39).
|
|
#[test]
|
|
fn document_line_projects_is_its_own_feature() {
|
|
assert!(KNOWN_FEATURES.contains(&DOCUMENT_LINE_PROJECTS));
|
|
assert_ne!(DOCUMENT_LINE_PROJECTS, PROJECTS);
|
|
let before: Vec<&str> = KNOWN_FEATURES
|
|
.iter()
|
|
.copied()
|
|
.filter(|f| *f != DOCUMENT_LINE_PROJECTS)
|
|
.collect();
|
|
let l = line(
|
|
FORMAT_SET,
|
|
json!({"format": 2, "features": [PROJECTS, DOCUMENT_LINE_PROJECTS]}),
|
|
);
|
|
match declared_knowing(&l, &before) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features document_line_projects")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
let with_projects = JournalFormat {
|
|
format: 2,
|
|
features: before.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
assert!(matches!(
|
|
with_projects.require_feature(DOCUMENT_LINE_PROJECTS),
|
|
Err(StoreError::FormatUpgradePending { .. })
|
|
));
|
|
let up = with_projects.upgrade().expect("waits for it");
|
|
assert_eq!(
|
|
up.adds(),
|
|
vec!["the journal feature document_line_projects"]
|
|
);
|
|
}
|
|
|
|
/// The version before P4b S1 (every feature through
|
|
/// `document_line_projects`) stops at a declaration of
|
|
/// `document_standing` as newer, never adding a payment taken after a
|
|
/// reversal on top of the reversed one; and books that declared every
|
|
/// earlier feature still wait for it (B-27, D-39).
|
|
#[test]
|
|
fn document_standing_is_its_own_feature() {
|
|
assert!(KNOWN_FEATURES.contains(&DOCUMENT_STANDING));
|
|
let before: Vec<&str> = KNOWN_FEATURES
|
|
.iter()
|
|
.copied()
|
|
.filter(|f| *f != DOCUMENT_STANDING)
|
|
.collect();
|
|
let l = line(
|
|
FORMAT_SET,
|
|
json!({"format": 2, "features": [DOCUMENT_LINE_PROJECTS, DOCUMENT_STANDING]}),
|
|
);
|
|
match declared_knowing(&l, &before) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features document_standing")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
let older = JournalFormat {
|
|
format: 2,
|
|
features: before.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
assert!(matches!(
|
|
older.require_feature(DOCUMENT_STANDING),
|
|
Err(StoreError::FormatUpgradePending { .. })
|
|
));
|
|
let up = older.upgrade().expect("waits for it");
|
|
assert_eq!(up.adds(), vec!["the journal feature document_standing"]);
|
|
}
|
|
|
|
/// The version before P6 (every feature through `documents_v2`) stops
|
|
/// at a declaration of `attachment_text` as newer, never dropping a
|
|
/// file's extraction, annotations or settlements; and books that
|
|
/// declared every earlier feature still wait for it (B-27, D-39).
|
|
#[test]
|
|
fn attachment_text_is_its_own_feature() {
|
|
assert!(KNOWN_FEATURES.contains(&ATTACHMENT_TEXT));
|
|
let before: Vec<&str> = KNOWN_FEATURES
|
|
.iter()
|
|
.copied()
|
|
.filter(|f| *f != ATTACHMENT_TEXT)
|
|
.collect();
|
|
let l = line(
|
|
FORMAT_SET,
|
|
json!({"format": 2, "features": [DOCUMENTS_V2, ATTACHMENT_TEXT]}),
|
|
);
|
|
match declared_knowing(&l, &before) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features attachment_text")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
let older = JournalFormat {
|
|
format: 2,
|
|
features: before.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
assert!(matches!(
|
|
older.require_feature(ATTACHMENT_TEXT),
|
|
Err(StoreError::FormatUpgradePending { .. })
|
|
));
|
|
let up = older.upgrade().expect("waits for it");
|
|
assert_eq!(up.adds(), vec!["the journal feature attachment_text"]);
|
|
}
|
|
|
|
/// The version before P4b S4.2 (every feature through
|
|
/// `document_standing`) stops at a declaration of `documents_v2` as
|
|
/// newer, never restoring a party's email or default terms as absent
|
|
/// (or a document without its numbers, once S4.3 writes them); books
|
|
/// that declared every earlier feature still wait for it (B-27, D-39).
|
|
#[test]
|
|
fn documents_v2_is_its_own_feature() {
|
|
assert!(KNOWN_FEATURES.contains(&DOCUMENTS_V2));
|
|
let before: Vec<&str> = KNOWN_FEATURES
|
|
.iter()
|
|
.copied()
|
|
.filter(|f| *f != DOCUMENTS_V2)
|
|
.collect();
|
|
let l = line(
|
|
FORMAT_SET,
|
|
json!({"format": 2, "features": [DOCUMENT_STANDING, DOCUMENTS_V2]}),
|
|
);
|
|
match declared_knowing(&l, &before) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features documents_v2")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
let older = JournalFormat {
|
|
format: 2,
|
|
features: before.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
assert!(matches!(
|
|
older.require_feature(DOCUMENTS_V2),
|
|
Err(StoreError::FormatUpgradePending { .. })
|
|
));
|
|
let up = older.upgrade().expect("waits for it");
|
|
assert_eq!(up.adds(), vec!["the journal feature documents_v2"]);
|
|
}
|
|
|
|
/// The version before P4 (every feature through `attachment_text`)
|
|
/// stops at a declaration of `drafts_v2` as newer, never keeping a
|
|
/// discarded draft or calling a waiting one a draft being built; and
|
|
/// books that declared every earlier feature still wait for it
|
|
/// (B-27, D-39).
|
|
#[test]
|
|
fn drafts_v2_is_its_own_feature() {
|
|
assert!(KNOWN_FEATURES.contains(&DRAFTS_V2));
|
|
let before: Vec<&str> = KNOWN_FEATURES
|
|
.iter()
|
|
.copied()
|
|
.filter(|f| *f != DRAFTS_V2)
|
|
.collect();
|
|
let l = line(
|
|
FORMAT_SET,
|
|
json!({"format": 2, "features": [ATTACHMENT_TEXT, DRAFTS_V2]}),
|
|
);
|
|
match declared_knowing(&l, &before) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features drafts_v2")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
let older = JournalFormat {
|
|
format: 2,
|
|
features: before.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
assert!(matches!(
|
|
older.require_feature(DRAFTS_V2),
|
|
Err(StoreError::FormatUpgradePending { .. })
|
|
));
|
|
let up = older.upgrade().expect("waits for it");
|
|
assert_eq!(up.adds(), vec!["the journal feature drafts_v2"]);
|
|
}
|
|
|
|
/// This version's format: the newest it reads, every feature it
|
|
/// knows, each once, sorted (what new books and an upgrade declare).
|
|
#[test]
|
|
fn this_versions_format_is_the_newest_with_every_known_feature() {
|
|
let c = current();
|
|
assert_eq!(c.format, SUPPORTED_JOURNAL_FORMAT);
|
|
let mut known: Vec<String> = KNOWN_FEATURES.iter().map(|f| f.to_string()).collect();
|
|
known.sort();
|
|
known.dedup();
|
|
assert_eq!(c.features, known);
|
|
// What it declares reads back as itself.
|
|
let features: Vec<&str> = c.features.iter().map(String::as_str).collect();
|
|
let payload = JournalFormat::default()
|
|
.declaration(c.format, &features)
|
|
.unwrap();
|
|
assert_eq!(declared(&line(FORMAT_SET, payload)).unwrap(), c);
|
|
assert_eq!(c.upgrade(), None, "this version's books wait for nothing");
|
|
}
|
|
|
|
/// Books wait for an upgrade exactly when this version writes a higher
|
|
/// format number or knows a feature they haven't declared; the upgrade
|
|
/// is always to this version's whole format (SCENARIOS 1 to 3).
|
|
#[test]
|
|
fn books_wait_for_an_upgrade_exactly_when_behind() {
|
|
let at = |format: u32, features: &[&str]| JournalFormat {
|
|
format,
|
|
features: features.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
// Format 1 (nothing declared): waits for this version's format.
|
|
let old = JournalFormat::default();
|
|
let up = old.upgrade().expect("format 1 books wait for format 2");
|
|
assert_eq!((up.from.clone(), up.to.clone()), (old, current()));
|
|
assert_eq!(
|
|
up.adds()[0],
|
|
format!("journal format {SUPPORTED_JOURNAL_FORMAT}")
|
|
);
|
|
// A version that knows features: each one missing makes books wait,
|
|
// and the upgrade adds all of them at once.
|
|
let to = at(2, &["a", "b"]);
|
|
for (books, adds) in [
|
|
(
|
|
at(1, &[]),
|
|
vec![
|
|
"journal format 2",
|
|
"the journal feature a",
|
|
"the journal feature b",
|
|
],
|
|
),
|
|
(
|
|
at(2, &[]),
|
|
vec!["the journal feature a", "the journal feature b"],
|
|
),
|
|
(at(2, &["b"]), vec!["the journal feature a"]),
|
|
(at(2, &["a"]), vec!["the journal feature b"]),
|
|
] {
|
|
let up = books.upgrade_to(to.clone()).expect("behind");
|
|
assert_eq!(up.to, to, "the whole format, at once");
|
|
assert_eq!(up.adds(), adds, "{books:?}");
|
|
}
|
|
assert_eq!(at(2, &["a", "b"]).upgrade_to(to.clone()), None);
|
|
assert_eq!(
|
|
at(2, &["b", "a"]).upgrade_to(to.clone()),
|
|
None,
|
|
"in any order"
|
|
);
|
|
// Nothing ahead of this version counts as behind (it never opens).
|
|
assert_eq!(at(3, &["a", "b"]).upgrade_to(to), None);
|
|
}
|
|
|
|
/// A write that needs a format or feature the books haven't declared
|
|
/// is refused as waiting for the upgrade, saying what it needs; one
|
|
/// the books declared passes.
|
|
#[test]
|
|
fn a_write_needs_what_the_books_declared() {
|
|
let at = |format: u32, features: &[&str]| JournalFormat {
|
|
format,
|
|
features: features.iter().map(|f| f.to_string()).collect(),
|
|
};
|
|
let need = |r: Result<(), StoreError>| match r {
|
|
Err(StoreError::FormatUpgradePending { need }) => need,
|
|
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
|
};
|
|
assert_eq!(need(at(1, &[]).require_format(2)), "journal format 2");
|
|
at(2, &[]).require_format(2).unwrap();
|
|
at(2, &[]).require_format(1).unwrap();
|
|
assert_eq!(
|
|
need(at(2, &["a"]).require_feature("b")),
|
|
"the journal feature b"
|
|
);
|
|
at(2, &["a", "b"]).require_feature("b").unwrap();
|
|
// The refusal names the guard, says nothing changed, and that only
|
|
// the owner can agree, and how.
|
|
let text = StoreError::FormatUpgradePending {
|
|
need: "journal format 2".into(),
|
|
}
|
|
.to_string();
|
|
for says in [
|
|
"format_upgrade_pending:",
|
|
"needs journal format 2",
|
|
"Nothing was changed",
|
|
"Only the owner can give it",
|
|
"owner passphrase",
|
|
"wanderbooks books upgrade",
|
|
"no tool can",
|
|
] {
|
|
assert!(text.contains(says), "{says}: {text}");
|
|
}
|
|
}
|
|
|
|
/// An upgrade never weakens the gate: a version that knows fewer
|
|
/// features than the books declared still stops at the declaration,
|
|
/// as newer, and one that knows them reads it.
|
|
#[test]
|
|
fn a_reader_that_doesnt_know_a_declared_feature_still_refuses_it_as_newer() {
|
|
let l = line(FORMAT_SET, json!({"format": 2, "features": ["a"]}));
|
|
match declared_knowing(&l, &[]) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features a")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
assert_eq!(
|
|
declared_knowing(&l, &["a"]).unwrap(),
|
|
JournalFormat {
|
|
format: 2,
|
|
features: vec!["a".into()]
|
|
}
|
|
);
|
|
}
|
|
|
|
/// A format newer than this version reads is newer whatever shape the
|
|
/// rest of its declaration takes, and however large its number: a
|
|
/// newer format may define its features differently, so they are
|
|
/// judged only for a format this version reads (rules.md B-27).
|
|
#[test]
|
|
fn a_newer_format_is_newer_whatever_its_features_look_like() {
|
|
let mut f = JournalFormat::default();
|
|
for newer in [
|
|
json!({"format": 3, "features": {"actors": true}}),
|
|
json!({"format": 3, "features": [1, 2]}),
|
|
json!({"format": 3}),
|
|
json!({"format": 4_294_967_296_u64, "features": []}),
|
|
] {
|
|
assert!(
|
|
is_newer(&f.admit(&line(FORMAT_SET, newer.clone()))),
|
|
"{newer}"
|
|
);
|
|
}
|
|
assert_eq!(f, JournalFormat::default());
|
|
// The same shapes under a format this version reads are damage.
|
|
for bad in [
|
|
json!({"format": 1, "features": {"actors": true}}),
|
|
json!({"format": 1, "features": [1, 2]}),
|
|
] {
|
|
assert!(matches!(
|
|
f.admit(&line(FORMAT_SET, bad)),
|
|
Err(StoreError::JournalCorrupt(_))
|
|
));
|
|
}
|
|
}
|
|
|
|
/// P3 defect 17's feature (members_cumulative): this version reads it;
|
|
/// a version before it (KNOWN_FEATURES empty, as through P2) stops at
|
|
/// its declaration with NewerJournal, never replaying a close counted
|
|
/// from its regime's start under the run rule.
|
|
#[test]
|
|
fn an_older_reader_stops_at_the_members_cumulative_feature() {
|
|
assert!(KNOWN_FEATURES.contains(&MEMBERS_CUMULATIVE));
|
|
let l = line(
|
|
FORMAT_SET,
|
|
json!({"format": 2, "features": [MEMBERS_CUMULATIVE]}),
|
|
);
|
|
assert_eq!(
|
|
declared(&l).unwrap(),
|
|
JournalFormat {
|
|
format: 2,
|
|
features: vec![MEMBERS_CUMULATIVE.to_string()]
|
|
}
|
|
);
|
|
match declared_knowing(&l, &[]) {
|
|
Err(StoreError::NewerJournal { need, .. }) => {
|
|
assert_eq!(need, "journal features members_cumulative")
|
|
}
|
|
other => panic!("expected NewerJournal, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The setting keys and tax categories P2 (9c99a0cf, the last version
|
|
/// before P3) read: a `settings.set` or `account.tax_set` naming any
|
|
/// other makes P2 refuse the books, so each other needs a feature
|
|
/// (P3 review, 2026-10-21: `books.schedule_convention` and two tax
|
|
/// categories didn't have one).
|
|
const P2_SETTING_KEYS: &[&str] = &[
|
|
"company.name",
|
|
"company.entity_type",
|
|
"company.tax_classification",
|
|
"company.tax_form",
|
|
"company.fiscal_year_start",
|
|
"company.timezone",
|
|
"company.formation_state",
|
|
"company.formed",
|
|
"company.state_entity_number",
|
|
"company.ein",
|
|
"company.irs_name_control",
|
|
"company.duns",
|
|
"company.address",
|
|
"company.registered_agent",
|
|
"company.partnership_representative",
|
|
"company.cpa",
|
|
"company.members",
|
|
"books.accounting_basis",
|
|
"books.cash_accounts",
|
|
"books.financing_accounts",
|
|
"books.investing_accounts",
|
|
"books.close_into",
|
|
"books.close_sweep",
|
|
"books.asset_tag_prefix",
|
|
"policy.evidence.threshold",
|
|
"policy.evidence.required_accounts",
|
|
"policy.evidence.exempt_accounts",
|
|
"policy.parties",
|
|
"policy.approval_rules",
|
|
"backup.git_remote",
|
|
"backup.folder",
|
|
];
|
|
const P2_TAX_CATEGORIES: &[&str] = &[
|
|
"gross_receipts",
|
|
"returns_allowances",
|
|
"interest_income",
|
|
"other_income",
|
|
"cost_of_goods_sold",
|
|
"advertising",
|
|
"bad_debts",
|
|
"car_truck",
|
|
"charitable_contributions",
|
|
"commissions_fees",
|
|
"contract_labor",
|
|
"depreciation",
|
|
"employee_benefits",
|
|
"guaranteed_payments",
|
|
"insurance",
|
|
"interest_expense",
|
|
"legal_professional",
|
|
"meals",
|
|
"office_expense",
|
|
"officer_compensation",
|
|
"organization_startup",
|
|
"pension_profit_sharing",
|
|
"rent_equipment",
|
|
"rent_property",
|
|
"repairs",
|
|
"salaries_wages",
|
|
"supplies",
|
|
"taxes_licenses",
|
|
"travel",
|
|
"utilities",
|
|
"other_deductions",
|
|
"nondeductible",
|
|
];
|
|
|
|
#[test]
|
|
fn every_setting_key_and_tax_category_p2_didnt_read_needs_a_feature() {
|
|
for k in ledger_core::settings::KEYS {
|
|
let known = P2_SETTING_KEYS.contains(&k.name);
|
|
assert_eq!(
|
|
setting_feature(k.name).is_none(),
|
|
known,
|
|
"{}: P2 {} it",
|
|
k.name,
|
|
if known { "reads" } else { "refuses" }
|
|
);
|
|
}
|
|
for c in ledger_core::tax::CATEGORIES {
|
|
let known = P2_TAX_CATEGORIES.contains(&c.key);
|
|
assert_eq!(tax_category_feature(c.key).is_none(), known, "{}", c.key);
|
|
}
|
|
for f in [
|
|
setting_feature("books.schedule_convention"),
|
|
tax_category_feature("tax_exempt_income"),
|
|
] {
|
|
assert!(KNOWN_FEATURES.contains(&f.unwrap()));
|
|
}
|
|
}
|
|
}
|