From e59e8001c684b68973e6e436a3e9106b8644e348 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 11 Oct 2026 18:40:51 -0700 Subject: [PATCH] feat: receipt unlink and attachment withdrawal 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 Wanderland Labs books: 8 migrated .eml attachments swapped for their PDF renders, trial balance unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu --- crates/ledger-mcp/src/server.rs | 104 +++++++++++++++++++- crates/ledger-mcp/src/types.rs | 44 +++++++++ crates/ledger-mcp/tests/mcp_stdio.rs | 137 ++++++++++++++++++++++++++- crates/ledger-store/src/error.rs | 16 ++++ crates/ledger-store/src/journal.rs | 3 +- crates/ledger-store/src/schema.rs | 9 +- crates/ledger-store/src/store.rs | 128 ++++++++++++++++++++++--- crates/ledger-store/tests/store.rs | 112 +++++++++++++++++++++- docs/capability-parity.md | 2 +- docs/mcp-interface.md | 18 +++- docs/receipt-store.md | 12 ++- docs/requirements.md | 6 ++ plan/STATUS.md | 10 ++ 13 files changed, 580 insertions(+), 21 deletions(-) diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 671c689..91ce0ab 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -21,7 +21,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -55,6 +55,43 @@ impl BooksServer { .lock() .map_err(|_| "store lock poisoned".to_string()) } + + /// Fail with the structured `receipt_required` error when the receipt + /// policy requires this posted entry to keep at least one receipt. + /// Drafts pass (posting enforces the policy later); reversals pass (they + /// are exempt: the original carries the evidence). + fn ensure_receipt_not_required(&self, store: &Store, entry_id: u64) -> Result<(), String> { + let entry = store + .ledger() + .get_entry(entry_id) + .ok_or_else(|| format!("unknown entry {entry_id}"))?; + if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() { + return Ok(()); + } + let split_group = self.policy.split_group(store.ledger(), entry_id, entry); + if !self.policy.receipt_required(store.ledger(), entry) && split_group.is_empty() { + return Ok(()); + } + let total = ReceiptPolicy::entry_debits(entry); + Err(ReceiptRequiredError { + code: "receipt_required".to_string(), + message: format!( + "entry {entry_id} moves {} in debits ({}); unlinking its last receipt would leave it without one.", + money(total), + self.policy.describe() + ), + entry_id: Some(entry_id), + asset_id: None, + debits: total.to_string(), + policy: self.policy.describe(), + remedy_tool: "books.attach_receipt".to_string(), + remedy_hint: format!( + "attach the replacement receipt to entry {entry_id} with books.attach_receipt first, then retry books.unlink_receipt" + ), + split_group, + } + .to_tool_error()) + } } // --------------------------------------------------------------------------- @@ -846,9 +883,65 @@ impl BooksServer { Ok(Json(LinkReceiptOut { ok: true })) } + #[tool( + name = "books.unlink_receipt", + description = "Detach a receipt from its entry, with a reason recorded in the journal. The receipt stays filed (unlinked); link it elsewhere or withdraw it with books.withdraw_attachment. The receipt policy still holds: an entry that requires a receipt cannot be left with none, so to replace a receipt attach the new one first, then unlink the old." + )] + async fn unlink_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + let attachment = store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == p.attachment_id) + .ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?; + if let Some(entry_id) = attachment.entry_id { + let remaining = store + .list_attachments(Some(entry_id)) + .map_err(|e| e.to_string())? + .len() + - 1; + if remaining == 0 { + self.ensure_receipt_not_required(&store, entry_id)?; + } + } + let entry_id = store + .unlink_receipt(p.attachment_id, &p.reason) + .map_err(|e| e.to_string())?; + let remaining_receipts = store + .list_attachments(Some(entry_id)) + .map_err(|e| e.to_string())? + .len() as u64; + Ok(Json(UnlinkReceiptOut { + ok: true, + entry_id, + remaining_receipts, + })) + } + + #[tool( + name = "books.withdraw_attachment", + description = "Withdraw an unlinked receipt or a filed document, with a reason recorded in the journal. It drops out of books.list_receipts and can never be linked again. Nothing is deleted: the bytes stay in the blob store and the journal keeps the filing and the withdrawal. Unlink a linked receipt first." + )] + async fn withdraw_attachment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .withdraw_attachment(p.attachment_id, &p.reason) + .map_err(|e| e.to_string())?; + Ok(Json(WithdrawAttachmentOut { ok: true })) + } + #[tool( name = "books.list_receipts", - description = "List receipts and filed documents, optionally filtered to one entry.", + description = "List receipts and filed documents, optionally filtered to one entry. Withdrawn attachments are hidden unless include_withdrawn=true.", annotations(read_only_hint = true) )] async fn list_receipts( @@ -857,10 +950,12 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let store = self.lock()?; + let include_withdrawn = p.include_withdrawn.unwrap_or(false); let receipts = store .list_attachments(p.entry_id) .map_err(|e| e.to_string())? .into_iter() + .filter(|a| include_withdrawn || a.withdrawn_at.is_none()) .map(|a| ReceiptOut { attachment_id: a.id, sha256: a.blob_sha, @@ -870,6 +965,8 @@ impl BooksServer { mime_type: a.mime_type, size_bytes: a.size_bytes, description: a.description, + withdrawn: a.withdrawn_at.is_some(), + withdrawn_reason: a.withdrawn_reason, }) .collect(); Ok(Json(ListReceiptsOut { receipts })) @@ -1687,6 +1784,9 @@ impl BooksServer { } .to_tool_error()); } + for aid in &attachment_ids { + store.check_linkable(*aid).map_err(|e| e.to_string())?; + } let entry_id = store .dispose_asset( p.asset_id, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index a9fc7e2..6b2f5e1 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -503,11 +503,51 @@ pub struct LinkReceiptOut { pub ok: bool, } +/// Parameters for `books.unlink_receipt`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct UnlinkReceiptParams { + /// Attachment id of the linked receipt. + pub attachment_id: u64, + /// Why it is being unlinked, recorded in the journal (e.g. "replaced by + /// the PDF render, attachment 9"). + pub reason: String, +} + +/// Result of `books.unlink_receipt`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct UnlinkReceiptOut { + /// True when unlinked. + pub ok: bool, + /// The entry it was unlinked from. + pub entry_id: u64, + /// Receipts still linked to that entry. + pub remaining_receipts: u64, +} + +/// Parameters for `books.withdraw_attachment`. +#[derive(Debug, Deserialize, JsonSchema)] +pub struct WithdrawAttachmentParams { + /// Attachment id of an unlinked receipt or a filed document. + pub attachment_id: u64, + /// Why it is being withdrawn, recorded in the journal. + pub reason: String, +} + +/// Result of `books.withdraw_attachment`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct WithdrawAttachmentOut { + /// True when withdrawn. + pub ok: bool, +} + /// Parameters for `books.list_receipts`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ListReceiptsParams { /// Filter to one entry. Omit for all. pub entry_id: Option, + /// Include withdrawn attachments. Default false. + #[serde(default)] + pub include_withdrawn: Option, } /// One receipt or document. @@ -529,6 +569,10 @@ pub struct ReceiptOut { pub size_bytes: u64, /// Description, if any. pub description: Option, + /// True when withdrawn (only listed with include_withdrawn). + pub withdrawn: bool, + /// Why it was withdrawn, if it was. + pub withdrawn_reason: Option, } /// Result of `books.list_receipts`. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 044abe6..0e759e6 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -134,7 +134,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: 23 narrow tools, chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 48, "expected 48 tools, got {}", tools.len()); + assert_eq!(tools.len(), 50, "expected 50 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( @@ -534,6 +534,141 @@ fn mcp_receipt_policy_rejects_split_entries() { assert_eq!(p3["status"], "posted"); } +#[test] +fn mcp_receipt_corrections_over_stdio() { + let dir = test_dir("receipt-corrections"); + let mut mcp = Mcp::spawn(&dir); + mcp.request( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "smoke-test", "version": "0.1.0"}, + }), + ); + mcp.notify("notifications/initialized"); + for (code, name, typ) in [("1000", "Cash", "asset"), ("6000", "Software", "expense")] { + mcp.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": typ}), + ); + } + let post = |mcp: &mut Mcp, desc: &str, amount: &str, receipt: &[u8]| -> (u64, u64) { + let e = mcp.call( + "books.draft_entry", + json!({"date": "2026-04-01", "description": desc}), + ); + let id = e["entry_id"].as_u64().unwrap(); + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": "6000", "side": "debit", "amount": amount}), + ); + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}), + ); + let att = mcp.call( + "books.attach_receipt", + json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822", + "content_base64": base64_like(receipt)}), + ); + let posted = mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert_eq!(posted["status"], "posted"); + (id, att["attachment_id"].as_u64().unwrap()) + }; + + // A $100 entry whose only receipt is an .eml. + let (big, eml) = post(&mut mcp, "Annual license", "100.00", b"eml-bytes"); + + // Unlinking its only receipt would break the policy: refused, with the remedy. + let err = mcp.call_err( + "books.unlink_receipt", + json!({"attachment_id": eml, "reason": "replacing with PDF"}), + ); + let err_json: serde_json::Value = serde_json::from_str(&err).unwrap(); + assert_eq!(err_json["code"], "receipt_required"); + assert_eq!(err_json["entry_id"], big); + assert_eq!(err_json["remedy_tool"], "books.attach_receipt"); + + // Replace: attach the PDF first, then unlink the .eml. + let pdf = mcp.call( + "books.attach_receipt", + json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf", + "content_base64": base64_like(b"pdf-bytes")}), + )["attachment_id"] + .as_u64() + .unwrap(); + let un = mcp.call( + "books.unlink_receipt", + json!({"attachment_id": eml, "reason": "replaced by the PDF render"}), + ); + assert_eq!(un["entry_id"], big); + assert_eq!(un["remaining_receipts"], 1); + + // A linked receipt can't be withdrawn; an unlinked one can, with a reason. + let err = mcp.call_err( + "books.withdraw_attachment", + json!({"attachment_id": pdf, "reason": "x"}), + ); + assert!(err.contains("unlink it first"), "{err}"); + let err = mcp.call_err( + "books.withdraw_attachment", + json!({"attachment_id": eml, "reason": " "}), + ); + assert!(err.contains("reason"), "{err}"); + mcp.call( + "books.withdraw_attachment", + json!({"attachment_id": eml, "reason": "file PDFs, not .eml"}), + ); + + // Listings hide the withdrawn .eml unless asked; the entry keeps the PDF. + let listed = mcp.call("books.list_receipts", json!({})); + let ids: Vec = listed["receipts"] + .as_array() + .unwrap() + .iter() + .map(|r| r["attachment_id"].as_u64().unwrap()) + .collect(); + assert_eq!(ids, vec![pdf]); + let all = mcp.call("books.list_receipts", json!({"include_withdrawn": true})); + let gone = all["receipts"] + .as_array() + .unwrap() + .iter() + .find(|r| r["attachment_id"] == eml) + .unwrap() + .clone(); + assert_eq!(gone["withdrawn"], true); + assert_eq!(gone["withdrawn_reason"], "file PDFs, not .eml"); + assert_eq!(gone["entry_id"], serde_json::Value::Null); + let entry = mcp.call("books.get_entry", json!({"entry_id": big})); + assert_eq!(entry["receipts"].as_array().unwrap().len(), 1); + assert_eq!(entry["receipts"][0]["attachment_id"], pdf); + // A withdrawn attachment can never be linked again. + let err = mcp.call_err( + "books.link_receipt", + json!({"attachment_id": eml, "entry_id": big}), + ); + assert!(err.contains("withdrawn"), "{err}"); + + // Under the threshold, the last receipt may go. + let (small, small_eml) = post(&mut mcp, "Domain renewal", "10.00", b"small-eml"); + let un = mcp.call( + "books.unlink_receipt", + json!({"attachment_id": small_eml, "reason": "wrong file"}), + ); + assert_eq!(un["entry_id"], small); + assert_eq!(un["remaining_receipts"], 0); + + // The books never moved. + let tb = mcp.call("books.trial_balance", json!({})); + assert_eq!(tb["balanced"], true); + assert_eq!(tb["total_debits"], "110.00"); +} + #[test] fn mcp_resources_over_stdio() { let dir = test_dir("resources"); diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 5664ef2..dab4c83 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -41,6 +41,22 @@ pub enum StoreError { #[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), diff --git a/crates/ledger-store/src/journal.rs b/crates/ledger-store/src/journal.rs index d64904f..10afae8 100644 --- a/crates/ledger-store/src/journal.rs +++ b/crates/ledger-store/src/journal.rs @@ -29,7 +29,8 @@ pub struct JournalLine { pub ts: String, /// Event type: `account.added`, `entry.created`, `entry.line_added`, /// `entry.posted`, `entry.reversed`, `entry.line_class_set`, - /// `receipt.attached`, `receipt.linked`, `document.filed`, + /// `receipt.attached`, `receipt.linked`, `receipt.unlinked`, + /// `document.filed`, `attachment.withdrawn`, /// `asset.registered`, `asset.depreciation_posted`, `asset.disposed`, /// `period.created`, `period.closed`, `line.cleared`, `customer.added`, /// `vendor.added`, `invoice.created`, `bill.created`, diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 2df4971..f04b67c 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 = 4; +pub const SCHEMA_VERSION: u32 = 5; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -172,5 +172,12 @@ CREATE TABLE IF NOT EXISTS payroll_lines( ); CREATE INDEX IF NOT EXISTS idx_payroll_lines_run ON payroll_lines(run_id); ALTER TABLE lines ADD COLUMN class_id INTEGER; +"#, + r#" +-- v5: withdrawn attachments. Withdrawal retires a receipt or document from +-- listings and linking; the bytes stay in the blob store and the journal +-- keeps the filing and the withdrawal. +ALTER TABLE attachments ADD COLUMN withdrawn_at TEXT; +ALTER TABLE attachments ADD COLUMN withdrawn_reason TEXT; "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 067af24..a9e92e8 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -115,6 +115,10 @@ pub struct Attachment { pub description: Option, /// UTC timestamp when filed. pub created_at: String, + /// UTC timestamp when withdrawn, if it was. + pub withdrawn_at: Option, + /// Why it was withdrawn, if it was. + pub withdrawn_reason: Option, } /// Input to [`Store::run_payroll`]: one employee's gross pay and every @@ -1757,7 +1761,10 @@ impl Store { Ok((id, sha, dedup)) } - /// Link a previously filed receipt to an entry. + /// Link a previously filed (unlinked) receipt to an entry. A receipt + /// already linked elsewhere must be unlinked first, so moving evidence + /// off an entry always goes through [`Store::unlink_receipt`] and its + /// recorded reason. pub fn link_receipt( &mut self, attachment_id: u64, @@ -1766,14 +1773,7 @@ impl Store { self.ledger .get_entry(entry_id) .ok_or(LedgerError::UnknownEntry(entry_id))?; - let exists: i64 = self.conn.query_row( - "SELECT COUNT(*) FROM attachments WHERE id = ?", - params![attachment_id as i64], - |r| r.get(0), - )?; - if exists == 0 { - return Err(StoreError::UnknownAttachment(attachment_id)); - } + self.check_linkable(attachment_id)?; let line = self.append_journal( "receipt.linked", json!({ "attachment_id": attachment_id, "entry_id": entry_id }), @@ -1782,6 +1782,89 @@ impl Store { Ok(()) } + /// Fail unless the attachment exists, is unlinked, and is not + /// withdrawn: the precondition of [`Store::link_receipt`], checkable + /// before posting anything that will link it. + pub fn check_linkable(&self, attachment_id: u64) -> Result<(), StoreError> { + let (entry_id, withdrawn) = self.attachment_state(attachment_id)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(attachment_id)); + } + if let Some(eid) = entry_id { + return Err(StoreError::AttachmentLinked(attachment_id, eid)); + } + Ok(()) + } + + /// Detach a receipt from its entry, recording why. The attachment stays + /// filed (unlinked) and can be linked again or withdrawn. The journal + /// keeps both the link and the unlink: the evidence history is + /// append-only even when the current evidence set changes. Returns the + /// entry it was linked to. + /// + /// Policy-free like the rest of the store: whether an entry may be left + /// without a receipt is the MCP layer's call. + pub fn unlink_receipt( + &mut self, + attachment_id: u64, + reason: &str, + ) -> Result { + let reason = reason.trim(); + if reason.is_empty() { + return Err(StoreError::ReasonRequired); + } + let (entry_id, withdrawn) = self.attachment_state(attachment_id)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(attachment_id)); + } + let entry_id = entry_id.ok_or(StoreError::AttachmentNotLinked(attachment_id))?; + let line = self.append_journal( + "receipt.unlinked", + json!({ "attachment_id": attachment_id, "entry_id": entry_id, "reason": reason }), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(entry_id) + } + + /// Withdraw a receipt or document, recording why: it drops out of + /// listings and can never be linked again. Nothing is deleted: the bytes + /// stay in the blob store and the journal keeps the filing and the + /// withdrawal. A linked receipt must be unlinked first. + pub fn withdraw_attachment( + &mut self, + attachment_id: u64, + reason: &str, + ) -> Result<(), StoreError> { + let reason = reason.trim(); + if reason.is_empty() { + return Err(StoreError::ReasonRequired); + } + self.check_linkable(attachment_id)?; + let line = self.append_journal( + "attachment.withdrawn", + json!({ "attachment_id": attachment_id, "reason": reason }), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// `(linked entry, withdrawn?)` for an attachment. + fn attachment_state(&self, attachment_id: u64) -> Result<(Option, bool), StoreError> { + self.conn + .query_row( + "SELECT entry_id, withdrawn_at IS NOT NULL FROM attachments WHERE id = ?", + params![attachment_id as i64], + |r| { + Ok(( + r.get::<_, Option>(0)?.map(|v| v as EntryId), + r.get::<_, bool>(1)?, + )) + }, + ) + .optional()? + .ok_or(StoreError::UnknownAttachment(attachment_id)) + } + /// List attachments, optionally filtered to one entry. pub fn list_attachments( &self, @@ -1789,7 +1872,8 @@ impl Store { ) -> Result, StoreError> { let mut sql = String::from( "SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \ - b.size_bytes, a.description, a.created_at FROM attachments a \ + b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \ + FROM attachments a \ JOIN blobs b ON b.sha256 = a.blob_sha", ); if entry_id.is_some() { @@ -2594,7 +2678,11 @@ fn replay_line_into_ledger( ))); } } - "receipt.attached" | "receipt.linked" | "document.filed" => { + "receipt.attached" + | "receipt.linked" + | "receipt.unlinked" + | "document.filed" + | "attachment.withdrawn" => { // Not accounting state; the projection records them. } "asset.registered" => { @@ -2923,6 +3011,22 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto ], )?; } + "receipt.unlinked" => { + conn.execute( + "UPDATE attachments SET entry_id = NULL WHERE id = ?", + params![get_u64(p, "attachment_id")? as i64], + )?; + } + "attachment.withdrawn" => { + conn.execute( + "UPDATE attachments SET withdrawn_at = ?, withdrawn_reason = ? WHERE id = ?", + params![ + line.ts, + get_str(p, "reason")?, + get_u64(p, "attachment_id")? as i64, + ], + )?; + } "asset.registered" => { let cost = &p["cost"]; let salvage = &p["salvage_value"]; @@ -3184,5 +3288,7 @@ fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result { size_bytes: r.get::<_, i64>(6)? as u64, description: r.get(7)?, created_at: r.get(8)?, + withdrawn_at: r.get(9)?, + withdrawn_reason: r.get(10)?, }) } diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index a2c662b..b012681 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -5,7 +5,7 @@ use chrono::NaiveDate; use ledger_core::{AccountType, Currency, Money, Side}; -use ledger_store::{JournalWriter, Store}; +use ledger_store::{JournalWriter, Store, StoreError}; use rust_decimal::Decimal; use std::path::PathBuf; @@ -347,3 +347,113 @@ fn corrupt_projection_is_quarantined_and_rebuilt() { assert_eq!(tb.total_debits, Decimal::from(14000)); store.verify().unwrap(); } + +#[test] +fn receipt_corrections_are_journaled_and_survive_rebuild() { + let dir = test_dir("receipt-corrections"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rent = store + .add_account("6000", "Rent Expense", AccountType::Expense, None, false) + .unwrap(); + let e = store + .create_entry( + NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), + "February rent", + ) + .unwrap(); + store.add_line(e, rent, Side::Debit, usd(800)).unwrap(); + store.add_line(e, cash, Side::Credit, usd(800)).unwrap(); + store.post_entry(e).unwrap(); + let (eml, _, _) = store + .attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None) + .unwrap(); + let (pdf, _, _) = store + .attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None) + .unwrap(); + + // Every correction needs a reason. + assert!(matches!( + store.unlink_receipt(eml, " "), + Err(StoreError::ReasonRequired) + )); + // A linked receipt can't be withdrawn or moved: unlink it first. + assert!(matches!( + store.withdraw_attachment(eml, "dup"), + Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e + )); + assert!(matches!( + store.link_receipt(pdf, e), + Err(StoreError::AttachmentLinked(_, _)) + )); + + // Swap the .eml for the PDF: unlink, then withdraw. + assert_eq!( + store + .unlink_receipt(eml, "replaced by the PDF render") + .unwrap(), + e + ); + assert!(matches!( + store.unlink_receipt(eml, "again"), + Err(StoreError::AttachmentNotLinked(_)) + )); + store + .withdraw_attachment(eml, "file PDFs, not .eml") + .unwrap(); + assert!(matches!( + store.link_receipt(eml, e), + Err(StoreError::AttachmentWithdrawn(_)) + )); + assert!(matches!( + store.withdraw_attachment(eml, "again"), + Err(StoreError::AttachmentWithdrawn(_)) + )); + assert!(matches!( + store.unlink_receipt(999, "x"), + Err(StoreError::UnknownAttachment(999)) + )); + + let check = |store: &Store| { + let linked: Vec = store + .list_attachments(Some(e)) + .unwrap() + .iter() + .map(|a| a.id) + .collect(); + assert_eq!(linked, vec![pdf]); + let all = store.list_attachments(None).unwrap(); + let gone = all.iter().find(|a| a.id == eml).unwrap(); + assert_eq!(gone.entry_id, None); + assert!(gone.withdrawn_at.is_some()); + assert_eq!( + gone.withdrawn_reason.as_deref(), + Some("file PDFs, not .eml") + ); + // Withdrawn is not deleted: the bytes are still there, hash-verified. + assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes"); + }; + check(&store); + + // The journal is the history: the attach, the unlink, and the withdrawal. + let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap(); + for t in [ + "receipt.attached", + "receipt.unlinked", + "attachment.withdrawn", + ] { + assert!( + journal.contains(&format!("\"type\":\"{t}\"")), + "missing {t}" + ); + } + + drop(store); + let mut store = Store::open(&dir).unwrap(); + check(&store); + store.rebuild().unwrap(); + check(&store); + store.verify().unwrap(); +} diff --git a/docs/capability-parity.md b/docs/capability-parity.md index 7054279..0dcf0c2 100644 --- a/docs/capability-parity.md +++ b/docs/capability-parity.md @@ -18,7 +18,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed), | 4 | Live Balance Sheet tab | engine + `books.balance_sheet` | covered | | 5 | Live Cash Flow tab | engine `cash_flow` (indirect) + `books.cash_flow` | covered | | 6 | Transaction Journal as append-only log | engine posting log + `books.search_entries` | covered | -| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` | covered | +| 7 | Receipt storage in Drive | blob store + `books.attach_receipt` / `link_receipt` / `unlink_receipt` / `withdraw_attachment` | covered | | 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered | | 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered | | 10 | Human review of what the assistant recorded | `books.digest` | covered | diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 00bfb92..58abc82 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -117,8 +117,22 @@ so the lifetime total is exactly (cost − salvage). → `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is optional: a receipt can be filed before its entry exists. - `books.link_receipt { receipt_id, entry_id }` → link a previously filed - receipt to its entry. -- `books.list_receipts { entry_id? }` → metadata list (no bytes). + (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere + must be unlinked first. +- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id, + remaining_receipts }`. Detaches a receipt from its entry; it stays filed, + unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the + receipt policy: an entry that requires a receipt cannot be left with none + (`receipt_required` error, remedy `books.attach_receipt`), so replacing a + receipt is attach-new, then unlink-old. +- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires + an unlinked receipt or a filed document: hidden from listings, never + linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the + blob store. Nothing in the evidence trail is ever erased — receipts change + by new events, the same way entries change by reversals. +- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list + (no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn + attachments are omitted unless `include_withdrawn` is true. - `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`. - `books.file_document { category: "formation" | "legal" | "tax" | "accounting" | "other", file_name, mime_type, content_base64, diff --git a/docs/receipt-store.md b/docs/receipt-store.md index ec276a1..ef4355c 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -50,7 +50,9 @@ apply is safe — on startup the applier replays every journal line with - `seq`: 1-based, gapless, assigned by the writer. - `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders). - `type`: `account.added`, `entry.posted`, `entry.reversed`, - `receipt.attached`, `receipt.linked`, `document.filed`. + `receipt.attached`, `receipt.linked`, `receipt.unlinked`, + `document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset, + period, payroll, and class events listed on `JournalLine`). - `payload`: the full domain facts (account fields, entry with lines, blob sha, ...). Everything the applier needs; nothing it must look up elsewhere. - `prev`: hex SHA-256 of the previous line's canonical JSON (keys sorted, no @@ -99,6 +101,14 @@ before backups. plus `receipts`. - Documents filed via `books.file_document` and receipts via `books.attach_receipt` share the blob store; they differ only in metadata. +- **Receipts change by events, never by edits.** `receipt.unlinked` + (`attachment_id`, `entry_id`, `reason`) detaches a receipt from its entry; + `attachment.withdrawn` (`attachment_id`, `reason`) retires an unlinked + receipt or a document (`withdrawn_at`, `withdrawn_reason` columns, schema + v5). Linking requires an unlinked, non-withdrawn attachment, so evidence + only leaves an entry through a recorded unlink. Blobs are untouched: a + withdrawn file is still hash-verified and readable, and the journal shows + when it was filed, linked, unlinked, and withdrawn, and why. - The store is policy-free: it records attachments faithfully. Whether a posting *requires* a receipt is decided by the MCP layer's receipt policy (`docs/mcp-interface.md`), which reads the `attachments` table. diff --git a/docs/requirements.md b/docs/requirements.md index 290b6f7..3556a09 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -52,6 +52,12 @@ requirement conflict, the requirement wins. ## Receipt policy (MCP phase) +- **R-13a — Receipt corrections.** Posted entries are immutable (R-3); their + evidence is not frozen but is append-only. A receipt is replaced by + attaching the new one and unlinking the old with a reason; unwanted + receipts and documents are withdrawn with a reason. Unlinking may never + leave an entry the policy (R-13) requires a receipt for with none. Every + step is a journal event; no blob is deleted. - **R-13 — Receipt enforcement.** The MCP layer (not the engine — the engine stays policy-free) enforces a configurable receipt policy: postings at or above a threshold (`require_receipts_above`, default `"25.00"` in base diff --git a/plan/STATUS.md b/plan/STATUS.md index e71f87b..fac40d7 100644 --- a/plan/STATUS.md +++ b/plan/STATUS.md @@ -151,3 +151,13 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done (QBO/CSV/email/sheets, conversational). 5 integration tests green; clippy clean. Bootstrap: `mise trust && mise install`, `mise run build`, `mise run mcp-add`, then `claude` in the dir. +- 2026-10-11: Receipts are mutable through events, never edits (Stephen: + "the journal is just a history of every single thing that happens"). + `books.unlink_receipt` and `books.withdraw_attachment` (both require a + reason) journal `receipt.unlinked` / `attachment.withdrawn`; nothing is + erased from the journal or blob store. Unlinking can't strand an entry the + receipt policy requires evidence for (attach the replacement first). + `link_receipt` now refuses an already-linked or withdrawn attachment, which + closes a silent path for moving a receipt off a posted entry; disposals + check their receipt ids before posting. Schema v5. First real use: swapping + the migrated Wanderland Labs `.eml` receipts for their PDF renders.