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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 18:40:51 -07:00
1 parent 3bfea47a59
commit e59e8001c6
13 files changed
+580 -21

No files matched your search

+102 -2
View File
@@ -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<UnlinkReceiptParams>,
) -> Result<Json<UnlinkReceiptOut>, 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<WithdrawAttachmentParams>,
) -> Result<Json<WithdrawAttachmentOut>, 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<Json<ListReceiptsOut>, 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,
+44
View File
@@ -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<u64>,
/// Include withdrawn attachments. Default false.
#[serde(default)]
pub include_withdrawn: Option<bool>,
}
/// One receipt or document.
@@ -529,6 +569,10 @@ pub struct ReceiptOut {
pub size_bytes: u64,
/// Description, if any.
pub description: Option<String>,
/// True when withdrawn (only listed with include_withdrawn).
pub withdrawn: bool,
/// Why it was withdrawn, if it was.
pub withdrawn_reason: Option<String>,
}
/// Result of `books.list_receipts`.
+136 -1
View File
@@ -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<u64> = 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");