From 6b7dccd1eebc1141b99494ee01608dfb0e20511f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 15:38:53 -0600 Subject: [PATCH] feat(audit): audit runs and attestations are journaled (P8) A claim that someone checked the books is evidence the CPA handoff rests on, so it is a journal fact and replays: audit.run (a programme run through a day at the books' figure head, with the hashes that make it reproducible and the two filed workpapers), receipt.attested, entry.reviewed and period.attested. Behind the journal feature audits, schema 20; no figure moves. The figure head is the last line that bears on a figure: filing a workpaper, a run or an attestation never makes an audit stale, an entry, document, close, settings or role change does, so a period is attested only on an audit that still describes the books. A receipt reading that differs from the entry is recorded as disagreeing and never counts; whether a claim counts is derived against the bar when asked, never stored. The store also reads what a ledger cannot: the chain, the database against a replay, stored and extracted files, the access log, duplicate files, and the backups' standing handed in by the server. Fixture old-books/p8_before was written by the version before this one and opens to its figures. period.attested was the tests' placeholder for an unknown event type; they use ledger.not_yet_written now. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-gui/src/pages/journal.rs | 49 ++ crates/ledger-gui/tests/newer_journal.rs | 6 +- crates/ledger-store/src/audit.rs | 795 ++++++++++++++++++ crates/ledger-store/src/error.rs | 6 + crates/ledger-store/src/format.rs | 52 ++ crates/ledger-store/src/lib.rs | 1 + crates/ledger-store/src/meta.rs | 192 +++++ crates/ledger-store/src/schema.rs | 38 +- crates/ledger-store/src/store.rs | 34 + crates/ledger-store/tests/audit.rs | 612 ++++++++++++++ .../old-books-writer/src/bin/p8_before.rs | 85 ++ ...279a6ced262a8670bd5d7063a0668469398ac6523e | 1 + ...ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 | 1 + ...f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f | 1 + .../old-books/p8_before/expected.json | 461 ++++++++++ .../old-books/p8_before/journal.jsonl | 52 ++ crates/ledger-store/tests/format.rs | 65 +- crates/ledger-store/tests/store.rs | 42 + fuzz/fuzz_targets/fuzz_store_replay.rs | 4 + 19 files changed, 2479 insertions(+), 18 deletions(-) create mode 100644 crates/ledger-store/src/audit.rs create mode 100644 crates/ledger-store/tests/audit.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e create mode 100644 crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 create mode 100644 crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f create mode 100644 crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index c62d40d..b75865d 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -449,6 +449,55 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Draft #{id} discarded: {}", s(p, "reason")), None, ), + // Audits and attestations (plan P8): claims about the books, not + // figures. + "audit.run" => ev( + icon::MAGNIFYING_GLASS, + format!( + "Audit {} through {}", + s(p, "scope").replace('_', " "), + s(p, "through") + ), + None, + ), + "receipt.attested" => ev( + icon::CHECK_CIRCLE, + if p.get("disagrees").and_then(|v| v.as_bool()) == Some(true) { + format!( + "{} read {} against entry #{}: it disagrees", + s(p, "by"), + attachment_name(b, att), + p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0) + ) + } else { + format!( + "{} attested {} for entry #{}", + s(p, "by"), + attachment_name(b, att), + p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0) + ) + }, + Some(Route::Document(att)), + ), + "entry.reviewed" => ev( + icon::CHECK_CIRCLE, + format!( + "{} reviewed entry #{}", + s(p, "by"), + p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0) + ), + p.get("entry_id").and_then(|v| v.as_u64()).map(Route::Entry), + ), + "period.attested" => ev( + icon::CHECK_CIRCLE, + format!( + "{} attested period #{} on audit #{}", + s(p, "by"), + p.get("period_id").and_then(|v| v.as_u64()).unwrap_or(0), + p.get("audit_run_id").and_then(|v| v.as_u64()).unwrap_or(0) + ), + None, + ), "entry.settles" => { let settles: Vec = p .get("settles") diff --git a/crates/ledger-gui/tests/newer_journal.rs b/crates/ledger-gui/tests/newer_journal.rs index 5b8f95d..905b335 100644 --- a/crates/ledger-gui/tests/newer_journal.rs +++ b/crates/ledger-gui/tests/newer_journal.rs @@ -88,7 +88,11 @@ fn says_update(text: &str) { #[test] fn the_viewer_says_update_for_newer_books_and_corrupt_for_damaged_ones() { for (tag, line_type, payload) in [ - ("type", "period.attested", json!({"key": "company.name"})), + ( + "type", + "ledger.not_yet_written", + json!({"key": "company.name"}), + ), ( "format", "journal.format_set", diff --git a/crates/ledger-store/src/audit.rs b/crates/ledger-store/src/audit.rs new file mode 100644 index 0000000..d446070 --- /dev/null +++ b/crates/ledger-store/src/audit.rs @@ -0,0 +1,795 @@ +//! The audit's journal and the integrity checks (plan P8; rules.md B-45): +//! `audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`, +//! what "the current head" means, and the findings only the store can see. +//! +//! ```text +//! GOAL: an audit run is a journaled fact keyed by the books' figure +//! head, so the same books give the same run once, and an +//! attestation rests on a head that has not moved. +//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 ยง5.01 (the audit +//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53 +//! (an audit is a run, not a job), IZ-1k (the head). +//! INVARIANTS: +//! 1. The figure head is the seq of the last journal line that bears on +//! a figure: everything but the audit and attestation events, the +//! evidence filings (an attachment, a link, a statement, a +//! reconciliation, extraction, annotations) and the format +//! declaration. An audit's own filing never moves it. +//! 2. A run is idempotent on (through, figure head, scope, program +//! version): asking again returns the run that exists and journals +//! nothing. +//! 3. A period is attested only on a run at the current figure head +//! that covers it (its through date is on or after the period's +//! end). +//! 4. A receipt attestation whose amount or date differs from the +//! entry's is recorded, marked `disagrees`, and never counts. +//! 5. `counted` is never stored: it is read against +//! `policy.attestation_bar` as it stands when asked. +//! 6. The method is the client's own claim; the books keep it and say +//! so. +//! 7. Every write waits for the `audits` journal feature and journals +//! nothing when refused. +//! SCENARIOS: +//! 1. IF the same audit is asked twice at one head: SHOULD return the +//! first run both times. +//! 2. IF an entry posts after a run: SHOULD refuse attesting a period +//! on that run. +//! ``` + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use ledger_core::audit::{Finding, Method, Scope, Severity, Subject}; +use rusqlite::params; +use rust_decimal::Decimal; +use serde_json::Value; + +use super::{Store, apply_line_to_sqlite}; +use crate::error::StoreError; + +/// The audit programme's version: the crate's, and a revision of its +/// checks. A run at another version is another run. +pub const PROGRAM_VERSION: &str = concat!("audit-", env!("CARGO_PKG_VERSION"), "-1"); + +/// Journal lines that do not bear on a figure (INVARIANT 1). +pub const NOT_FIGURES: &[&str] = &[ + "audit.run", + "receipt.attested", + "entry.reviewed", + "period.attested", + "store.created", + "journal.format_set", + "journal.tail_repaired", + "attachment.linked", + "attachment.unlinked", + "attachment.withdrawn", + "attachment.text_extracted", + "attachment.annotated", + "receipt.attached", + "receipt.linked", + "receipt.unlinked", + "document.filed", + "statement.recorded", + "statement.voided", + "reconciliation.completed", + "reconciliation.voided", + "decision.recorded", + "open_item.raised", + "open_item.resolved", + "entry.settles", +]; + +/// The integrity checks' codes, severities and predicates (the +/// statements checks are `ledger_core::audit::CODES`). +pub const CODES: &[ledger_core::audit::CodeRow] = &[ + ledger_core::audit::CodeRow { + code: "journal_damaged", + severity: Severity::Failure, + predicate: "the journal's hash chain does not verify", + }, + ledger_core::audit::CodeRow { + code: "projection_differs", + severity: Severity::Failure, + predicate: "the live books or the database differ from a replay of the journal", + }, + ledger_core::audit::CodeRow { + code: "blob_missing", + severity: Severity::Failure, + predicate: "a stored file the books name is not on disk", + }, + ledger_core::audit::CodeRow { + code: "blob_damaged", + severity: Severity::Failure, + predicate: "a stored file's bytes do not match their SHA-256", + }, + ledger_core::audit::CodeRow { + code: "access_log_damaged", + severity: Severity::Failure, + predicate: "the access log's chain does not verify", + }, + ledger_core::audit::CodeRow { + code: "extraction_blob_missing", + severity: Severity::Warning, + predicate: "a file's extracted text is not on disk", + }, + ledger_core::audit::CodeRow { + code: "duplicate_blob", + severity: Severity::Warning, + predicate: "two filed attachments, neither withdrawn, hold the same bytes", + }, + ledger_core::audit::CodeRow { + code: "backup_stale", + severity: Severity::Warning, + predicate: "a backup target has not reached the books' head within a day", + }, + ledger_core::audit::CodeRow { + code: "drill_failed", + severity: Severity::Failure, + predicate: "a backup failed, or failed its restore drill", + }, +]; + +fn found(code: &'static str, subject: Subject, message: String) -> Finding { + let severity = CODES + .iter() + .find(|r| r.code == code) + .map_or(Severity::Failure, |r| r.severity); + Finding { + code, + severity, + subject, + message, + } +} + +/// What the backups' standing says, one target at a time, for the audit +/// (the server reads the standing; the store does not know the backups). +#[derive(Clone, Debug)] +pub struct BackupStanding { + /// The target's kind and name, as the backups name it. + pub target: String, + /// It has not reached the head within a day. + pub stale: bool, + /// Its last run's error, if it failed. + pub last_error: Option, + /// Its last restore drill's error, if it failed. + pub drill_error: Option, +} + +/// The backup findings: a target stale, failing, or failing its drill. +/// Books with no target set up have none (that is the owner's standing +/// choice, which `books.start` already says; an audit reports what the +/// backups it can see do). +pub fn backup_findings(targets: &[BackupStanding]) -> Vec { + let mut out = Vec::new(); + for t in targets { + if t.stale { + out.push(found( + "backup_stale", + Subject::Named(t.target.clone()), + format!("{} has not reached the books' head within a day", t.target), + )); + } + if let Some(e) = &t.last_error { + out.push(found( + "drill_failed", + Subject::Named(t.target.clone()), + format!("{}'s last backup failed: {e}", t.target), + )); + } + if let Some(e) = &t.drill_error { + out.push(found( + "drill_failed", + Subject::Named(t.target.clone()), + format!("{}'s restore drill failed: {e}", t.target), + )); + } + } + out +} + +/// An audit run as the journal records it (`audit.run`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuditRun { + /// Its id (1 for the first). + pub id: u64, + /// The audit covers the books through this date. + pub through: NaiveDate, + /// The figure head it was made at ([`Store::figure_head`]). + pub through_seq: u64, + /// What it covers. + pub scope: Scope, + /// The programme's version ([`PROGRAM_VERSION`]). + pub program_version: String, + /// SHA-256 of the trial balance through the date. + pub tb_hash: String, + /// SHA-256 of the findings. + pub findings_sha256: String, + /// The workpaper's JSON, filed as a document. + pub workpaper_attachment: Option, + /// The workpaper's PDF, filed as a document. + pub workpaper_pdf: Option, + /// The journal line that recorded it. + pub seq: u64, +} + +/// What a run is made from, before it has an id or a seq. +#[derive(Clone, Debug)] +pub struct NewAuditRun { + /// Through this date. + pub through: NaiveDate, + /// The figure head the findings were computed at. + pub through_seq: u64, + /// What it covers. + pub scope: Scope, + /// SHA-256 of the trial balance. + pub tb_hash: String, + /// SHA-256 of the findings. + pub findings_sha256: String, + /// The filed workpaper JSON. + pub workpaper_attachment: Option, + /// The filed workpaper PDF. + pub workpaper_pdf: Option, +} + +/// What an attestation says it checked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Attested { + /// A receipt against an entry. + Receipt { + /// The filed receipt. + attachment_id: u64, + /// The entry it supports. + entry_id: u64, + /// The amount the checker read off it. + amount_checked: Decimal, + /// The date the checker read off it. + date_checked: NaiveDate, + /// The checker's reading differs from the entry's. + disagrees: bool, + }, + /// An entry, reviewed whole. + Entry { + /// The entry. + entry_id: u64, + }, + /// A period, on an audit run. + Period { + /// The period. + period_id: u64, + /// The run it rests on. + audit_run_id: u64, + }, +} + +/// A claim that someone checked, as the journal records it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Attestation { + /// The journal line. + pub seq: u64, + /// Who claims it: the caller's token name. + pub by: String, + /// When, UTC. + pub at: String, + /// How, as the client says. + pub method: Method, + /// A free note. + pub note: Option, + /// What was checked. + pub what: Attested, +} + +impl Attestation { + /// Its kind's word: `receipt`, `entry` or `period`. + pub fn kind(&self) -> &'static str { + match self.what { + Attested::Receipt { .. } => "receipt", + Attested::Entry { .. } => "entry", + Attested::Period { .. } => "period", + } + } +} + +/// Who claims an attestation, how, and what they add. +#[derive(Clone, Debug)] +pub struct Claim { + /// The caller's token name. + pub by: String, + /// How, as the client says. + pub method: Method, + /// A free note. + pub note: Option, +} + +impl Claim { + fn into_attestation(self, what: Attested) -> Attestation { + Attestation { + seq: 0, + by: self.by, + at: now(), + method: self.method, + note: self + .note + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()), + what, + } + } +} + +fn refused(why: impl Into) -> StoreError { + StoreError::AuditRefused(why.into()) +} + +fn now() -> String { + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +impl Store { + /// The seq of the last journal line that bears on a figure + /// (INVARIANT 1); zero for books with none. + pub fn figure_head(&self) -> Result { + let quoted: Vec = NOT_FIGURES.iter().map(|t| format!("'{t}'")).collect(); + let sql = format!( + "SELECT COALESCE(MAX(seq), 0) FROM events WHERE type NOT IN ({})", + quoted.join(",") + ); + let n: i64 = self.conn.query_row(&sql, [], |r| r.get(0))?; + Ok(n as u64) + } + + /// The entries with at least one receipt filed on them or linked to + /// them, not withdrawn. + pub fn entries_with_receipts(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT entry_id FROM attachments WHERE withdrawn_at IS NULL AND entry_id IS NOT NULL + UNION + SELECT l.subject_id FROM attachment_links l JOIN attachments a ON a.id = l.attachment_id + WHERE l.subject_kind = 'entry' AND a.withdrawn_at IS NULL", + )?; + let ids = stmt + .query_map([], |r| r.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(ids.into_iter().map(|i| i as u64).collect()) + } + + /// What only the store can see: the chain, the database against a + /// replay, the stored files, the access log, extraction text and + /// duplicate files. Reads; changes nothing. + pub fn integrity_findings(&self) -> Vec { + let mut out = Vec::new(); + match self.verify() { + Ok(()) => {} + Err(StoreError::VerifyMismatch(why)) => out.push(found( + "projection_differs", + Subject::Books, + format!("the books differ from a replay of the journal: {why}"), + )), + Err(e) => out.push(found( + "journal_damaged", + Subject::Books, + format!("the journal does not verify: {e}"), + )), + } + let shas = |sql: &str| -> Vec { + self.conn + .prepare(sql) + .and_then(|mut s| { + s.query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>() + }) + .unwrap_or_default() + }; + let mut blobs = shas("SELECT sha256 FROM blobs ORDER BY sha256"); + blobs.dedup(); + for sha in blobs { + if !self.blobs.exists(&sha) { + out.push(found( + "blob_missing", + Subject::Named(sha.chars().take(12).collect()), + format!("the stored file {sha} is not on disk"), + )); + } else if let Err(StoreError::CorruptBlob(_)) = self.blobs.get(&sha) { + out.push(found( + "blob_damaged", + Subject::Named(sha.chars().take(12).collect()), + format!("the stored file {sha} does not match its SHA-256"), + )); + } + } + if let Err(e) = crate::access::read(&self.data_dir) { + out.push(found( + "access_log_damaged", + Subject::Named("access log".into()), + format!("the access log does not verify: {e}"), + )); + } + for sha in shas( + "SELECT DISTINCT text_sha256 FROM extractions WHERE text_sha256 IS NOT NULL ORDER BY text_sha256", + ) { + if !self.blobs.exists(&sha) { + out.push(found( + "extraction_blob_missing", + Subject::Named(sha.chars().take(12).collect()), + format!("the extracted text {sha} is not on disk"), + )); + } + } + let dups: Vec<(String, String)> = self + .conn + .prepare( + "SELECT blob_sha, GROUP_CONCAT(id) FROM + (SELECT blob_sha, id FROM attachments WHERE withdrawn_at IS NULL ORDER BY id) + GROUP BY blob_sha HAVING COUNT(*) > 1 ORDER BY blob_sha", + ) + .and_then(|mut s| { + s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::, _>>() + }) + .unwrap_or_default(); + for (sha, ids) in dups { + out.push(found( + "duplicate_blob", + Subject::Named(sha.chars().take(12).collect()), + format!("attachments {ids} hold the same bytes ({sha})"), + )); + } + out + } + + /// Every audit run, oldest first. + pub fn audit_runs(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, through, through_seq, scope, program_version, tb_hash, findings_sha256, + workpaper_attachment, workpaper_pdf, seq FROM audits ORDER BY id", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, i64>(9)?, + )) + })?; + let mut out = Vec::new(); + for row in rows { + let (id, through, seq_at, scope, version, tb, fs, wp, pdf, seq) = row?; + out.push(AuditRun { + id: id as u64, + through: NaiveDate::parse_from_str(&through, "%Y-%m-%d") + .map_err(|_| StoreError::JournalCorrupt("audits.through".into()))?, + through_seq: seq_at as u64, + scope: Scope::parse(&scope) + .ok_or_else(|| StoreError::JournalCorrupt("audits.scope".into()))?, + program_version: version, + tb_hash: tb, + findings_sha256: fs, + workpaper_attachment: wp.map(|v| v as u64), + workpaper_pdf: pdf.map(|v| v as u64), + seq: seq as u64, + }); + } + Ok(out) + } + + /// One run. + pub fn audit_run(&self, id: u64) -> Result, StoreError> { + Ok(self.audit_runs()?.into_iter().find(|r| r.id == id)) + } + + /// The run that already answers an audit of `through` at `head` + /// (INVARIANT 2). + pub fn find_audit_run( + &self, + through: NaiveDate, + head: u64, + scope: Scope, + ) -> Result, StoreError> { + Ok(self.audit_runs()?.into_iter().find(|r| { + r.through == through + && r.through_seq == head + && r.scope == scope + && r.program_version == PROGRAM_VERSION + })) + } + + /// Whether the books' figures are where `run` left them + /// (IZ-1k): nothing bearing on a figure has been journaled since. + pub fn audit_is_current(&self, run: &AuditRun) -> Result { + Ok(run.through_seq == self.figure_head()?) + } + + /// Record an audit run (`audit.run`). Waits for the `audits` feature. + pub fn record_audit_run(&mut self, new: NewAuditRun) -> Result { + self.ensure_feature(crate::format::AUDITS)?; + if let Some(existing) = self.find_audit_run(new.through, new.through_seq, new.scope)? { + return Ok(existing); + } + for a in [new.workpaper_attachment, new.workpaper_pdf] + .into_iter() + .flatten() + { + self.attachment_state(a)?; + } + let id = self.audit_runs()?.last().map_or(1, |r| r.id + 1); + let mut run = AuditRun { + id, + through: new.through, + through_seq: new.through_seq, + scope: new.scope, + program_version: PROGRAM_VERSION.into(), + tb_hash: new.tb_hash, + findings_sha256: new.findings_sha256, + workpaper_attachment: new.workpaper_attachment, + workpaper_pdf: new.workpaper_pdf, + seq: 0, + }; + let line = self.append_journal("audit.run", crate::meta::audit_run(&run))?; + run.seq = line.seq; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(run) + } + + /// Every attestation, oldest first. + pub fn attestations(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, + method, amount_checked, date_checked, disagrees, note + FROM attestations ORDER BY seq", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, String>(6)?, + r.get::<_, String>(7)?, + r.get::<_, String>(8)?, + r.get::<_, Option>(9)?, + r.get::<_, Option>(10)?, + r.get::<_, Option>(11)?, + r.get::<_, Option>(12)?, + )) + })?; + let bad = |w: &str| StoreError::JournalCorrupt(format!("attestations.{w}")); + let mut out = Vec::new(); + for row in rows { + let (seq, kind, att, entry, period, run, by, at, method, amount, date, dis, note) = + row?; + let method: Value = serde_json::from_str(&method)?; + let what = match kind.as_str() { + "receipt" => Attested::Receipt { + attachment_id: att.ok_or_else(|| bad("attachment_id"))? as u64, + entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64, + amount_checked: amount + .and_then(|a| a.parse().ok()) + .ok_or_else(|| bad("amount_checked"))?, + date_checked: date + .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()) + .ok_or_else(|| bad("date_checked"))?, + disagrees: dis.unwrap_or(0) != 0, + }, + "entry" => Attested::Entry { + entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64, + }, + "period" => Attested::Period { + period_id: period.ok_or_else(|| bad("period_id"))? as u64, + audit_run_id: run.ok_or_else(|| bad("audit_run_id"))? as u64, + }, + _ => return Err(bad("kind")), + }; + out.push(Attestation { + seq: seq as u64, + by, + at, + method: crate::meta::method_from_value(&method)?, + note, + what, + }); + } + Ok(out) + } + + fn journal_attestation( + &mut self, + line_type: &str, + a: Attestation, + ) -> Result { + let (line_type, payload) = (line_type, crate::meta::attestation(&a)); + let line = self.append_journal(line_type, payload)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(Attestation { seq: line.seq, ..a }) + } + + /// Attest that a receipt supports an entry (`receipt.attested`): the + /// checker's reading of its amount and date against the entry's. A + /// reading that differs is recorded as disagreeing and never counts + /// (INVARIANT 4). + pub fn attest_receipt( + &mut self, + attachment_id: u64, + entry_id: u64, + amount: Decimal, + date: NaiveDate, + claim: Claim, + ) -> Result { + self.ensure_feature(crate::format::AUDITS)?; + let entry = self + .ledger + .get_entry(entry_id) + .ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?; + if entry.status() != ledger_core::EntryStatus::Posted { + return Err(refused(format!("entry {entry_id} is not posted"))); + } + let (_, withdrawn) = self.attachment_state(attachment_id)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(attachment_id)); + } + if !self + .attachment_links(attachment_id)? + .contains(&crate::store::Subject::Entry(entry_id)) + { + return Err(refused(format!( + "attachment {attachment_id} is not filed on or linked to entry {entry_id}" + ))); + } + let debits = entry + .lines() + .iter() + .filter(|l| l.side == ledger_core::Side::Debit) + .try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount)); + let amount_agrees = + debits == Some(amount) || entry.lines().iter().any(|l| l.amount.amount == amount); + let disagrees = !amount_agrees || date != entry.date(); + self.journal_attestation( + "receipt.attested", + claim.into_attestation(Attested::Receipt { + attachment_id, + entry_id, + amount_checked: amount, + date_checked: date, + disagrees, + }), + ) + } + + /// Record that an entry was reviewed whole (`entry.reviewed`). + pub fn review_entry(&mut self, entry_id: u64, claim: Claim) -> Result { + self.ensure_feature(crate::format::AUDITS)?; + let entry = self + .ledger + .get_entry(entry_id) + .ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?; + if entry.status() != ledger_core::EntryStatus::Posted { + return Err(refused(format!("entry {entry_id} is not posted"))); + } + self.journal_attestation( + "entry.reviewed", + claim.into_attestation(Attested::Entry { entry_id }), + ) + } + + /// The run a period can be attested on: the latest at the current + /// figure head whose through date is on or after the period's end + /// (INVARIANT 3). + pub fn run_for_period(&self, period_id: u64) -> Result { + let period = self + .ledger + .get_period(period_id) + .ok_or_else(|| refused(format!("no period {period_id}")))?; + let head = self.figure_head()?; + let runs = self.audit_runs()?; + runs.into_iter() + .rev() + .find(|r| r.through_seq == head && r.through >= period.end) + .ok_or_else(|| { + refused(format!( + "no audit through {} or later stands at the books' current head: run books.audit first (an entry, a document, a close or a settings change since the last one moves the head)", + period.end + )) + }) + } + + /// Attest a period on an audit run at the current head + /// (`period.attested`). + pub fn attest_period( + &mut self, + period_id: u64, + claim: Claim, + ) -> Result<(Attestation, AuditRun), StoreError> { + self.ensure_feature(crate::format::AUDITS)?; + let run = self.run_for_period(period_id)?; + let a = self.journal_attestation( + "period.attested", + claim.into_attestation(Attested::Period { + period_id, + audit_run_id: run.id, + }), + )?; + Ok((a, run)) + } +} + +/// Read an `audits` row for a replayed line. +pub(crate) fn insert_run(conn: &rusqlite::Connection, run: &AuditRun) -> Result<(), StoreError> { + conn.execute( + "INSERT INTO audits(id, through, through_seq, scope, program_version, tb_hash, findings_sha256, + workpaper_attachment, workpaper_pdf, seq) + VALUES (?,?,?,?,?,?,?,?,?,?)", + params![ + run.id as i64, + run.through.to_string(), + run.through_seq as i64, + run.scope.key(), + run.program_version, + run.tb_hash, + run.findings_sha256, + run.workpaper_attachment.map(|v| v as i64), + run.workpaper_pdf.map(|v| v as i64), + run.seq as i64, + ], + )?; + Ok(()) +} + +/// Write an `attestations` row for a replayed line. +pub(crate) fn insert_attestation( + conn: &rusqlite::Connection, + a: &Attestation, +) -> Result<(), StoreError> { + let (mut att, mut entry, mut period, mut run) = (None, None, None, None); + let (mut amount, mut date, mut dis) = (None, None, None); + match &a.what { + Attested::Receipt { + attachment_id, + entry_id, + amount_checked, + date_checked, + disagrees, + } => { + att = Some(*attachment_id as i64); + entry = Some(*entry_id as i64); + amount = Some(amount_checked.to_string()); + date = Some(date_checked.to_string()); + dis = Some(i64::from(*disagrees)); + } + Attested::Entry { entry_id } => entry = Some(*entry_id as i64), + Attested::Period { + period_id, + audit_run_id, + } => { + period = Some(*period_id as i64); + run = Some(*audit_run_id as i64); + } + } + conn.execute( + "INSERT INTO attestations(seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, + method, amount_checked, date_checked, disagrees, note) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + params![ + a.seq as i64, + a.kind(), + att, + entry, + period, + run, + a.by, + a.at, + crate::meta::method_json(&a.method).to_string(), + amount, + date, + dis, + a.note, + ], + )?; + Ok(()) +} diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 8bbe1dd..c607070 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -162,6 +162,12 @@ pub enum StoreError { )] WriteOffNeedsEvidence, + /// An audit or an attestation the books refuse (plan P8; rules.md + /// B-45): a stale head, an entry not posted, a receipt not on the + /// entry. The words say what to do. + #[error("{0}")] + AuditRefused(String), + /// The requested base currency differs from the store's. #[error("base currency mismatch: store is {0}, requested {1}")] CurrencyMismatch(String, String), diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 9960efe..5bca69e 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -66,6 +66,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ ATTACHMENT_TEXT, DRAFTS_V2, SOURCE_REVISIONS, + AUDITS, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -105,6 +106,9 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> { | "bills.discount_warn_days" | "company.email" | "company.website" => Some(DOCUMENTS_V2), + // P8: what counts as an attestation, and whether the CPA package + // waits for an audit. + "policy.attestation_bar" | "policy.cpa_package_requires_audit" => Some(AUDITS), _ => None, } } @@ -228,6 +232,17 @@ pub const DRAFTS_V2: &str = "drafts_v2"; /// declaration instead (rules.md B-27). pub const SOURCE_REVISIONS: &str = "source_revisions"; +/// Feature: `audits` (plan P8): `audit.run` (a programme of checks run +/// through a date, with the hash of its findings and its workpaper), +/// `receipt.attested`, `entry.reviewed` and `period.attested` (claims by +/// an authenticated client that it checked), and the two settings that +/// say what counts (`policy.attestation_bar`) and whether the CPA package +/// waits for an audit (`policy.cpa_package_requires_audit`). An older +/// version doesn't know those event types or settings; it stops at the +/// declaration instead of dropping the claims a handoff rests on +/// (rules.md B-27, B-45). +pub const AUDITS: &str = "audits"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -253,6 +268,7 @@ pub const EVENT_TYPES: &[&str] = &[ "attachment.text_extracted", "attachment.unlinked", "attachment.withdrawn", + "audit.run", "bill.created", "bill.paid", "class.added", @@ -275,6 +291,7 @@ pub const EVENT_TYPES: &[&str] = &[ "entry.party_set", "entry.posted", "entry.reversed", + "entry.reviewed", "entry.settles", "entry.vendor_set", "fx.rate_set", @@ -298,10 +315,12 @@ pub const EVENT_TYPES: &[&str] = &[ "party.w9_set", "payroll.run", "payroll.taxes_remitted", + "period.attested", "period.closed", "period.created", "project.set", "receipt.attached", + "receipt.attested", "receipt.linked", "receipt.unlinked", "reconciliation.completed", @@ -1035,6 +1054,39 @@ mod tests { assert_eq!(up.adds(), vec!["the journal feature source_revisions"]); } + /// The version before P8 stops at a declaration of `audits` as newer, + /// never dropping an attestation a handoff rests on; books that + /// declared every earlier feature still wait for it (B-27, D-39). + #[test] + fn audits_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&AUDITS)); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != AUDITS) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [DRAFTS_V2, AUDITS]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features audits") + } + 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(AUDITS), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = older.upgrade().expect("waits for it"); + assert_eq!(up.adds(), vec!["the journal feature audits"]); + } + /// This version's format: the newest it reads, every feature it /// knows, each once, sorted (what new books and an upgrade declare). #[test] diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 590a4ae..cf5601e 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -47,6 +47,7 @@ pub use error::StoreError; pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT}; pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; pub use snapshot::{DiskCheck, Snapshot}; +pub use store::audit; pub use store::{ Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, Extraction, ExtractionStatus, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index 4922a6b..876da39 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -729,6 +729,198 @@ pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> { Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?)) } +// --------------------------------------------------------------------------- +// Audits and attestations (plan P8, feature `audits`) +// --------------------------------------------------------------------------- + +/// A method as journaled: only the parts the client gave. +pub fn method_json(m: &ledger_core::audit::Method) -> Value { + let mut o = Map::new(); + put(&mut o, "model", m.model.clone()); + put( + &mut o, + "reasoning_effort", + m.reasoning_effort.map(|e| e.key()), + ); + put(&mut o, "adversarial", m.adversarial); + put(&mut o, "verifier_agents", m.verifier_agents); + Value::Object(o) +} + +/// Read a method. A key or an effort this version doesn't know is newer +/// when a line is at hand (B-27), else corrupt. +fn method_of( + line: Option<&crate::journal::JournalLine>, + m: &Value, + event: &str, +) -> Result { + let o = m.as_object().ok_or_else(|| corrupt(event, "method"))?; + let unknown = |what: String| match line { + Some(l) => crate::format::newer_content(l, what), + None => corrupt(event, "method"), + }; + let mut out = ledger_core::audit::Method::default(); + for (k, v) in o { + match k.as_str() { + "model" => { + out.model = Some( + v.as_str() + .ok_or_else(|| corrupt(event, "method.model"))? + .to_string(), + ) + } + "reasoning_effort" => { + let s = v + .as_str() + .ok_or_else(|| corrupt(event, "method.reasoning_effort"))?; + out.reasoning_effort = Some( + ledger_core::audit::Effort::parse(s) + .ok_or_else(|| unknown(format!("the reasoning effort {s:?}")))?, + ); + } + "adversarial" => { + out.adversarial = Some( + v.as_bool() + .ok_or_else(|| corrupt(event, "method.adversarial"))?, + ) + } + "verifier_agents" => { + out.verifier_agents = Some( + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| corrupt(event, "method.verifier_agents"))?, + ) + } + other => return Err(unknown(format!("the method key {other:?}"))), + } + } + Ok(out) +} + +/// A method read back from the projection. +pub fn method_from_value(m: &Value) -> Result { + method_of(None, m, "attestations") +} + +/// `audit.run {id, through, through_seq, scope, program_version, tb_hash, +/// findings_sha256, workpaper_attachment?, workpaper_pdf?}`. +pub fn audit_run(r: &crate::audit::AuditRun) -> Value { + let mut o = Map::new(); + o.insert("id".into(), json!(r.id)); + o.insert("through".into(), json!(r.through.to_string())); + o.insert("through_seq".into(), json!(r.through_seq)); + o.insert("scope".into(), json!(r.scope.key())); + o.insert("program_version".into(), json!(r.program_version)); + o.insert("tb_hash".into(), json!(r.tb_hash)); + o.insert("findings_sha256".into(), json!(r.findings_sha256)); + put(&mut o, "workpaper_attachment", r.workpaper_attachment); + put(&mut o, "workpaper_pdf", r.workpaper_pdf); + Value::Object(o) +} + +/// Read an `audit.run`. A scope this version doesn't know is newer. +pub fn audit_run_of( + line: &crate::journal::JournalLine, +) -> Result { + const E: &str = "audit.run"; + let p = &line.payload; + let scope_key = str_of(p, E, "scope")?; + let scope = ledger_core::audit::Scope::parse(&scope_key).ok_or_else(|| { + crate::format::newer_content(line, format!("the audit scope {scope_key:?}")) + })?; + Ok(crate::audit::AuditRun { + id: u64_of(p, E, "id")?, + through: date_of(&str_of(p, E, "through")?, E, "through")?, + through_seq: u64_of(p, E, "through_seq")?, + scope, + program_version: str_of(p, E, "program_version")?, + tb_hash: str_of(p, E, "tb_hash")?, + findings_sha256: str_of(p, E, "findings_sha256")?, + workpaper_attachment: opt_u64(p, E, "workpaper_attachment")?, + workpaper_pdf: opt_u64(p, E, "workpaper_pdf")?, + seq: line.seq, + }) +} + +/// The payload of `receipt.attested`, `entry.reviewed` or `period.attested`. +pub fn attestation(a: &crate::audit::Attestation) -> Value { + use crate::audit::Attested; + let mut o = Map::new(); + match &a.what { + Attested::Receipt { + attachment_id, + entry_id, + amount_checked, + date_checked, + disagrees, + } => { + o.insert("attachment_id".into(), json!(attachment_id)); + o.insert("entry_id".into(), json!(entry_id)); + o.insert("amount_checked".into(), json!(amount_checked.to_string())); + o.insert("date_checked".into(), json!(date_checked.to_string())); + o.insert("disagrees".into(), json!(disagrees)); + } + Attested::Entry { entry_id } => { + o.insert("entry_id".into(), json!(entry_id)); + } + Attested::Period { + period_id, + audit_run_id, + } => { + o.insert("period_id".into(), json!(period_id)); + o.insert("audit_run_id".into(), json!(audit_run_id)); + } + } + o.insert("by".into(), json!(a.by)); + o.insert("at".into(), json!(a.at)); + o.insert("method".into(), method_json(&a.method)); + put(&mut o, "note", a.note.clone()); + Value::Object(o) +} + +/// Read one of the three attestation events by its line. +pub fn attestation_of( + line: &crate::journal::JournalLine, +) -> Result { + use crate::audit::Attested; + let p = &line.payload; + let e = line.line_type.as_str(); + let what = match e { + "receipt.attested" => Attested::Receipt { + attachment_id: u64_of(p, e, "attachment_id")?, + entry_id: u64_of(p, e, "entry_id")?, + amount_checked: str_of(p, e, "amount_checked")? + .parse() + .map_err(|_| corrupt(e, "amount_checked"))?, + date_checked: date_of(&str_of(p, e, "date_checked")?, e, "date_checked")?, + disagrees: p + .get("disagrees") + .and_then(Value::as_bool) + .ok_or_else(|| corrupt(e, "disagrees"))?, + }, + "entry.reviewed" => Attested::Entry { + entry_id: u64_of(p, e, "entry_id")?, + }, + "period.attested" => Attested::Period { + period_id: u64_of(p, e, "period_id")?, + audit_run_id: u64_of(p, e, "audit_run_id")?, + }, + _ => return Err(crate::format::unknown_type(line)), + }; + Ok(crate::audit::Attestation { + seq: line.seq, + by: str_of(p, e, "by")?, + at: str_of(p, e, "at")?, + method: method_of( + Some(line), + p.get("method").ok_or_else(|| corrupt(e, "method"))?, + e, + )?, + note: opt_str(p, e, "note")?, + what, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index f0e1e70..bd498c8 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 19; +pub const SCHEMA_VERSION: u32 = 20; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -485,5 +485,41 @@ CREATE TABLE IF NOT EXISTS draft_states( state TEXT NOT NULL, note TEXT NULL ); +"#, + r#" +-- v20: audits and attestations (plan P8, feature `audits`). An audit run +-- is a row per `audit.run`: the date it covers, the figure head it was +-- made at, the hashes that make it reproducible and the filed workpaper. +-- An attestation is a row per `receipt.attested`, `entry.reviewed` or +-- `period.attested`; whether one counts is derived against +-- `policy.attestation_bar` when asked and never stored. The method is the +-- client's own claim, kept as canonical JSON. Amounts stay TEXT. +CREATE TABLE IF NOT EXISTS audits( + id INTEGER PRIMARY KEY, + through TEXT NOT NULL, + through_seq INTEGER NOT NULL, + scope TEXT NOT NULL, + program_version TEXT NOT NULL, + tb_hash TEXT NOT NULL, + findings_sha256 TEXT NOT NULL, + workpaper_attachment INTEGER NULL, + workpaper_pdf INTEGER NULL, + seq INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS attestations( + seq INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + attachment_id INTEGER NULL, + entry_id INTEGER NULL, + period_id INTEGER NULL, + audit_run_id INTEGER NULL, + by_who TEXT NOT NULL, + at_ts TEXT NOT NULL, + method TEXT NOT NULL, + amount_checked TEXT NULL, + date_checked TEXT NULL, + disagrees INTEGER NULL, + note TEXT NULL +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 2f1cded..12d1a30 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -51,6 +51,9 @@ use rust_decimal::Decimal; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; +#[path = "audit.rs"] +pub mod audit; + /// File names inside the data directory. pub const JOURNAL_FILE: &str = "journal.jsonl"; @@ -7594,6 +7597,21 @@ fn replay_line( let a = crate::meta::annotated_of(line)?; ledger.replay_annotate_attachment(a)?; } + "audit.run" => { + // Not accounting state; the projection records it. Checked + // for shape here (an unknown scope is newer, rules.md B-27). + crate::meta::audit_run_of(line)?; + } + // A claim that someone checked; no figure moves. + "receipt.attested" => { + crate::meta::attestation_of(line)?; + } + "entry.reviewed" => { + crate::meta::attestation_of(line)?; + } + "period.attested" => { + crate::meta::attestation_of(line)?; + } "entry.settles" => { let (entry, settles) = crate::meta::entry_settles_of(p)?; ledger.replay_set_entry_settles(entry, settles)?; @@ -8809,6 +8827,22 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } + "audit.run" => { + let run = crate::meta::audit_run_of(line)?; + audit::insert_run(conn, &run)?; + } + "receipt.attested" => { + let a = crate::meta::attestation_of(line)?; + audit::insert_attestation(conn, &a)?; + } + "entry.reviewed" => { + let a = crate::meta::attestation_of(line)?; + audit::insert_attestation(conn, &a)?; + } + "period.attested" => { + let a = crate::meta::attestation_of(line)?; + audit::insert_attestation(conn, &a)?; + } "entry.settles" => { // Rebuilt into the ledger by replay; no report rows. } diff --git a/crates/ledger-store/tests/audit.rs b/crates/ledger-store/tests/audit.rs new file mode 100644 index 0000000..7fd1f7e --- /dev/null +++ b/crates/ledger-store/tests/audit.rs @@ -0,0 +1,612 @@ +//! Audits and attestations in the store (plan P8; rules.md B-45): the +//! `audits` feature, the figure head, idempotent runs, attestations that +//! rest on a head, and the integrity findings only the store can see. + +use chrono::NaiveDate; +use ledger_core::audit::{Effort, Method, Scope}; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::audit::{self, Attested, BackupStanding, Claim, NewAuditRun, backup_findings}; +use ledger_store::format::{AUDITS, JournalFormat}; +use ledger_store::{BlobStore, JOURNAL_FILE, JournalWriter, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::json; +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!("wb-audit-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(s.parse().unwrap(), Currency::USD) +} + +struct Fx { + bank: u64, + capital: u64, + supplies: u64, + /// A posted 40.00 purchase. + entry: u64, +} + +/// A bank, capital and an expense account, and a posted 40.00 purchase on +/// the 5th. +fn books(dir: &std::path::Path, format: Option<&JournalFormat>) -> (Store, Fx) { + let mut s = match format { + Some(f) => Store::create_in_format(dir, Currency::USD, f).unwrap(), + None => 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 supplies = s + .add_account("6000", "Supplies", AccountType::Expense, None, false) + .unwrap(); + let e = s.create_entry(d(1, 1), "Opening").unwrap(); + s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap(); + s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap(); + s.post_entry(e).unwrap(); + let entry = s.create_entry(d(1, 5), "Printer paper").unwrap(); + s.add_line(entry, supplies, Side::Debit, usd("40.00")) + .unwrap(); + s.add_line(entry, bank, Side::Credit, usd("40.00")).unwrap(); + s.post_entry(entry).unwrap(); + ( + s, + Fx { + bank, + capital, + supplies, + entry, + }, + ) +} + +fn run_of(s: &Store, through: NaiveDate, scope: Scope) -> NewAuditRun { + NewAuditRun { + through, + through_seq: s.figure_head().unwrap(), + scope, + tb_hash: "tb".into(), + findings_sha256: "fs".into(), + workpaper_attachment: None, + workpaper_pdf: None, + } +} + +fn claim(effort: &str) -> Claim { + Claim { + by: "verifier-1".into(), + method: Method { + model: Some("opus".into()), + reasoning_effort: Effort::parse(effort), + adversarial: Some(true), + verifier_agents: Some(2), + }, + note: None, + } +} + +fn journal_bytes(dir: &std::path::Path) -> Vec { + std::fs::read(dir.join(JOURNAL_FILE)).unwrap() +} + +/// R-A: every write of the feature waits for the owner's upgrade, +/// journaling nothing; afterwards it is taken and the books replay to +/// what was live. +#[test] +fn audit_writes_wait_for_audits() { + let dir = test_dir("waits"); + let (mut s, fx) = books(&dir, Some(&JournalFormat::default())); + assert!(!s.journal_format().has_feature(AUDITS)); + let (att, _, _) = s + .attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None) + .unwrap(); + let before = journal_bytes(&dir); + let new = run_of(&s, d(1, 31), Scope::Month); + for r in [ + s.record_audit_run(new.clone()).map(|_| ()), + s.attest_receipt( + att, + fx.entry, + "40.00".parse().unwrap(), + d(1, 5), + claim("high"), + ) + .map(|_| ()), + s.review_entry(fx.entry, claim("high")).map(|_| ()), + ] { + match r { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature audits") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + } + assert_eq!(journal_bytes(&dir), before, "a refusal journals nothing"); + s.upgrade_format().unwrap(); + let run = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Month)) + .unwrap(); + s.review_entry(fx.entry, claim("high")).unwrap(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(s.audit_runs().unwrap(), vec![run]); + assert_eq!(s.attestations().unwrap().len(), 1); +} + +/// INVARIANT 1 and 2: filing a workpaper and journaling the run do not +/// move the figure head; an entry does; the same audit asked again +/// returns the first run and journals nothing. +#[test] +fn a_run_is_idempotent_on_the_figure_head() { + let dir = test_dir("idem"); + let (mut s, fx) = books(&dir, None); + let head = s.figure_head().unwrap(); + let (wp, _, _) = s + .file_document("workpaper", "w.json", "application/json", b"{}", None) + .unwrap(); + assert_eq!(s.figure_head().unwrap(), head, "a filing is not a figure"); + let mut new = run_of(&s, d(1, 31), Scope::Month); + new.workpaper_attachment = Some(wp); + let first = s.record_audit_run(new.clone()).unwrap(); + assert_eq!(first.id, 1); + assert_eq!(first.through_seq, head); + assert_eq!(s.figure_head().unwrap(), head, "a run is not a figure"); + let bytes = journal_bytes(&dir); + let again = s.record_audit_run(new).unwrap(); + assert_eq!(again, first); + assert_eq!(journal_bytes(&dir), bytes, "asking again journals nothing"); + assert!(s.audit_is_current(&first).unwrap()); + // Another scope is another run. + let other = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Full)) + .unwrap(); + assert_eq!(other.id, 2); + // An entry moves the head: the run no longer stands. + let e = s.create_entry(d(2, 1), "Stamps").unwrap(); + s.add_line(e, fx.supplies, Side::Debit, usd("3.00")) + .unwrap(); + s.add_line(e, fx.bank, Side::Credit, usd("3.00")).unwrap(); + s.post_entry(e).unwrap(); + assert!(s.figure_head().unwrap() > head); + assert!(!s.audit_is_current(&first).unwrap()); + s.verify().unwrap(); +} + +/// A receipt reading that differs from the entry's is recorded, marked, +/// and never counts; one that agrees is not marked. The receipt must be on +/// the entry. +#[test] +fn amount_mismatch_never_counts() { + let dir = test_dir("mismatch"); + let (mut s, fx) = books(&dir, None); + let (att, _, _) = s + .attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper", None) + .unwrap(); + let other = s + .attach_receipt(None, "x.txt", "text/plain", b"stray", None) + .unwrap() + .0; + let amount = |t: &str| -> Decimal { t.parse().unwrap() }; + let disagrees = |a: &ledger_store::audit::Attestation| match a.what { + Attested::Receipt { disagrees, .. } => disagrees, + _ => panic!("not a receipt"), + }; + let ok = s + .attest_receipt(att, fx.entry, amount("40.00"), d(1, 5), claim("high")) + .unwrap(); + assert!(!disagrees(&ok)); + let wrong_amount = s + .attest_receipt(att, fx.entry, amount("41.00"), d(1, 5), claim("high")) + .unwrap(); + assert!(disagrees(&wrong_amount)); + let wrong_date = s + .attest_receipt(att, fx.entry, amount("40.00"), d(1, 6), claim("high")) + .unwrap(); + assert!(disagrees(&wrong_date)); + // Refused: a receipt not on the entry, a draft, an unknown entry. + assert!(matches!( + s.attest_receipt(other, fx.entry, amount("40.00"), d(1, 5), claim("high")), + Err(StoreError::AuditRefused(_)) + )); + let draft = s.create_entry(d(2, 2), "Draft").unwrap(); + assert!(matches!( + s.review_entry(draft, claim("high")), + Err(StoreError::AuditRefused(_)) + )); + assert!(s.review_entry(999, claim("high")).is_err()); + // All three kinds replay, in order. + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + let got = s.attestations().unwrap(); + assert_eq!(got.len(), 3); + assert!(got.windows(2).all(|w| w[0].seq < w[1].seq)); + assert_eq!(got[0].method.reasoning_effort, Some(Effort::High)); + assert_eq!(got[0].by, "verifier-1"); +} + +/// INVARIANT 3: a period is attested only on a run at the current head +/// that covers it; an entry since makes the run stale and the attestation +/// is refused until the audit is run again. +#[test] +fn stale_head_period_attestation_refused() { + let dir = test_dir("stale"); + let (mut s, fx) = books(&dir, None); + let jan = s.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); + // No audit yet. + assert!(matches!( + s.attest_period(jan, claim("high")), + Err(StoreError::AuditRefused(_)) + )); + // An audit through before the period's end does not cover it. + s.record_audit_run(run_of(&s, d(1, 15), Scope::Month)) + .unwrap(); + assert!(matches!( + s.attest_period(jan, claim("high")), + Err(StoreError::AuditRefused(_)) + )); + let run = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Month)) + .unwrap(); + let (a, on) = s.attest_period(jan, claim("high")).unwrap(); + assert_eq!(on.id, run.id); + assert_eq!( + a.what, + Attested::Period { + period_id: jan, + audit_run_id: run.id + } + ); + // An entry since: the run is stale. + let e = s.create_entry(d(2, 1), "Stamps").unwrap(); + s.add_line(e, fx.supplies, Side::Debit, usd("3.00")) + .unwrap(); + s.add_line(e, fx.capital, Side::Credit, usd("3.00")) + .unwrap(); + s.post_entry(e).unwrap(); + let head = s.head(); + match s.attest_period(jan, claim("high")) { + Err(StoreError::AuditRefused(why)) => assert!(why.contains("books.audit"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.head(), head, "a refusal journals nothing"); + s.verify().unwrap(); +} + +/// The integrity table: each code seeded alone. +#[test] +fn integrity_detects_each_seeded_defect() { + // A whole book finds nothing. + { + let dir = test_dir("whole"); + let (s, _) = books(&dir, None); + assert_eq!(s.integrity_findings(), vec![]); + } + let seed = |code: &str| -> Vec<&'static str> { + let dir = test_dir(&format!("seed-{code}")); + let (mut s, fx) = books(&dir, None); + let found = match code { + "blob_missing" | "blob_damaged" | "extraction_blob_missing" => { + let (_, sha, _) = s + .attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None) + .unwrap(); + let target = if code == "extraction_blob_missing" { + // The text the extractor read, content-addressed too. + let x = s.extractions().unwrap().pop().expect("an extraction"); + x.text_sha256.expect("text") + } else { + sha + }; + let path = dir.join("blobs").join(&target[..2]).join(&target); + let path = if path.exists() { + path + } else { + // The store's own layout. + walk(&dir.join("blobs"), &target).expect("the blob file") + }; + if code == "blob_damaged" { + std::fs::write(&path, b"tampered").unwrap(); + } else { + std::fs::remove_file(&path).unwrap(); + } + s.integrity_findings() + } + "duplicate_blob" => { + drop(s); + let bytes = b"filed twice by an older version"; + let (sha, _) = BlobStore::open(&dir.join("blobs")) + .unwrap() + .put(bytes) + .unwrap(); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] { + w.append( + "receipt.attached", + json!({ + "attachment_id": id, "blob_sha": sha, "entry_id": null, + "category": "receipts", "file_name": name, + "mime_type": "application/pdf", "size_bytes": bytes.len() as u64, + "description": null, + }), + ) + .unwrap(); + } + drop(w); + Store::open(&dir).unwrap().integrity_findings() + } + "access_log_damaged" => { + std::fs::write( + dir.join("access.jsonl"), + b"{\"not\": \"a chained record\"}\n", + ) + .unwrap(); + s.integrity_findings() + } + "projection_differs" => { + let db = ledger_store::database_in_use(&dir); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute("DELETE FROM lines", []).unwrap(); + drop(conn); + s.integrity_findings() + } + "journal_damaged" => { + let path = dir.join(JOURNAL_FILE); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replacen("Printer paper", "Printer papers", 1)).unwrap(); + s.integrity_findings() + } + "backup_stale" => backup_findings(&[BackupStanding { + target: "folder".into(), + stale: true, + last_error: None, + drill_error: None, + }]), + "drill_failed" => backup_findings(&[BackupStanding { + target: "folder".into(), + stale: false, + last_error: None, + drill_error: Some("restore did not reach the head".into()), + }]), + other => panic!("no seed for {other}"), + }; + found.iter().map(|f| f.code).collect() + }; + for row in audit::CODES { + let got = seed(row.code); + assert!(got.contains(&row.code), "{}: found {got:?}", row.code); + // The seed implies nothing else but its own consequences: a + // missing or damaged file is found once; a damaged journal can + // also differ from its replay. + for c in &got { + assert!( + *c == row.code + || (row.code == "journal_damaged" && *c == "projection_differs") + // verify reads a file's text too, so a damaged or missing + // stored file is also a replay that cannot be made. + || (row.code.starts_with("blob_") || row.code == "extraction_blob_missing") + && [ + "blob_missing", + "blob_damaged", + "extraction_blob_missing", + "projection_differs", + ] + .contains(c), + "{}: also found {c}", + row.code + ); + } + } + // No backups set up is the owner's standing choice, not a finding. + assert!(backup_findings(&[]).is_empty()); +} + +fn walk(dir: &std::path::Path, name: &str) -> Option { + for e in std::fs::read_dir(dir).ok()? { + let p = e.ok()?.path(); + if p.is_dir() { + if let Some(f) = walk(&p, name) { + return Some(f); + } + } else if p.file_name().is_some_and(|n| n == name) { + return Some(p); + } + } + None +} + +/// 8.0.6 item 2: the books the version before P8 wrote +/// (`old-books/p8_before`) open to the same journal, have no audit run or +/// claim, are whole (nothing for the integrity checks to find), refuse +/// the new writes until the owner's upgrade, and take them after it. +#[test] +fn books_the_version_before_p8_wrote_wait_for_the_upgrade() { + let from = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p8_before"); + let dir = test_dir("p8-before"); + std::fs::create_dir_all(dir.join("blobs")).unwrap(); + std::fs::copy(from.join(JOURNAL_FILE), dir.join(JOURNAL_FILE)).unwrap(); + fn copy(from: &std::path::Path, to: &std::path::Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let e = e.unwrap(); + if e.file_type().unwrap().is_dir() { + copy(&e.path(), &to.join(e.file_name())); + } else { + std::fs::copy(e.path(), to.join(e.file_name())).unwrap(); + } + } + } + copy(&from.join("blobs"), &dir.join("blobs")); + let mut s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert!(!s.journal_format().has_feature(AUDITS)); + assert!(s.audit_runs().unwrap().is_empty()); + assert!(s.attestations().unwrap().is_empty()); + assert_eq!(s.integrity_findings(), vec![]); + // The head is the last figure line; the filings after it don't move it. + let head = s.figure_head().unwrap(); + assert!(head > 0 && head <= s.head().0); + let jan = s.ledger().list_periods()[0].id; + let before = journal_bytes(&dir); + assert!(matches!( + s.record_audit_run(run_of(&s, d(1, 31), Scope::Month)), + Err(StoreError::FormatUpgradePending { .. }) + )); + assert!(matches!( + s.attest_period(jan, claim("high")), + Err(StoreError::FormatUpgradePending { .. }) + )); + assert_eq!(journal_bytes(&dir), before, "refused: nothing journaled"); + s.upgrade_format().unwrap(); + let run = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Month)) + .unwrap(); + s.attest_period(jan, claim("high")).unwrap(); + assert_eq!(s.figure_head().unwrap(), head, "claims are not figures"); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(s.audit_runs().unwrap(), vec![run]); +} + +/// Done-when 2: books with an unfooted cash flow, a posted entry with no +/// receipt, an open draft and a duplicate file give exactly those four +/// findings: the statements' and the store's, together. +#[test] +fn four_defects_give_exactly_four_findings() { + use ledger_core::audit::{AuditInput, statements}; + let dir = test_dir("four"); + let (mut s, fx) = { + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let card = s + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let supplies = s + .add_account_with_tax( + "6000", + "Supplies", + AccountType::Expense, + None, + false, + Some(("supplies", None)), + ) + .unwrap(); + let e = s.create_entry(d(1, 1), "Opening").unwrap(); + s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap(); + s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap(); + s.post_entry(e).unwrap(); + s.attach_receipt(Some(e), "open.txt", "text/plain", b"deposit 500.00", None) + .unwrap(); + // A purchase over the threshold with no receipt: posted through the + // primitive, as a hand entry made before the rule was tightened. + let bare = s.create_entry(d(1, 5), "Printer paper").unwrap(); + s.add_line(bare, supplies, Side::Debit, usd("40.00")) + .unwrap(); + s.add_line(bare, bank, Side::Credit, usd("40.00")).unwrap(); + s.post_entry(bare).unwrap(); + // An open draft. + let draft = s.create_entry(d(1, 9), "Stamps").unwrap(); + s.add_line(draft, supplies, Side::Debit, usd("5.00")) + .unwrap(); + s.add_line(draft, bank, Side::Credit, usd("5.00")).unwrap(); + (s, (bank, card, bare)) + }; + // A duplicate: two records an older version filed for one file. + drop(s); + let bytes = b"filed twice by an older version"; + let (sha, _) = BlobStore::open(&dir.join("blobs")) + .unwrap() + .put(bytes) + .unwrap(); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + for (id, name) in [(10, "one.pdf"), (11, "two.pdf")] { + w.append( + "receipt.attached", + json!({ + "attachment_id": id, "blob_sha": sha, "entry_id": null, + "category": "receipts", "file_name": name, + "mime_type": "application/pdf", "size_bytes": bytes.len() as u64, + "description": null, + }), + ) + .unwrap(); + } + drop(w); + s = Store::open(&dir).unwrap(); + let (bank, card, _) = fx; + // The statement of cash flows with a liability named as cash: the + // engine refuses it, and that refusal is the finding. + let params = ledger_core::CashFlowParams { + start: d(1, 1), + end: d(1, 31), + cash_accounts: vec![bank, card], + financing_accounts: vec![], + investing_accounts: vec![], + members: vec![], + }; + let made = ledger_core::cash_flow(s.ledger(), ¶ms) + .map(|c| c.reconciles) + .map_err(|e| e.to_string()); + assert!(made.is_err(), "a liability is not a cash account"); + let receipts = s.entries_with_receipts().unwrap(); + let mut found = statements( + s.ledger(), + &AuditInput { + settings: s.ledger().settings(), + receipts: &receipts, + cash_flow: Some(&made), + settings_problems: &[], + undecided_1099: &[], + }, + d(1, 31), + ); + found.extend(s.integrity_findings()); + ledger_core::audit::sort(&mut found); + let mut codes: Vec<&str> = found.iter().map(|f| f.code).collect(); + codes.sort_unstable(); + assert_eq!( + codes, + [ + "cash_flow_unfooted", + "draft_open", + "duplicate_blob", + "evidence_missing" + ], + "{found:#?}" + ); +} diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs new file mode 100644 index 0000000..720012a --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs @@ -0,0 +1,85 @@ +//! Writes `p8_before`: books the version before P8 (main b97234ac) wrote +//! through its Store API: a receipted opening and purchase, a 40.00 +//! purchase with no receipt, a small one under the threshold, a reversal, +//! an invoice with a part payment, an open draft, and a closed January. +//! No audit run, attestation or attestation setting exists in that +//! version; this version opens the same journal and must report the same +//! figures, and an audit of it finds exactly the missing receipt and the +//! open draft. +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, DocumentLine, 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 cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} +fn usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).unwrap()); + let dir = root.join("p8_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 sales = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap(); + let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap(); + let software = s.add_account("6100", "Software", 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(); + s.attach_receipt(Some(seed), "opening.txt", "text/plain", b"Opening deposit 1000.00 2026-01-01", None) + .unwrap(); + let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000); + s.post_entry(rent_jan).unwrap(); + s.attach_receipt(Some(rent_jan), "rent.txt", "text/plain", b"Rent January 500.00 2026-01-05", None) + .unwrap(); + // 40.00 is over the 25.00 threshold: no receipt, so an audit finds it. + let paper = post(&mut s, d(2026, 1, 7), "Printer paper", software, bank, 4_000); + s.post_entry(paper).unwrap(); + // Under the threshold: needs none. + let stamps = post(&mut s, d(2026, 1, 8), "Stamps", software, bank, 1_200); + s.post_entry(stamps).unwrap(); + let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000); + s.post_entry(wrong).unwrap(); + s.attach_receipt(Some(wrong), "rent2.txt", "text/plain", b"Rent again 500.00 2026-01-12", None) + .unwrap(); + s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap(); + let customer = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice( + customer, + d(2026, 1, 10), + d(2026, 2, 9), + vec![DocumentLine { + description: "Design".into(), + amount: cents(120_000), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + s.receive_payment(inv, d(2026, 1, 20), cents(40_000), bank).unwrap(); + // An open draft in February; January closes around it. + let _open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500); + let jan = s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)).unwrap(); + s.close_period(jan).unwrap(); + let _ = sales; + s.verify().unwrap(); + println!("WROTE {:?}", dir); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e new file mode 100644 index 0000000..c0a4124 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e @@ -0,0 +1 @@ +Opening deposit 1000.00 2026-01-01 \ No newline at end of file diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 new file mode 100644 index 0000000..22c35d3 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 @@ -0,0 +1 @@ +Rent again 500.00 2026-01-12 \ No newline at end of file diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f new file mode 100644 index 0000000..e77bc39 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f @@ -0,0 +1 @@ +Rent January 500.00 2026-01-05 \ No newline at end of file diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json new file mode 100644 index 0000000..4d75a14 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json @@ -0,0 +1,461 @@ +{ + "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": 4, + "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-01-07", + "description": "Printer paper", + "id": 3, + "lines": [ + { + "account": 5, + "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": "Posted" + }, + { + "date": "2026-01-08", + "description": "Stamps", + "id": 4, + "lines": [ + { + "account": 5, + "amount": { + "amount": "12.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "12.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-12", + "description": "Rent paid twice", + "id": 5, + "lines": [ + { + "account": 4, + "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-01-14", + "description": "Entered twice", + "id": 6, + "lines": [ + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": 5, + "status": "Posted" + }, + { + "date": "2026-01-10", + "description": "Invoice 1", + "id": 7, + "lines": [ + { + "account": 6, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-20", + "description": "Payment on invoice 1", + "id": 8, + "lines": [ + { + "account": 6, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 1, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-12", + "description": "Coworking day pass", + "id": 9, + "lines": [ + { + "account": 4, + "amount": { + "amount": "35.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "35.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Draft" + }, + { + "date": "2026-01-31", + "description": "Close period 2026-01", + "id": 10, + "lines": [ + { + "account": 3, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 5, + "amount": { + "amount": "52.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 7, + "amount": { + "amount": "648.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [ + [ + 7, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 8, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ] + ], + "invoices": [ + { + "amount_paid": "400.00", + "customer_id": 1, + "date": "2026-01-10", + "due_date": "2026-02-09", + "entry_id": 7, + "id": 1, + "lines": [ + { + "amount": "1200.00", + "description": "Design" + } + ], + "status": "Partial" + } + ], + "payroll_runs": [], + "periods": [ + { + "close_seq": 1, + "closing_entry": 10, + "end": "2026-01-31", + "id": 1, + "member_close": null, + "name": "2026-01", + "start": "2026-01-01", + "status": "Closed" + } + ], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "1648", + "balanced": true, + "current_earnings": "0", + "equity": "1648", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "0", + "net_income": "0", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "848", + "name": "Bank" + }, + { + "code": "1200", + "credit": "0", + "debit": "800", + "name": "Accounts Receivable" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0", + "name": "Capital" + }, + { + "code": "3999", + "credit": "648", + "debit": "0", + "name": "Retained Earnings" + } + ], + "total_credits": "1648", + "total_debits": "1648" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl new file mode 100644 index 0000000..8466158 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl @@ -0,0 +1,52 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:33:25Z","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":"a41fd982ec883c0f7dbeec0382bcf10c69246a4d063cf04997f40ad96467f00d","seq":2,"ts":"2026-10-29T20:33:25Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"f0adba7a9a2da79cc2b28f4ab2a69238da5f3ad9269e76c1c21e0027fdf86c51","seq":3,"ts":"2026-10-29T20:33:25Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"093d7586712b4e3379fea7da2923ab4b25efa7be0d533e212ea4c31ecbb81332","seq":4,"ts":"2026-10-29T20:33:25Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":3,"name":"Sales","parent":null,"placeholder":false},"prev":"2b8f70ae6d4b736aa0cc523563b47cdd1be396e3fd3a5c2f84cceb001fbaff81","seq":5,"ts":"2026-10-29T20:33:26Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"d4fc3949ba8c20b553504561a2c22f96d6a4351713ae66e83fe34f601ead0b2d","seq":6,"ts":"2026-10-29T20:33:26Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"3088d962972a512f5d1a12a8dd4da1da6ae8d1388e67ea18666b87687401b97c","seq":7,"ts":"2026-10-29T20:33:26Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"5947b37f609878df3587e37c72411cc2505fb694aab1bc9a7199cecfd2897031","seq":8,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"d0aff2093f4f8114e04eb158ddfcfc24201846214141fea1def86cd965551006","seq":9,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"850e9125ac3b5d457e611882642600a7298fa3acdd386ce2b7c9494ecb29b0d0","seq":10,"ts":"2026-10-29T20:33:26Z","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":"abc483bc19148cc173cf93402702b60ee5e31029fcdcfa704f10204c38a707ec","seq":11,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"} +{"payload":{"attachment_id":1,"blob_sha":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","category":"receipts","description":null,"entry_id":1,"file_name":"opening.txt","mime_type":"text/plain","size_bytes":34},"prev":"4a2665579fd49f5d11e27eaaca556990d58559dd1a9d8f23e9d274449ff61a8d","seq":12,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"} +{"payload":{"attachment_id":1,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","version":"0.1.0"},"prev":"d8c2d6bf43d24b9cf4b3c0a12b514cd9f77cf51f979ab54e17c6fc879fdc285f","seq":13,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"} +{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"d048136f116634d3df443725f71b4f5a9ed2cfdc45a8dba9fc9a54bb993266ff","seq":14,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"6850fe7154d84ec18c876c9c4985607b9660e1a4d6ff1c42814358039d25224d","seq":15,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"24ecb6745877198c45149e53672d81a7e8b75051770c2eac046c373ad5020694","seq":16,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"76e855cebe94f29e2bbf65e4be0634976eec4164867ec4c2152e39b21a5b9ab0","seq":17,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"} +{"payload":{"attachment_id":2,"blob_sha":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","category":"receipts","description":null,"entry_id":2,"file_name":"rent.txt","mime_type":"text/plain","size_bytes":30},"prev":"72fb7f27dba8721febaffcc2dc93870d79272ca3208abfea27d1535f46fa8334","seq":18,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"} +{"payload":{"attachment_id":2,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","version":"0.1.0"},"prev":"bd55df8f6fce00e7a55262be3123985789504189f9bc8364a194fa886719fc6a","seq":19,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"} +{"payload":{"date":"2026-01-07","description":"Printer paper","id":3},"prev":"2129339f3ce925cb9d0ff71f108ce7ba5c1dadd8432834effb2586e7632c1add","seq":20,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":5,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"3980e707c3feb852f13fff9daa38ae8bf64831d851a76b8a5e7e97eff3237506","seq":21,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"673065104b3196477ebed498501f952f62b0cc1ed8cb35cda30b00e3d4932590","seq":22,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-07","description":"Printer paper","id":3,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":1,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c2c265a5318dfc5437b94272a5ceb654e93eb982815eea919ce11ca492e69f53","seq":23,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"} +{"payload":{"date":"2026-01-08","description":"Stamps","id":4},"prev":"e54f5a63600a423bc05f572cc2e5357b591afee176de438adbfc673e10028443","seq":24,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":5,"amount":"12.00","currency":"USD","id":4,"side":"debit"},"prev":"c0acdf9d0c8fd94c66be0718354953d951f377d961da3846bba4fcfe9715577a","seq":25,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"12.00","currency":"USD","id":4,"side":"credit"},"prev":"9231e3a5b347df6caabb01248419230918234919a32552ebb509706a79f4cfd0","seq":26,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-08","description":"Stamps","id":4,"lines":[{"account":5,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"66e4fd5f78d0499f6c748e283ba81325fb43d3277a73bc7ba02e1cc43a8e3d37","seq":27,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5},"prev":"e62d56bcde5b843e5b007e5d70bec0b39159e04c0e496533341af80fb2c04e88","seq":28,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":4,"amount":"500.00","currency":"USD","id":5,"side":"debit"},"prev":"b9270e6df82d8f9972ec1ce5046113bc460e1c28b48a1d23a3f5631dae23eb6c","seq":29,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":5,"side":"credit"},"prev":"91bd76318ce8bcdc02caaff76a44ade5b63aea97fc2e9406a914f87d91b7b87b","seq":30,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"994d4a25eb115362c405208ffc926af4898c61e0938d225c3cceb1aaff2520b1","seq":31,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"attachment_id":3,"blob_sha":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","category":"receipts","description":null,"entry_id":5,"file_name":"rent2.txt","mime_type":"text/plain","size_bytes":28},"prev":"97c54bdaa6d977bddbb61f0f797903f162a5445aadad0f86fbef119d26f03885","seq":32,"ts":"2026-10-29T20:33:27Z","type":"receipt.attached"} +{"payload":{"attachment_id":3,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","version":"0.1.0"},"prev":"6908282c4f663ee92e59574a7f772d341692a684975433afb0370fe7f25ad798","seq":33,"ts":"2026-10-29T20:33:27Z","type":"attachment.text_extracted"} +{"payload":{"date":"2026-01-14","description":"Entered twice","id":6,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"d8fae93f64fa2c2fafa13ed4ed3a6f578a6d7a2af15cdef1bd2b3bed86242665","seq":34,"ts":"2026-10-29T20:33:27Z","type":"entry.reversed"} +{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"c525d0946d25b018a020b71072d59e39a6a842d85fa4c554af73965235b93aca","seq":35,"ts":"2026-10-29T20:33:27Z","type":"customer.added"} +{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":6,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"99f31ecc1df1963071728eb1927d66574cbbc48579604183b569b0507f725aaa","seq":36,"ts":"2026-10-29T20:33:27Z","type":"account.added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7},"prev":"000b3f21fbda30070ebdc436788844e60e7fb30a2c574a4fc96df1184e6a974f","seq":37,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":6,"amount":"1200.00","currency":"USD","id":7,"side":"debit"},"prev":"31912cd94f8cf8ab2381467d0c1f06e7b7d1aad7232dc34bf26976f8b07117df","seq":38,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":7,"side":"credit"},"prev":"b3544c286be658d2569eaec61f1ecf924b6345125ccdee5de44c7be69eb80d87","seq":39,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7,"lines":[{"account":6,"amount":"1200.00","currency":"USD","side":"debit"},{"account":3,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"780aa79b403d46831221d2f9de731eafe6e20e3b916fa6bcc712485234a53a41","seq":40,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":7,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"d13a0896487f5aec07d43136ad9fd10e70d4f048528ff4e7a32a9d23a74f7225","seq":41,"ts":"2026-10-29T20:33:27Z","type":"invoice.created"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8},"prev":"293d3e1a957fa177d28515cf7a4dfa324945bb73d8a2b535844fbaafa186ff1a","seq":42,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":6,"amount":"400.00","currency":"USD","id":8,"side":"credit"},"prev":"68c56c6c09a27ba436c95d4ebe2e0fb56511bf5db181ad424d7c6ed56cfd0bc3","seq":43,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"400.00","currency":"USD","id":8,"side":"debit"},"prev":"5284c73245f1f3a6d2820a6165fe720bc7b7034ad3cd07ba1320f30fed4acadc","seq":44,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8,"lines":[{"account":6,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"c2afa1d4c940bbc35df2ec94272080c5fa0dad93a9547cbec6d07ab11f4ba4f3","seq":45,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":8,"invoice_id":1},"prev":"30a6274b20559dc7f83f2b00f39da4c72bc4244c013ad63368d0f6362311e058","seq":46,"ts":"2026-10-29T20:33:27Z","type":"invoice.payment_received"} +{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":9},"prev":"adb2e790bd245c5ab0286870ad837296d0df062498c080b4349579b9f5f67ed9","seq":47,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":4,"amount":"35.00","currency":"USD","id":9,"side":"debit"},"prev":"030bdf46cf4b8dae86f000cca47fd0f95d0f7dc01f3637729c082797846a1416","seq":48,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"35.00","currency":"USD","id":9,"side":"credit"},"prev":"b6e4796ab7517782b0e5ee77126e5701246d2b4234c505bc079ba1ce93ad4b04","seq":49,"ts":"2026-10-29T20:33:28Z","type":"entry.line_added"} +{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"d5653ccd2b9fdc574ebb68c068246e70b69639f26a3934faf41eeeb77dc5f8bb","seq":50,"ts":"2026-10-29T20:33:28Z","type":"period.created"} +{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"14a0f2b42d59a5a18e901c935c1fe15198d6a8d308917359b840504b74700cb8","seq":51,"ts":"2026-10-29T20:33:28Z","type":"account.added"} +{"payload":{"close_to":"retained_earnings","closing_entry":10,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":10,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":5,"amount":"52.00","currency":"USD","side":"credit"},{"account":7,"amount":"648.00","currency":"USD","side":"credit"}]},"id":1},"prev":"724a1d45168bcb5282dd894952b98aa4b364641158c42e9204758de8c2afeba2","seq":52,"ts":"2026-10-29T20:33:28Z","type":"period.closed"} diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 9ae33fe..c0483b7 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -150,7 +150,7 @@ fn assert_corrupt(got: Result) { fn old_reader_gets_newer_journal() { let cases = [ ( - "period.attested", + "ledger.not_yet_written", json!({"key": "company.name", "value": "X"}), "an event type this version doesn't know", ), @@ -243,12 +243,24 @@ fn the_writer_and_the_viewer_agree_on_newer_books() { &[ // Chained and of a known type, but replay refuses it. ("entry.posted", json!({"id": 99})), - ("period.attested", json!({"key": "company.name"})), + ("ledger.not_yet_written", json!({"key": "company.name"})), ], )[1]; let need = "an event type this version doesn't know"; - assert_newer(Store::open(&dir), seq, "period.attested", need, false); - assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false); + assert_newer( + Store::open(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); + assert_newer( + Snapshot::load(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); } /// Damage stays corruption, whatever the damaged line's type: the gate @@ -282,7 +294,7 @@ fn damaged_journals_are_still_corrupt() { ]; let newer = [ (FORMAT_SET, json!({"format": 3, "features": []})), - ("period.attested", json!({"key": "company.name"})), + ("ledger.not_yet_written", json!({"key": "company.name"})), ]; for (name, damage) in damages { for (i, last) in newer.iter().enumerate() { @@ -566,32 +578,32 @@ fn a_newer_format_frames_its_lines_its_own_way() { let nexts: [(&str, Next, Option); 6] = [ ( "float", - |seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}), + |seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": prev}), Some(true), ), ( "string-seq", - |seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}), + |seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev}), Some(true), ), ( "envelope-key", - |seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}), + |seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev, "actor": "agent"}), Some(true), ), ( "no-prev", - |seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}), + |seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "parent": prev}), Some(false), ), ( "prev-not-text", - |seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}), + |seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": 1.5}), Some(false), ), ( "wrong-prev", - |seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}), + |seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": "00"}), None, ), ]; @@ -686,11 +698,23 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() { } else { // After a refused line, it is a next line without a readable // prev: unverified, for both. - let seq = append(&dir, &[("period.attested", json!({}))])[0]; + let seq = append(&dir, &[("ledger.not_yet_written", json!({}))])[0]; append_raw(&dir, b"{\"prev\":\"\xff\"}\n"); let need = "an event type this version doesn't know"; - assert_newer(Store::open(&dir), seq, "period.attested", need, false); - assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false); + assert_newer( + Store::open(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); + assert_newer( + Snapshot::load(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); } } } @@ -703,12 +727,21 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() { fn an_old_reader_never_repairs_newer_books() { let dir = test_dir("newer-torn"); books(&dir); - let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0]; + let seq = append( + &dir, + &[("ledger.not_yet_written", json!({"key": "company.name"}))], + )[0]; append_raw(&dir, b"{\"seq\":"); let path = dir.join(JOURNAL_FILE); let before = std::fs::read(&path).unwrap(); let need = "an event type this version doesn't know"; - assert_newer(Store::open(&dir), seq, "period.attested", need, false); + assert_newer( + Store::open(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); assert_eq!(std::fs::read(&path).unwrap(), before); let set_aside: Vec<_> = std::fs::read_dir(&dir) .unwrap() diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 8ad72ba..d5ad434 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -2181,6 +2181,37 @@ fn books_written_under_older_rules_still_open() { json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}), ) .unwrap(); + // P8's new shapes, hand-written as this version writes them: an audit + // run and the three kinds of attestation (the method is the client's + // own claim). This reader takes them; the version before it stopped at + // the declaration. + let method = json!({"model": "opus", "reasoning_effort": "high", + "adversarial": true, "verifier_agents": 2}); + w.append( + "audit.run", + json!({"id": 1, "through": "2026-05-31", "through_seq": 30, "scope": "month", + "program_version": "audit-0.1.0-1", "tb_hash": "aa", "findings_sha256": "bb", + "workpaper_attachment": 6, "workpaper_pdf": 7}), + ) + .unwrap(); + w.append( + "receipt.attested", + json!({"attachment_id": 5, "entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:00Z", + "method": method, "amount_checked": "99.99", "date_checked": "2026-04-01", + "disagrees": false, "note": "read the page"}), + ) + .unwrap(); + w.append( + "entry.reviewed", + json!({"entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:01Z", "method": {}}), + ) + .unwrap(); + w.append( + "period.attested", + json!({"period_id": 1, "by": "verifier", "at": "2026-06-01T00:00:02Z", + "method": method, "audit_run_id": 1}), + ) + .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())); @@ -2210,6 +2241,17 @@ fn books_written_under_older_rules_still_open() { assert_eq!(annotation.fields.receipt_number, Some(3)); assert_eq!(annotation.fields.payee.as_deref(), Some("Acme")); assert_eq!(store.ledger().next_receipt_number(), 4); + // P8's shapes replayed: the run and the three claims, as written. + let runs = store.audit_runs().unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].scope, ledger_core::audit::Scope::Month); + assert_eq!(runs[0].workpaper_pdf, Some(7)); + let claims = store.attestations().unwrap(); + assert_eq!(claims.len(), 3); + assert_eq!(claims[0].kind(), "receipt"); + assert_eq!(claims[0].method.verifier_agents, Some(2)); + assert_eq!(claims[1].method, ledger_core::audit::Method::default()); + assert_eq!(claims[2].kind(), "period"); assert_eq!(store.ledger().entry_settles(7), [1]); assert!(store.ledger().settles_entry(1)); let acme = store.ledger().get_party(1).expect("the party"); diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index e9138c1..2869326 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -46,6 +46,7 @@ const TYPES: &[&str] = &[ "project.set", "entry.settles", "entry.draft_state_set", "entry.draft_discarded", "convention.source_revised", "decision.source_revised", "open_item.source_revised", + "audit.run", "receipt.attested", "entry.reviewed", "period.attested", // Merged 2026-10-28 (S7, P6): every type EVENT_TYPES names. ]; @@ -68,6 +69,9 @@ const FIELDS: &[&str] = &[ "tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention", "status", "party_id", "started_on", "ended_on", "links", "notes", "projects", "via", "by", "to", "reference", "note", "state", + "through", "through_seq", "scope", "program_version", "tb_hash", "findings_sha256", + "workpaper_attachment", "workpaper_pdf", "at", "amount_checked", "date_checked", + "disagrees", "audit_run_id", "period_id", "verifier_agents", "reasoning_effort", ]; /// Journal features a `journal.format_set` may declare.