Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.
- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
and leaves the receipt filed, unlinked. The receipt policy holds: an
entry that requires a receipt can't lose its last one (structured
receipt_required error), so a replacement is attach-new, then
unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
attachment.withdrawn for an unlinked receipt or a filed document: hidden
from list_receipts (unless include_withdrawn), never linkable again. The
blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
Before, re-linking silently moved a receipt off a posted entry,
bypassing the policy; evidence now leaves an entry only through a
recorded unlink. dispose_asset checks its receipt ids before posting
instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
stores migrate on open and replay rebuilds the projection.
Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the the first company books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
76 lines
2.3 KiB
Rust
76 lines
2.3 KiB
Rust
//! Error type for the store.
|
|
|
|
use thiserror::Error;
|
|
|
|
/// Every way a store operation can fail.
|
|
#[derive(Debug, Error)]
|
|
pub enum StoreError {
|
|
/// Filesystem error.
|
|
#[error("io error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
/// SQLite error.
|
|
#[error("sqlite error: {0}")]
|
|
Rusqlite(#[from] rusqlite::Error),
|
|
|
|
/// JSON error.
|
|
#[error("json error: {0}")]
|
|
Json(#[from] serde_json::Error),
|
|
|
|
/// The engine rejected the operation. The journal is untouched.
|
|
#[error("ledger error: {0}")]
|
|
Ledger(#[from] ledger_core::LedgerError),
|
|
|
|
/// The journal failed integrity verification.
|
|
#[error("journal corrupt: {0}")]
|
|
JournalCorrupt(String),
|
|
|
|
/// Replay produced different ids than the journal recorded.
|
|
#[error("replay mismatch: {0}")]
|
|
ReplayMismatch(String),
|
|
|
|
/// [`crate::Store::verify`] found divergence.
|
|
#[error("verify failed: {0}")]
|
|
VerifyMismatch(String),
|
|
|
|
/// A blob's bytes did not match its SHA-256.
|
|
#[error("blob corrupt: hash mismatch for {0}")]
|
|
CorruptBlob(String),
|
|
|
|
/// Referenced attachment does not exist.
|
|
#[error("unknown attachment {0}")]
|
|
UnknownAttachment(u64),
|
|
|
|
/// The attachment is not linked to any entry.
|
|
#[error("attachment {0} is not linked to an entry")]
|
|
AttachmentNotLinked(u64),
|
|
|
|
/// The attachment is linked to an entry; unlink it first.
|
|
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
|
|
AttachmentLinked(u64, u64),
|
|
|
|
/// The attachment was withdrawn and can no longer be linked or changed.
|
|
#[error("attachment {0} is withdrawn")]
|
|
AttachmentWithdrawn(u64),
|
|
|
|
/// A correction to the evidence trail needs a reason.
|
|
#[error("a non-empty reason is required")]
|
|
ReasonRequired,
|
|
|
|
/// The store's schema is newer than this binary understands.
|
|
#[error("schema version {0} newer than supported {1}")]
|
|
SchemaTooNew(u32, u32),
|
|
|
|
/// `create` called where a store already exists.
|
|
#[error("store already exists at {0}")]
|
|
AlreadyExists(String),
|
|
|
|
/// `open` called where no store exists.
|
|
#[error("store not found at {0}")]
|
|
NotFound(String),
|
|
|
|
/// The requested base currency differs from the store's.
|
|
#[error("base currency mismatch: store is {0}, requested {1}")]
|
|
CurrencyMismatch(String, String),
|
|
}
|