diff --git a/crates/ledger-cli/tests/attention_parity.rs b/crates/ledger-cli/tests/attention_parity.rs index 1b9fd2c..bb9c4aa 100644 --- a/crates/ledger-cli/tests/attention_parity.rs +++ b/crates/ledger-cli/tests/attention_parity.rs @@ -297,3 +297,56 @@ fn an_unsent_invoice_is_on_both_lists() { assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}"); assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}"); } + +/// Plan P4 step 6 (R-F): a draft that says it waits for its evidence is +/// one item, "draft awaiting receipt", and a draft still being built is +/// another, "draft not posted": both on the viewer's list and in +/// `books.start`'s next steps, from the one engine. +#[test] +fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() { + let today = ledger_core::settings::today_in(None); + let place = common::Place::new("attention-drafts-parity"); + let dir = place.books_data(); + let _ = std::fs::remove_dir_all(&dir); + let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap(); + let cash = st + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let rent = st + .add_account("6000", "Rent", AccountType::Expense, None, false) + .unwrap(); + let mut draft = |what: &str| { + let e = st.create_entry(today - chrono::Days::new(5), what).unwrap(); + st.add_line(e, rent, Side::Debit, usd(4_000)).unwrap(); + st.add_line(e, cash, Side::Credit, usd(4_000)).unwrap(); + e + }; + let waiting = draft("Figma annual plan"); + let _building = draft("Coworking day pass"); + st.set_draft_state( + waiting, + ledger_core::DraftState::AwaitingEvidence { + note: "invoice due".into(), + }, + ) + .unwrap(); + drop(st); + let mut wire = place.start(Some(SETTINGS)); + wire.initialize("attention-drafts-parity"); + compare_and_expect( + &place, + &mut wire, + today, + &format!("Draft #{waiting} awaiting receipt: Figma annual plan"), + ); + let titles: Vec = start(&mut wire)["next_steps"] + .as_array() + .unwrap() + .iter() + .filter_map(|s| s["why"].as_str().map(str::to_string)) + .collect(); + assert!( + titles.contains(&"Draft not posted: Coworking day pass".to_string()), + "{titles:?}" + ); +} diff --git a/crates/ledger-cli/tests/backups.rs b/crates/ledger-cli/tests/backups.rs index b27fe42..295865f 100644 --- a/crates/ledger-cli/tests/backups.rs +++ b/crates/ledger-cli/tests/backups.rs @@ -232,7 +232,22 @@ async fn connect(home: &Home, book: &str, token: String) -> Agent { } /// Call a tool: Ok(structured result) or Err(the error text). -async fn call(c: &Client, tool: &str, args: Value) -> Result { +/// Call a tool the way an agent confirms (plan P4, D-50): a call with +/// `confirmed: true` and no `preview` is made first without `confirmed`, +/// then with the `preview` it returned. A refused preview is the answer. +async fn call(c: &Client, tool: &str, mut args: Value) -> Result { + if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() { + let mut first = args.clone(); + first["confirmed"] = json!(false); + let seen = call_once(c, tool, first).await?; + if let Some(digest) = seen.get("preview").and_then(Value::as_str) { + args["preview"] = json!(digest); + } + } + call_once(c, tool, args).await +} + +async fn call_once(c: &Client, tool: &str, args: Value) -> Result { let args = args.as_object().cloned().unwrap_or_default(); let r = tokio::time::timeout( Duration::from_secs(60), diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs index 432c226..3fb84a1 100644 --- a/crates/ledger-cli/tests/common/mod.rs +++ b/crates/ledger-cli/tests/common/mod.rs @@ -366,8 +366,32 @@ impl Wire { resp } - /// Call a tool: the `tools/call` answer. - pub fn call(&mut self, tool: &str, args: Value) -> Value { + /// Call a tool: the `tools/call` answer. A call with `confirmed: true` + /// and no `preview` is made the way an agent makes it (plan P4, D-50): + /// the same call without `confirmed` first, then the confirm with the + /// `preview` it returned. A preview that is refused is the answer + /// (the refusal a confirm would have met); a tool that returns no + /// preview is called as given. Use [`Self::call_raw`] to send exactly + /// these arguments. + pub fn call(&mut self, tool: &str, mut args: Value) -> Value { + if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() { + let mut first = args.clone(); + first["confirmed"] = json!(false); + let seen = self.call_raw(tool, first); + match answer_of(&seen) { + Err(_) => return seen, + Ok(out) => { + if let Some(digest) = out.get("preview").and_then(Value::as_str) { + args["preview"] = json!(digest); + } + } + } + } + self.call_raw(tool, args) + } + + /// Call a tool with exactly these arguments. + pub fn call_raw(&mut self, tool: &str, args: Value) -> Value { self.rpc("tools/call", json!({"name": tool, "arguments": args})) } @@ -597,6 +621,28 @@ pub fn upload_args(bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str "mime_type": mime_type, "file_name": file_name, "purpose": purpose}) } +/// `tools/call` through `rpc` (a harness's own request function), the way +/// an agent confirms (plan P4, D-50): a call with `confirmed: true` and no +/// `preview` is made first without `confirmed`, then with the `preview` it +/// returned. A refused preview is the answer; a tool that returns no +/// preview is called as given. +pub fn tools_call(mut rpc: impl FnMut(&str, Value) -> Value, name: &str, mut args: Value) -> Value { + if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() { + let mut first = args.clone(); + first["confirmed"] = json!(false); + let seen = rpc("tools/call", json!({"name": name, "arguments": first})); + match answer_of(&seen) { + Err(_) => return seen, + Ok(out) => { + if let Some(digest) = out.get("preview").and_then(Value::as_str) { + args["preview"] = json!(digest); + } + } + } + } + rpc("tools/call", json!({"name": name, "arguments": args})) +} + /// A `tools/call` answer: Ok(its structured result) or Err(the refusal's /// or the protocol error's text). pub fn answer_of(r: &Value) -> Result { @@ -985,7 +1031,22 @@ pub async fn connect_at(run: &Path, book: &str, token: Option) -> Result } /// Call a tool: Ok(structured result) or Err(the error text). -pub async fn call(c: &Client, tool: &str, args: Value) -> Result { +/// Call a tool the way an agent confirms (plan P4, D-50): a call with +/// `confirmed: true` and no `preview` is made first without `confirmed`, +/// then with the `preview` it returned. A refused preview is the answer. +pub async fn call(c: &Client, tool: &str, mut args: Value) -> Result { + if args.get("confirmed") == Some(&json!(true)) && args.get("preview").is_none() { + let mut first = args.clone(); + first["confirmed"] = json!(false); + let seen = call_once(c, tool, first).await?; + if let Some(digest) = seen.get("preview").and_then(Value::as_str) { + args["preview"] = json!(digest); + } + } + call_once(c, tool, args).await +} + +async fn call_once(c: &Client, tool: &str, args: Value) -> Result { use rmcp::model::CallToolRequestParams; let args = args.as_object().cloned().unwrap_or_default(); // A client whose server went away may wait forever: a call that diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs index ff87ab5..520eabf 100644 --- a/crates/ledger-cli/tests/documents.rs +++ b/crates/ledger-cli/tests/documents.rs @@ -50,7 +50,7 @@ impl Mcp { /// Call a tool: its result, or the refusal's text. fn try_call(&mut self, tool: &str, args: Value) -> Result { - let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone()); let result = &resp["result"]; if result.get("isError") == Some(&json!(true)) { let text = result["content"] @@ -125,7 +125,10 @@ fn an_invoice_from_issue_to_paid() { // and due date the confirm will take. let before = books.journal(); let preview = m.call("books.create_invoice", issue.clone()); - assert_eq!(preview["preview"], json!(true), "{preview}"); + assert!( + preview["preview"].is_string(), + "the digest to confirm by: {preview}" + ); assert_eq!(preview["number"], "INV-0001", "{preview}"); assert_eq!(preview["due_date"], "2026-03-03", "{preview}"); assert_eq!(preview["terms"], "Net 30", "{preview}"); @@ -240,7 +243,7 @@ fn a_bill_with_its_vendor_invoice_and_terms() { let mut preview = bill.clone(); preview["upload_id"] = json!(file); let p = m.call("books.create_bill", preview.clone()); - assert_eq!(p["preview"], json!(true), "{p}"); + assert!(p["preview"].is_string(), "the digest to confirm by: {p}"); assert_eq!(p["due_date"], "2026-03-03", "{p}"); assert_eq!(p["terms"], "2/10 net 30", "{p}"); assert_eq!(p["discount_by"], "2026-02-11", "{p}"); @@ -933,7 +936,7 @@ mod paying_on_terms { let out = ok( &mut s.wire, "books.receive_payment", - json!({"date": "2026-02-10", "deposit_to": "1000", + json!({"confirmed": true, "date": "2026-02-10", "deposit_to": "1000", "reference": "one deposit", "applications": [ {"invoice": first, "amount": "300.00"}, @@ -997,14 +1000,14 @@ mod paying_on_terms { let why = err( &mut s.wire, "books.pay_bill", - json!({"date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}), + json!({"confirmed": true, "date": "2026-02-05", "paid_from": "1000", "applications": applications(None)}), ); assert!(why.contains("needs a reason"), "{why}"); assert!(why.contains("discount_reason"), "{why}"); ok( &mut s.wire, "books.pay_bill", - json!({"date": "2026-02-05", "paid_from": "1000", + json!({"confirmed": true, "date": "2026-02-05", "paid_from": "1000", "applications": applications(Some("the vendor honoured it late"))}), ); } @@ -1018,7 +1021,7 @@ mod paying_on_terms { let out = ok( &mut s.wire, "books.receive_payment", - json!({"date": "2026-02-05", "deposit_to": "1000", + json!({"confirmed": true, "date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "100.00", "fee": {"amount": "3.00"}}), ); @@ -1053,7 +1056,7 @@ mod paying_on_terms { ok( &mut s.wire, "books.pay_bill", - json!({"date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00", + json!({"confirmed": true, "date": "2026-02-05", "paid_from": "2050", "bill": bill, "amount": "100.00", "instrument": "Visa"}), ); let worksheet = ok( @@ -1081,7 +1084,7 @@ mod paying_on_terms { ok( &mut s.wire, "books.pay_bill", - json!({"date": "2026-02-05", "paid_from": "1000", + json!({"confirmed": true, "date": "2026-02-05", "paid_from": "1000", "applications": [ {"bill": first, "amount": "100.00"}, {"bill": second, "amount": "200.00"} @@ -1118,7 +1121,7 @@ mod paying_on_terms { let why = err( &mut s.wire, "books.receive_payment", - json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}), + json!({"confirmed": true, "date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "150.00"}), ); assert!(why.contains("150.00"), "{why}"); assert!(why.contains("100.00"), "{why}"); @@ -1189,7 +1192,7 @@ mod paying_on_terms { ok( &mut s.wire, "books.receive_payment", - json!({"date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}), + json!({"confirmed": true, "date": "2026-02-05", "deposit_to": "1000", "invoice": invoice, "amount": "200.00"}), ); let out = ok( &mut s.wire, @@ -1282,7 +1285,7 @@ mod bad_debts { ok( &mut wire, "books.receive_payment", - json!({ + json!({"confirmed": true, "document_id": inv, "date": "2026-01-20", "amount": "40.00", @@ -1357,7 +1360,7 @@ mod bad_debts { ok( &mut wire, "books.receive_payment", - json!({ + json!({"confirmed": true, "document_id": inv, "date": "2026-03-02", "amount": "60.00", diff --git a/crates/ledger-cli/tests/drafts.rs b/crates/ledger-cli/tests/drafts.rs new file mode 100644 index 0000000..a5a4dd4 --- /dev/null +++ b/crates/ledger-cli/tests/drafts.rs @@ -0,0 +1,779 @@ +//! Drafts, the evidence gate and the preview digest over the wire (plan +//! P4; rules.md B-43, D-47, D-50): a draft waits for its receipt and +//! posts when it arrives, a discard unblocks a close, a report and the CPA +//! package say when drafts are open, a reversal needs no receipt of its +//! own, and every tool with `confirmed` takes the `preview` it returned +//! and refuses a stale one. +//! +//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — drafts", +//! "The preview digest") and rules.md B-43. Every call goes through a real +//! books server as an agent reaches it (`common.rs`). + +mod common; + +use common::{Books, answer_of}; +use serde_json::{Value, json}; + +/// Cash at 1000, the default 25.00 receipt threshold. +const SETTINGS: &str = "[company]\nname = \"Drafts Co\"\nfiscal_year_start = \"01-01\"\n\ +[books]\ncash_accounts = [\"1000\"]\n"; + +fn chart(w: &mut common::Wire) { + for (code, name, ty) in [ + ("1000", "Cash", "asset"), + ("3000", "Capital", "equity"), + ("4000", "Sales", "revenue"), + ("6000", "Rent", "expense"), + ] { + answer_of(&w.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + )) + .unwrap(); + } +} + +/// A draft of `amount` rent paid from cash on `date`. +fn draft(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 { + let r = answer_of(&w.call( + "books.draft_entry", + json!({"date": date, "description": what, "no_party_reason": "test"}), + )) + .unwrap(); + let id = r["entry_id"].as_u64().unwrap(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + answer_of(&w.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + )) + .unwrap(); + } + id +} + +fn a_receipt(w: &mut common::Wire, entry: u64) -> u64 { + let bytes = b"%PDF-1.4 a receipt"; + let upload = w.upload(bytes, "receipt.pdf", "application/pdf", "receipt"); + answer_of(&w.call( + "books.attach_receipt", + json!({"entry_id": entry, "upload_id": upload}), + )) + .unwrap()["attachment_id"] + .as_u64() + .unwrap() +} + +fn setup(tag: &str) -> (Books, common::Wire) { + let books = Books::with(tag, "USD", Some(SETTINGS)); + let mut w = books.session("p4"); + chart(&mut w); + (books, w) +} + +/// The refusal a call met, parsed when it is structured. +fn refusal(r: &Value) -> Value { + let text = answer_of(r).expect_err("expected a refusal"); + serde_json::from_str(&text).unwrap_or(Value::String(text)) +} + +/// A draft finished but waiting for its receipt says so, is listed as +/// missing its evidence, is refused at the post, and posts the moment the +/// receipt is attached. +#[test] +fn a_draft_waits_for_its_receipt_and_posts() { + let (_books, mut w) = setup("p4-waits"); + let id = draft(&mut w, "2026-03-01", "Figma annual plan", "100.00"); + let set = answer_of(&w.call( + "books.set_draft_state", + json!({"entry_id": id, "state": "awaiting_evidence", "note": "Figma invoice due"}), + )) + .unwrap(); + assert_eq!(set["state"], "awaiting_evidence"); + let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap(); + let d = &listed["drafts"][0]; + assert_eq!(d["entry_id"], id); + assert_eq!(d["state"], "awaiting_evidence"); + assert_eq!(d["note"], "Figma invoice due"); + assert_eq!(d["total"], "100.00"); + assert_eq!(d["missing_evidence"], true); + let waiting = answer_of(&w.call( + "books.list_drafts", + json!({"state": "awaiting_evidence", "as_of": "2026-03-31"}), + )) + .unwrap(); + assert_eq!(waiting["drafts"].as_array().unwrap().len(), 1); + let building = answer_of(&w.call("books.list_drafts", json!({"state": "building"}))).unwrap(); + assert!(building["drafts"].as_array().unwrap().is_empty()); + // The preview names the need; the confirm is refused with the error the + // MCP always gave. + let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap(); + assert_eq!(p["status"], "preview"); + assert_eq!(p["receipt_required"], true); + assert_eq!(p["receipt_missing"], true); + let digest = p["preview"].as_str().unwrap().to_string(); + let r = w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true, "preview": digest}), + ); + let err = refusal(&r); + assert_eq!(err["code"], "receipt_required", "{err}"); + // The receipt arrives; the same draft posts. + a_receipt(&mut w, id); + let r = w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + let posted = answer_of(&r).unwrap(); + assert_eq!(posted["status"], "posted"); + let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap(); + assert!(listed["drafts"].as_array().unwrap().is_empty()); +} + +/// B-43: the structured `receipt_required` error is the one the MCP built +/// before the gate moved into the store. +#[test] +fn the_gate_moved_and_the_mcp_error_is_unchanged() { + let (_books, mut w) = setup("p4-error"); + let id = draft(&mut w, "2026-03-01", "Rent", "100.00"); + let r = w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + let err = refusal(&r); + assert_eq!(err["code"], "receipt_required"); + assert_eq!(err["entry_id"], id); + assert_eq!(err["debits"], "100.00"); + assert_eq!( + err["policy"], + "receipts required when entry debits are strictly greater than 25.00" + ); + assert_eq!(err["remedy_tool"], "books.attach_receipt"); + assert!( + err["message"] + .as_str() + .unwrap() + .starts_with(&format!("entry {id} moves 100.00 in debits (")) + ); + assert!( + err["remedy_hint"] + .as_str() + .unwrap() + .contains("then retry books.post_entry with confirmed=true") + ); + assert!(err.get("split_group").is_none()); +} + +/// D-47: a reversal posts at once with no receipt of its own; its evidence +/// is its original's. +#[test] +fn a_reversal_needs_no_receipt_of_its_own() { + let (_books, mut w) = setup("p4-reversal"); + let id = draft(&mut w, "2026-03-01", "Rent", "100.00"); + a_receipt(&mut w, id); + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + )) + .unwrap(); + let r = answer_of(&w.call( + "books.reverse_entry", + json!({"entry_id": id, "date": "2026-03-05", "description": "Undo", "confirmed": true}), + )) + .unwrap(); + assert_eq!(r["status"], "posted"); + let d = answer_of(&w.call("books.digest", json!({"since": "2026-03-01"}))).unwrap(); + for e in d["entries"].as_array().unwrap() { + if e["reversal_of"].is_number() { + assert_eq!(e["receipt_required"], false, "{e}"); + assert_eq!(e["receipt_missing"], false, "{e}"); + } + } +} + +/// A period holding an open draft cannot close; discarded (previewed, then +/// confirmed with its digest), it can. +#[test] +fn discard_unblocks_the_close_over_the_wire() { + let (_books, mut w) = setup("p4-discard"); + let seed = draft(&mut w, "2026-03-01", "Seed", "10.00"); + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": seed, "confirmed": true}), + )) + .unwrap(); + let per = answer_of(&w.call( + "books.create_period", + json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}), + )) + .unwrap()["period_id"] + .as_u64() + .unwrap(); + let id = draft(&mut w, "2026-03-20", "Entered twice", "40.00"); + let r = w.call( + "books.close_period", + json!({"period_id": per, "confirmed": true}), + ); + assert!( + answer_of(&r).is_err(), + "a close is refused with a draft open" + ); + let p = answer_of(&w.call( + "books.discard_draft", + json!({"entry_id": id, "reason": "entered twice"}), + )) + .unwrap(); + assert_eq!(p["status"], "preview"); + assert_eq!(p["holds_open"], json!(["2026-03"])); + let digest = p["preview"].as_str().unwrap().to_string(); + // A reason is the preview's; a different one is a different preview. + let r = w.call_raw( + "books.discard_draft", + json!({"entry_id": id, "reason": "something else", "confirmed": true, "preview": digest}), + ); + assert_eq!(refusal(&r)["code"], "stale_preview"); + let done = answer_of(&w.call_raw( + "books.discard_draft", + json!({"entry_id": id, "reason": "entered twice", "confirmed": true, "preview": digest}), + )) + .unwrap(); + assert_eq!(done["status"], "discarded"); + assert!( + answer_of(&w.call("books.list_drafts", json!({}))).unwrap()["drafts"] + .as_array() + .unwrap() + .is_empty() + ); + let closed = answer_of(&w.call( + "books.close_period", + json!({"period_id": per, "confirmed": true}), + )) + .unwrap(); + assert_eq!(closed["status"], "closed"); + // Only a draft: the posted seed is never discarded. + let r = w.call( + "books.discard_draft", + json!({"entry_id": seed, "reason": "no"}), + ); + assert!(answer_of(&r).unwrap_err().contains("posted")); +} + +/// A report says what is open as of its end; strict refuses instead. +#[test] +fn report_warns_on_draft_before_as_of() { + let (_books, mut w) = setup("p4-report"); + let id = draft(&mut w, "2026-03-10", "Open", "40.00"); + let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap(); + let open = &tb["as_of_status"]["drafts_open_on_or_before"]; + assert_eq!(open[0]["entry_id"], id); + assert_eq!(open[0]["state"], "building"); + let early = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-01"}))).unwrap(); + assert!( + early["as_of_status"]["drafts_open_on_or_before"] + .as_array() + .unwrap() + .is_empty(), + "a draft dated after the report's end is not open in it" + ); + for tool in [ + "books.trial_balance", + "books.balance_sheet", + "books.profit_and_loss", + "books.cash_flow", + ] { + let args = match tool { + "books.trial_balance" | "books.balance_sheet" => { + json!({"as_of": "2026-03-31", "strict": true}) + } + _ => json!({"start": "2026-03-01", "end": "2026-03-31", "strict": true}), + }; + let r = w.call(tool, args); + let why = answer_of(&r).expect_err(tool); + assert!( + why.contains("strict") && why.contains("draft"), + "{tool}: {why}" + ); + } + // Posted, the report is clean and strict gives it. + a_receipt(&mut w, id); + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + )) + .unwrap(); + let ok = answer_of(&w.call( + "books.trial_balance", + json!({"as_of": "2026-03-31", "strict": true}), + )); + // Cash is not reconciled, so strict still names it: the refusal says + // so and not "draft". + if let Err(why) = ok { + assert!(!why.contains("draft"), "{why}"); + } +} + +/// The CPA package is refused while a draft dated in it is open, unless +/// the human says why; the reason is journaled as a decision. +#[test] +fn cpa_package_refuses_open_draft() { + let (books, mut w) = setup("p4-cpa"); + let id = draft(&mut w, "2026-03-10", "Open", "40.00"); + let args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"]}); + let err = refusal(&w.call("books.export_cpa_package", args.clone())); + assert_eq!(err["code"], "open_drafts", "{err}"); + assert!(err["message"].as_str().unwrap().contains(&format!("#{id}"))); + // A draft dated after as_of does not stop it. + let before = books.journal().len(); + let mut allowed = args.clone(); + allowed["allow_open_drafts"] = json!({"reason": "the CPA wants it before the receipt comes"}); + let out = answer_of(&w.call("books.export_cpa_package", allowed)).unwrap(); + assert!(out["file"].is_object(), "{out}"); + let journal = String::from_utf8(books.journal()).unwrap(); + assert!(books.journal().len() > before); + assert!(journal.contains("decision.recorded")); + assert!(journal.contains("the CPA wants it before the receipt comes")); +} + +/// D-50: a confirm takes the preview it was shown; without it, or after a +/// changed argument or a write by another session, it is refused with +/// `stale_preview` before anything is written. +#[test] +fn stale_preview_refused() { + let (books, mut w) = setup("p4-stale"); + let id = draft(&mut w, "2026-03-01", "Rent", "10.00"); + // A draft's preview is the draft's state: a line added since is stale. + let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap(); + let digest = p["preview"].as_str().unwrap().to_string(); + // The same preview from post_entry itself. + let p2 = answer_of(&w.call("books.post_entry", json!({"entry_id": id}))).unwrap(); + assert_eq!(p2["preview"], p["preview"]); + // No preview at all. + let r = w.call_raw( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert_eq!(refusal(&r)["code"], "stale_preview"); + // A line added since the preview. + answer_of(&w.call( + "books.add_line", + json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "1.00"}), + )) + .unwrap(); + answer_of(&w.call( + "books.add_line", + json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "1.00"}), + )) + .unwrap(); + let before = books.journal(); + let r = w.call_raw( + "books.post_entry", + json!({"entry_id": id, "confirmed": true, "preview": digest}), + ); + let err = refusal(&r); + assert_eq!(err["code"], "stale_preview"); + assert_eq!(err["remedy_tool"], "books.post_entry"); + assert_eq!(books.journal(), before, "a stale confirm writes nothing"); + // An operation's preview moves with the journal's head: a post by + // another session in between forces a re-preview. + let other = draft(&mut w, "2026-03-02", "Other", "5.00"); + let first = answer_of(&w.call( + "books.reverse_entry", + json!({"entry_id": other, "date": "2026-03-03", "description": "x"}), + )); + assert!(first.is_err(), "a draft is not reversible"); + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": other, "confirmed": true}), + )) + .unwrap(); + let p = answer_of(&w.call( + "books.reverse_entry", + json!({"entry_id": other, "date": "2026-03-03", "description": "x"}), + )) + .unwrap(); + let digest = p["preview"].as_str().unwrap().to_string(); + // The same call with another description is another preview... + let r = w.call_raw( + "books.reverse_entry", + json!({"entry_id": other, "date": "2026-03-03", "description": "y", + "confirmed": true, "preview": digest}), + ); + assert_eq!(refusal(&r)["code"], "stale_preview"); + // ...and so is a write between preview and confirm. + answer_of(&w.call( + "books.add_account", + json!({"code": "6100", "name": "Software", "account_type": "expense"}), + )) + .unwrap(); + let r = w.call_raw( + "books.reverse_entry", + json!({"entry_id": other, "date": "2026-03-03", "description": "x", + "confirmed": true, "preview": digest}), + ); + assert_eq!(refusal(&r)["code"], "stale_preview"); + // Re-previewed, it confirms. + let r = w.call( + "books.reverse_entry", + json!({"entry_id": other, "date": "2026-03-03", "description": "x", + "confirmed": true}), + ); + assert_eq!(answer_of(&r).unwrap()["status"], "posted"); +} + +/// Every tool that takes `confirmed` takes `preview` (D-50, from P4 on). +#[test] +fn every_confirm_tool_takes_a_preview() { + let books = Books::new("p4-schema"); + let mut w = books.session("p4"); + let tools = w.rpc("tools/list", json!({}))["result"]["tools"].clone(); + let mut confirm = Vec::new(); + for t in tools.as_array().expect("tools") { + let props = &t["inputSchema"]["properties"]; + if props.get("confirmed").is_some() { + confirm.push(t["name"].as_str().unwrap().to_string()); + assert!( + props.get("preview").is_some(), + "{} takes confirmed but not preview", + t["name"] + ); + } + } + for tool in [ + "books.post_entry", + "books.reverse_entry", + "books.run_schedules", + "books.dispose_asset", + "books.reconcile", + "books.close_period", + "books.create_invoice", + "books.create_bill", + "books.receive_payment", + "books.pay_bill", + "books.write_off_invoice", + "books.discard_draft", + ] { + assert!(confirm.iter().any(|t| t == tool), "{tool} has no confirmed"); + } +} + +/// Defect 6: an entry dated in the past but posted just now is in the +/// digest asked for by the journal sequence number, and not in the one +/// asked for by date. +#[test] +fn digest_since_seq_is_posting_time() { + let (_books, mut w) = setup("p4-digest"); + let early = draft(&mut w, "2026-01-05", "Backdated", "20.00"); + let head = + answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap()["head_seq"] + .as_u64() + .unwrap(); + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": early, "confirmed": true}), + )) + .unwrap(); + let by_seq = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap(); + let ids: Vec = by_seq["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_id"].as_u64().unwrap()) + .collect(); + assert_eq!(ids, vec![early]); + assert!(by_seq["head_seq"].as_u64().unwrap() > head); + let by_date = answer_of(&w.call("books.digest", json!({"since": "2026-02-01"}))).unwrap(); + assert!(by_date["entries"].as_array().unwrap().is_empty()); + // Nothing after the new head. + let none = + answer_of(&w.call("books.digest", json!({"since_seq": by_seq["head_seq"]}))).unwrap(); + assert!(none["entries"].as_array().unwrap().is_empty()); + // An account touched whose net movement is zero is listed, not lost. + let second = draft(&mut w, "2026-01-06", "Refund", "20.00"); + // (reverse the sign on the same accounts: debit cash, credit rent) + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": second, "confirmed": true}), + )) + .unwrap(); + let rev = answer_of(&w.call( + "books.reverse_entry", + json!({"entry_id": second, "date": "2026-01-07", "description": "Undo", + "confirmed": true}), + )) + .unwrap(); + assert_eq!(rev["status"], "posted"); + let d = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap(); + let zero: Vec<&str> = d["zero_net_accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); + assert!( + zero.is_empty(), + "the first entry still moves rent: {zero:?}" + ); + let only_pair = answer_of(&w.call( + "books.digest", + json!({"since": "2026-01-06", "as_of": "2026-01-31"}), + )) + .unwrap(); + let zero: Vec<&str> = only_pair["zero_net_accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); + assert_eq!(zero, vec!["1000 Cash", "6000 Rent"]); + assert_eq!(only_pair["drafts_by_state"], json!({})); + assert_eq!(only_pair["never_reconciled"], json!(["1000"])); +} + +/// The viewer and the server ask one function whether an entry lacks its +/// evidence (plan P4 step 2, I-5): on random books under three policies +/// the viewer's missing-evidence set equals the server's, and both equal +/// what the viewer decided before the gate moved (an entry's origin read +/// from the journal's events: a hand entry or a recurring draft is +/// covered; a document's, a payment's, a close, a schedule month's, a +/// depreciation's, a payroll's and a reversal are not). +#[cfg(feature = "gui")] +#[test] +fn gui_and_server_gate_agree() { + use chrono::NaiveDate; + use ledger_core::settings::{Change, Source}; + use ledger_core::{AccountType, Currency, Money, Side}; + use ledger_gui::data; + use rust_decimal::Decimal; + use std::collections::{BTreeMap, BTreeSet}; + + let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD); + for (n, (threshold, required, exempt)) in [ + ("0.00", vec![], vec![]), + ("25.00", vec!["6200"], vec!["6300"]), + ("100.00", vec![], vec!["6300", "6100"]), + ] + .into_iter() + .enumerate() + { + let root = tempfile::Builder::new() + .prefix(&format!("wb-p4-gate-{n}-")) + .tempdir() + .unwrap(); + let dir = root.path().join("book"); + std::fs::create_dir_all(&dir).unwrap(); + let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap(); + st.change_settings( + &[ + Change { + key: "policy.evidence.threshold".into(), + value: Some(json!(threshold)), + }, + Change { + key: "policy.evidence.required_accounts".into(), + value: Some(json!(required)), + }, + Change { + key: "policy.evidence.exempt_accounts".into(), + value: Some(json!(exempt)), + }, + ], + &Source { + decided_by: "test".into(), + decided_on: day(1, 1), + citation: None, + note: None, + }, + ) + .unwrap(); + let mut acct = + |code: &str, name: &str, ty| st.add_account(code, name, ty, None, false).unwrap(); + let bank = acct("1000", "Bank", AccountType::Asset); + let prepaid = acct("1300", "Prepaid", AccountType::Asset); + let capital = acct("3000", "Capital", AccountType::Equity); + let sales = acct("4000", "Sales", AccountType::Revenue); + let expenses = [ + acct("6100", "Supplies", AccountType::Expense), + acct("6200", "Contractors", AccountType::Expense), + acct("6300", "Fees", AccountType::Expense), + ]; + let seed = st.create_entry(day(1, 1), "Capital").unwrap(); + st.add_line(seed, bank, Side::Debit, usd(10_000_000)) + .unwrap(); + st.add_line(seed, capital, Side::Credit, usd(10_000_000)) + .unwrap(); + st.post_entry(seed).unwrap(); + // A deterministic pseudo-random run of hand entries. + let mut x: u64 = 0x2545F4914F6CDD1D ^ (n as u64); + let mut next = move || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x + }; + let mut posted = Vec::new(); + for i in 0..36u32 { + let e = st + .create_entry(day(2, 1 + (i % 27)), &format!("Entry {i}")) + .unwrap(); + let cents = 100 + (next() % 30_000) as i64; + let ex = expenses[(next() % 3) as usize]; + st.add_line(e, ex, Side::Debit, usd(cents)).unwrap(); + st.add_line(e, bank, Side::Credit, usd(cents)).unwrap(); + if next() % 3 == 0 { + st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None) + .unwrap(); + } + // Some stay drafts. + if next() % 6 != 0 { + st.post_entry(e).unwrap(); + posted.push(e); + } + } + for e in posted.iter().take(5) { + st.reverse_entry(*e, day(3, 2), "Reversed").unwrap(); + } + let customer = st.add_customer("Fernhill Labs").unwrap(); + let inv = st + .create_invoice( + customer, + day(3, 3), + day(4, 2), + vec![ledger_core::arap::DocumentLine { + description: "Design".into(), + amount: Decimal::new(250_000, 2), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + st.receive_payment(inv, day(3, 20), Decimal::new(100_000, 2), bank) + .unwrap(); + let sched = st + .register_schedule(ledger_core::NewSchedule { + description: "Insurance".into(), + kind: ledger_core::ScheduleKind::PrepaidExpense, + total: usd(120_000), + start: day(1, 1), + months: 12, + balance_account: prepaid, + pl_account: expenses[0], + convention: ledger_core::ScheduleConvention::MonthlyEven, + }) + .unwrap(); + st.post_schedule_month(sched, day(1, 31)).unwrap(); + let _ = sales; + let per = st.create_period("2026-01", day(1, 1), day(1, 31)).unwrap(); + let _ = st.close_period(per); + st.register_recurring(ledger_core::NewRecurring { + description: "Figma".into(), + lines: vec![ + (expenses[0], Side::Debit, usd(4_000)), + (bank, Side::Credit, usd(4_000)), + ], + frequency: ledger_core::Frequency::Monthly, + start: day(5, 10), + end: None, + }) + .unwrap(); + st.run_recurring(day(5, 31)).unwrap(); + // What the server decides: the engine, with the receipts filed. + let server: BTreeSet = (1..st.ledger().next_entry_id()) + .filter(|id| { + let Some(e) = st.ledger().get_entry(*id) else { + return false; + }; + ledger_core::evidence::requirement(st.ledger(), e, st.ledger().settings()) + .is_required() + && st.list_attachments(Some(*id)).unwrap().is_empty() + }) + .collect(); + // What the viewer decided before: origin from the journal's events. + let policy = ledger_core::evidence::Policy::from_settings(st.ledger().settings()); + let journal = String::from_utf8(std::fs::read(dir.join("journal.jsonl")).unwrap()).unwrap(); + let mut refs: BTreeMap> = BTreeMap::new(); + for line in journal.lines() { + let v: Value = serde_json::from_str(line).unwrap(); + let ty = v["type"].as_str().unwrap().to_string(); + let p = &v["payload"]; + let mut ids = Vec::new(); + if ty.starts_with("entry.") { + ids.extend(p["id"].as_u64()); + } + ids.extend(p["entry_id"].as_u64()); + ids.extend(p["closing_entry"].as_u64()); + ids.extend(p["entry"]["id"].as_u64()); + for id in ids { + refs.entry(id).or_default().push(ty.clone()); + } + } + let closings = st.ledger().closing_entry_ids(); + let old: BTreeSet = (1..st.ledger().next_entry_id()) + .filter(|id| { + let Some(e) = st.ledger().get_entry(*id) else { + return false; + }; + let has = |t: &str| refs.get(id).is_some_and(|v| v.iter().any(|x| x == t)); + let by_hand = !closings.contains(id) + && !has("entry.reversed") + && !has("schedule.posted") + && !has("asset.depreciation_posted") + && !has("asset.disposed") + && !has("bill.created") + && !has("bill.paid") + && !has("invoice.created") + && !has("invoice.payment_received") + && !has("payroll.run") + && !has("payroll.taxes_remitted"); + by_hand + && e.reversal_of().is_none() + && st.list_attachments(Some(*id)).unwrap().is_empty() + && policy.receipt_required(st.ledger(), e) + }) + .collect(); + drop(st); + let books = data::load_once(&data::Source { + data_dir: dir.clone(), + today: Some(day(6, 1)), + }) + .unwrap(); + let viewer: BTreeSet = (1..=books.ledger.next_entry_id()) + .filter(|id| books.entry(*id).is_some_and(|e| e.missing_receipt)) + .collect(); + assert!(!server.is_empty(), "policy {threshold}: nothing to compare"); + assert_eq!(viewer, server, "policy {threshold}: viewer vs server"); + assert_eq!(server, old, "policy {threshold}: engine vs the old reading"); + } +} + +/// Books an older version wrote hold a reversal and drafts (the fixture +/// `p4_before`): opened under this version, with no upgrade, the figures +/// are the writer's, the reversal needs no receipt of its own, and the +/// drafts are open and building. +#[test] +fn legacy_reversed_replays_identically() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-store/tests/fixtures/old-books/p4_before"); + let only = tempfile::tempdir().unwrap(); + std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap(); + let books = Books::placed("p4-legacy", only.path()); + let mut w = books.session("p4"); + let d = answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap(); + let reversals: Vec<&Value> = d["entries"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["reversal_of"].is_number()) + .collect(); + assert_eq!(reversals.len(), 1); + assert_eq!(reversals[0]["receipt_required"], false); + assert_eq!(reversals[0]["receipt_missing"], false); + let drafts = answer_of(&w.call("books.list_drafts", json!({}))).unwrap(); + let open: Vec<&Value> = drafts["drafts"].as_array().unwrap().iter().collect(); + assert_eq!(open.len(), 3, "{drafts}"); + assert!(open.iter().all(|d| d["state"] == "building")); + assert_eq!(d["drafts_by_state"], json!({"building": 3})); + let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-12-31"}))).unwrap(); + assert_eq!(tb["balanced"], true); +} diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index ac4bfd5..fe36e06 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -20,6 +20,8 @@ //! ``` #![cfg(target_os = "linux")] +mod common; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; @@ -332,7 +334,7 @@ impl Agent { /// Call a tool: Ok(its structured result) or Err(the error's text). fn call(&mut self, tool: &str, args: Value) -> Result { - let r = self.request("tools/call", json!({"name": tool, "arguments": args}), 60); + let r = common::tools_call(|m, p| self.request(m, p, 60), tool, args.clone()); if let Some(e) = r.get("error") { return Err(e["message"].as_str().unwrap_or_default().to_string()); } @@ -792,8 +794,38 @@ fn books_p2_served_cut_over_whole() { ) .unwrap()["id"] .clone(); + // A draft P2 reads (the old shape): its state and its discard are + // drafts_v2's, and wait. + let waiting_draft = a + .call( + "books.draft_entry", + json!({"date": "2026-02-12", "description": "Waiting for a receipt", + "no_party_reason": "test"}), + ) + .unwrap()["entry_id"] + .clone(); + for (account, side) in [("6600", "debit"), ("1000", "credit")] { + a.call( + "books.add_line", + json!({"entry_id": waiting_draft, "account": account, "side": side, + "amount": "10.00"}), + ) + .unwrap(); + } let waiting = journal(&dir); let refused: Vec<(&str, Value)> = vec![ + // A draft's state and its discard (drafts_v2, P4): an older + // version refuses the types. + ( + "books.set_draft_state", + json!({"entry_id": waiting_draft, "state": "awaiting_evidence", + "note": "the travel receipt"}), + ), + ( + "books.discard_draft", + json!({"entry_id": waiting_draft, "reason": "entered twice", + "confirmed": true}), + ), // An invoice carrying documents_v2's fields (terms: P4b S4.4) and // its line naming its project (document_line_projects, rules.md // B-37 item 8). diff --git a/crates/ledger-cli/tests/hostile_args.rs b/crates/ledger-cli/tests/hostile_args.rs index 1e0bdb4..1dcfc86 100644 --- a/crates/ledger-cli/tests/hostile_args.rs +++ b/crates/ledger-cli/tests/hostile_args.rs @@ -199,7 +199,7 @@ impl Mcp { } fn ok(&mut self, tool: &str, args: Value) -> Value { - let r = self.rpc("tools/call", json!({"name": tool, "arguments": args})); + let r = common::tools_call(|m, p| self.rpc(m, p), tool, args.clone()); assert!( r.get("error").is_none() && r["result"]["isError"] != json!(true), "fixture {tool} {args}: {r}" diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index b107e56..288349c 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -1179,8 +1179,8 @@ async fn a_token_does_only_what_its_scopes_allow() { "it may preview" ); assert!( - !tools.contains(&"books.receive_payment".to_string()), - "it may preview an invoice, but not post a payment" + !tools.contains(&"books.void_document".to_string()), + "it may preview an invoice, but not void one" ); let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await; if let Err(e) = &preview { @@ -1273,7 +1273,8 @@ async fn every_call_is_recorded_once_refusals_included() { assert_eq!(by("books.get_entry"), 1); assert_eq!(by("books.draft_entry"), 1); assert_eq!(by("books.add_line"), 2); - assert_eq!(by("books.post_entry"), 1); + // The preview, then the confirm (plan P4: each is a call, each recorded). + assert_eq!(by("books.post_entry"), 2); assert_eq!(by("books.access_log"), 0, "recorded once answered"); let refused: Vec<&&Value> = tools .iter() diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index fc812b9..8158e25 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -53,7 +53,7 @@ impl Mcp { /// Call a tool; panic on tool error. Returns structuredContent. fn call(&mut self, tool: &str, args: Value) -> Value { - let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone()); let result = &resp["result"]; assert_ne!( result.get("isError"), @@ -69,7 +69,7 @@ impl Mcp { /// Call a tool expecting an error; returns the error text. fn call_err(&mut self, tool: &str, args: Value) -> String { - let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone()); let result = &resp["result"]; assert_eq!( result.get("isError"), @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the 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(), 129, "expected 129 tools, got {}", tools.len()); + assert_eq!(tools.len(), 133, "expected 133 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index 417f8cd..fd6b33e 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -10,6 +10,8 @@ //! bridge_initializes_during_cold_start, no_local_agent_tcp). #![cfg(target_os = "linux")] +mod common; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; @@ -405,7 +407,7 @@ impl Agent { /// Call a tool: Ok(its structured result) or Err(the error's text). fn call(&mut self, tool: &str, args: Value) -> Result { - let r = self.request("tools/call", json!({"name": tool, "arguments": args}), 60); + let r = common::tools_call(|m, p| self.request(m, p, 60), tool, args.clone()); if let Some(e) = r.get("error") { return Err(e["message"].as_str().unwrap_or_default().to_string()); } diff --git a/crates/ledger-cli/tests/oracle_mcp.rs b/crates/ledger-cli/tests/oracle_mcp.rs index bf5b2e5..564d5fa 100644 --- a/crates/ledger-cli/tests/oracle_mcp.rs +++ b/crates/ledger-cli/tests/oracle_mcp.rs @@ -114,7 +114,7 @@ impl Mcp { } fn tool(&mut self, name: &str, args: Value) -> Out { - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); if let Some(e) = resp.get("error") { return Out::RpcErr(e.clone()); } @@ -1044,6 +1044,15 @@ fn cash_flow_exports_equal_the_core_statement() { "books.cash_flow", json!({"start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}), ); + // (The tool's answer also says what is open as of the end; the + // export's file is the statement alone.) + let mut tool = tool; + assert!( + tool.as_object_mut() + .unwrap() + .remove("as_of_status") + .is_some() + ); assert_eq!(tool, j); } @@ -3442,7 +3451,8 @@ fn cpa_package_holds_the_year_and_its_evidence() { let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok( "books.export_cpa_package", - json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false}), + json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false, + "allow_open_drafts": {"reason": "the test books keep drafts open"}}), ); assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip")); assert_eq!(out["file"]["mime_type"], "application/zip"); @@ -3646,7 +3656,8 @@ fn cpa_package_holds_the_year_and_its_evidence() { // Without receipts: no evidence at all. let out = m.ok( "books.export_cpa_package", - json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}), + json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false, + "allow_open_drafts": {"reason": "the test books keep drafts open"}}), ); let files = unzip(&m.handed(&out["file"])); assert!( diff --git a/crates/ledger-cli/tests/projects_parity.rs b/crates/ledger-cli/tests/projects_parity.rs index 167b0e9..4328f7a 100644 --- a/crates/ledger-cli/tests/projects_parity.rs +++ b/crates/ledger-cli/tests/projects_parity.rs @@ -274,7 +274,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s let bill = server.tool("books.create_bill", bill_args); server.tool( "books.receive_payment", - json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}), + json!({"confirmed": true, "document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}), ); let b = data::load_once(&Source { data_dir: data.clone(), diff --git a/crates/ledger-cli/tests/spec_1099.rs b/crates/ledger-cli/tests/spec_1099.rs index fc90e76..bfc788b 100644 --- a/crates/ledger-cli/tests/spec_1099.rs +++ b/crates/ledger-cli/tests/spec_1099.rs @@ -146,7 +146,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { diff --git a/crates/ledger-cli/tests/spec_assets.rs b/crates/ledger-cli/tests/spec_assets.rs index 2a617b2..ff0eb28 100644 --- a/crates/ledger-cli/tests/spec_assets.rs +++ b/crates/ledger-cli/tests/spec_assets.rs @@ -151,7 +151,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { @@ -605,6 +605,12 @@ fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() { by_tag["tag"] = json!(t.to_ascii_lowercase()); let p1 = m.ok("books.dispose_asset", by_id.clone()); let p2 = m.ok("books.dispose_asset", by_tag.clone()); + // (The digest names the arguments as sent, so it differs by id and + // by tag; everything it shows is the same.) + let (mut p1, mut p2) = (p1, p2); + for p in [&mut p1, &mut p2] { + p.as_object_mut().unwrap().remove("preview"); + } assert_eq!(p1, p2, "same preview by id and by tag"); let mut both = by_id.clone(); both["tag"] = json!(t); diff --git a/crates/ledger-cli/tests/spec_attachments.rs b/crates/ledger-cli/tests/spec_attachments.rs index fd51708..afa85c7 100644 --- a/crates/ledger-cli/tests/spec_attachments.rs +++ b/crates/ledger-cli/tests/spec_attachments.rs @@ -157,7 +157,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { diff --git a/crates/ledger-cli/tests/spec_kept.rs b/crates/ledger-cli/tests/spec_kept.rs index 0f25ee7..701330d 100644 --- a/crates/ledger-cli/tests/spec_kept.rs +++ b/crates/ledger-cli/tests/spec_kept.rs @@ -47,7 +47,7 @@ impl Mcp { /// Call a tool; panic on tool error. Returns structuredContent. fn call(&mut self, tool: &str, args: Value) -> Value { - let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone()); let result = &resp["result"]; assert_ne!( result.get("isError"), @@ -63,7 +63,7 @@ impl Mcp { /// Call a tool expecting an error; returns the error text. fn call_err(&mut self, tool: &str, args: Value) -> String { - let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone()); let result = &resp["result"]; assert_eq!( result.get("isError"), diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index b6630af..89d31e3 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -287,7 +287,7 @@ impl Mcp { fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); let files = self.read_only(name).then(|| self.data_files()); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { @@ -884,6 +884,30 @@ fn happy_path(name: &str) -> Mcp { "foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}), ); + // P4: a draft's state, the list, the full preview, and a discard. + let dd = m.ok( + "books.draft_entry", + json!({"date": "2026-04-23", "description": "Entered twice"}), + )["entry_id"] + .as_u64() + .unwrap(); + for (account, side) in [("6999", "debit"), ("1000", "credit")] { + m.ok( + "books.add_line", + json!({"entry_id": dd, "account": account, "side": side, "amount": "5.00"}), + ); + } + m.ok( + "books.set_draft_state", + json!({"entry_id": dd, "state": "awaiting_evidence", "note": "a receipt"}), + ); + m.ok("books.list_drafts", json!({"state": "awaiting_evidence"})); + m.ok("books.preview_entry", json!({"entry_id": dd})); + m.ok( + "books.discard_draft", + json!({"entry_id": dd, "reason": "entered twice", "confirmed": true}), + ); + // A/R and A/P. let inv = m.ok( "books.create_invoice", @@ -895,7 +919,7 @@ fn happy_path(name: &str) -> Mcp { .unwrap(); m.ok( "books.receive_payment", - json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), + json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), ); let vendor_invoice = m.upload( b"vendor invoice", @@ -914,7 +938,7 @@ fn happy_path(name: &str) -> Mcp { .unwrap(); m.ok( "books.pay_bill", - json!({"document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}), + json!({"confirmed": true, "document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}), ); m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); @@ -1258,11 +1282,13 @@ fn happy_path(name: &str) -> Mcp { ); m.ok( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}), + json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false, + "allow_open_drafts": {"reason": "the test books keep drafts open"}}), ); m.ok( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "include_receipts": false}), + json!({"as_of": "2026-12-31", "include_receipts": false, + "allow_open_drafts": {"reason": "the test books keep drafts open"}}), ); // The access log, the backups' status, and a backup now (none is set @@ -1666,7 +1692,7 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) { ), Op::PayBill { doc, amount } => ( "books.pay_bill", - json!({"document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount, + json!({"confirmed": true, "document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount, "cash_account": "1000"}), ), Op::Payout { deposit } => ( @@ -2636,7 +2662,7 @@ fn documents_void_and_tie_out_over_the_wire() { .unwrap(); let paid = m.ok( "books.receive_payment", - json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", + json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}), ); assert_eq!(paid["remaining"], "300.00", "{paid}"); @@ -2738,7 +2764,7 @@ fn documents_list_and_timeline_over_the_wire() { let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00"); let pay = m.ok( "books.receive_payment", - json!({"document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), + json!({"confirmed": true, "document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), )["entry_id"] .as_u64() .unwrap(); diff --git a/crates/ledger-cli/tests/spec_parties.rs b/crates/ledger-cli/tests/spec_parties.rs index 5a9be27..00e6661 100644 --- a/crates/ledger-cli/tests/spec_parties.rs +++ b/crates/ledger-cli/tests/spec_parties.rs @@ -152,7 +152,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { @@ -842,7 +842,7 @@ fn party_totals_match_a_hand_computed_year() { ); let pay = m.ok( "books.pay_bill", - json!({"document_id": bill["id"], "date": "2026-07-15", "amount": "300.00", + json!({"confirmed": true, "document_id": bill["id"], "date": "2026-07-15", "amount": "300.00", "cash_account": "1000"}), )["entry_id"] .as_u64() @@ -923,7 +923,7 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() { ); let pay = m.ok( "books.receive_payment", - json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "600.00", + json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "600.00", "cash_account": "1000"}), )["entry_id"] .as_u64() diff --git a/crates/ledger-cli/tests/spec_party_totals.rs b/crates/ledger-cli/tests/spec_party_totals.rs index 0a2a974..736c8e1 100644 --- a/crates/ledger-cli/tests/spec_party_totals.rs +++ b/crates/ledger-cli/tests/spec_party_totals.rs @@ -153,7 +153,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { diff --git a/crates/ledger-cli/tests/spec_projects.rs b/crates/ledger-cli/tests/spec_projects.rs index 648838a..04522bb 100644 --- a/crates/ledger-cli/tests/spec_projects.rs +++ b/crates/ledger-cli/tests/spec_projects.rs @@ -152,7 +152,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { @@ -1284,11 +1284,11 @@ fn invoice_and_bill_lines_count_toward_their_projects() { // Payments are balance-sheet only: no project. let got = m.ok( "books.receive_payment", - json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}), + json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}), ); let paid = m.ok( "books.pay_bill", - json!({"document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}), + json!({"confirmed": true, "document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}), ); for e in [&got["entry_id"], &paid["entry_id"]] { let e = e.as_u64().unwrap(); diff --git a/crates/ledger-cli/tests/spec_tax_policy.rs b/crates/ledger-cli/tests/spec_tax_policy.rs index f5073a4..c4e7433 100644 --- a/crates/ledger-cli/tests/spec_tax_policy.rs +++ b/crates/ledger-cli/tests/spec_tax_policy.rs @@ -55,7 +55,7 @@ impl Mcp { } fn result(&mut self, tool: &str, args: Value) -> Value { - let resp = self.request("tools/call", json!({"name": tool, "arguments": args})); + let resp = common::tools_call(|m, p| self.request(m, p), tool, args.clone()); resp["result"].clone() } diff --git a/crates/ledger-cli/tests/spec_vault.rs b/crates/ledger-cli/tests/spec_vault.rs index 8283a39..b4c75d3 100644 --- a/crates/ledger-cli/tests/spec_vault.rs +++ b/crates/ledger-cli/tests/spec_vault.rs @@ -162,7 +162,7 @@ impl Mcp { /// must match the outputSchema. fn tool(&mut self, name: &str, args: Value) -> Out { let before = self.journal(); - let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); let out = if let Some(err) = resp.get("error") { Out::RpcErr(err.clone()) } else { diff --git a/crates/ledger-mcp/handbook/drafts-and-reversals.md b/crates/ledger-mcp/handbook/drafts-and-reversals.md index d324a66..a151b84 100644 --- a/crates/ledger-mcp/handbook/drafts-and-reversals.md +++ b/crates/ledger-mcp/handbook/drafts-and-reversals.md @@ -1,5 +1,16 @@ # Drafts and reversals +Drafts +- A draft (books.draft_entry, books.add_line) is in no report until it posts. books.list_drafts shows the open ones; books.preview_entry shows what posting one would do and returns the preview to confirm with. +- A draft is either building or awaiting_evidence. When its lines are final and it only waits for its receipt, say so: books.set_draft_state with state awaiting_evidence and a note saying what it waits for ("Figma invoice due 11/3"). The state changes no figure; the receipt rule is checked when the draft posts. When the receipt arrives, attach it (books.attach_receipt), preview, and post. +- A draft that will never post (entered twice, never billed) is thrown away with books.discard_draft and the reason: previewed first, then confirmed with the preview. Nothing in any report moves. A period holding an open draft cannot close, so post or discard it first. +- Reports say what is still open as of their end (as_of_status); pass strict: true to refuse a report while a draft dated in it is open. The CPA package is refused while one is open unless you pass allow_open_drafts with the reason. + +Every confirm takes the preview +- Every tool that takes confirmed returns a preview (a digest) when called without it, and needs that preview back with confirmed=true. If the books or an argument changed since (another session posted, a line was added), the confirm is refused as stale_preview: preview again, show the human, then confirm. + +Reversals - Posted entries are immutable: there is no edit and no delete. -- Corrections go through books.reverse_entry: without confirmed it previews the reversing entry and changes nothing; with confirmed=true it posts the reversal, linked to the original. Then draft and post the corrected entry through the normal flow. +- Corrections go through books.reverse_entry: without confirmed it previews the reversing entry and changes nothing; with confirmed=true and the preview it posts the reversal, linked to the original. Then draft and post the corrected entry through the normal flow. +- A reversal needs no receipt of its own: its evidence is its original's, which stays filed. A returned-item notice from the bank, if there is one, is attached to the reversal right after. - Show the human the reversal preview before confirming, as for any posting. diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index f30f597..991d2ce 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 129] = [ +pub const TABLE: [(&str, Needs); 133] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -163,8 +163,8 @@ pub const TABLE: [(&str, Needs); 129] = [ // These preview, then post when confirmed (P4b S4.4, A-1). ("books.create_invoice", C), ("books.create_bill", C), - ("books.receive_payment", P), - ("books.pay_bill", P), + ("books.receive_payment", C), + ("books.pay_bill", C), ("books.void_document", P), // Records a send: changes the books' record, never a figure. ("books.mark_sent", W), @@ -196,6 +196,11 @@ pub const TABLE: [(&str, Needs); 129] = [ ("books.annotate_attachment", W), ("books.set_entry_settles", W), ("books.consolidate_duplicates", C), + // P4: drafts (plan P4; a discard previews, then acts). + ("books.list_drafts", R), + ("books.set_draft_state", W), + ("books.discard_draft", C), + ("books.preview_entry", R), ]; /// What `tool` needs, given its arguments; None for a tool this server @@ -381,12 +386,18 @@ mod tests { ); assert_eq!( needs( - "books.receive_payment", + "books.void_document", args(json!({"confirmed": false})).as_ref() ), Some(Scope::Post), "a tool that posts at once needs books:post whatever it is given" ); + // P4: a payment previews, then posts when confirmed. + assert_eq!(needs("books.receive_payment", None), Some(Scope::Write)); + assert_eq!( + needs("books.pay_bill", args(json!({"confirmed": true})).as_ref()), + Some(Scope::Post) + ); assert_eq!(needs("books.access_log", None), Some(Scope::Admin)); assert_eq!(needs("books.nonesuch", None), None); // INVARIANT 4. @@ -435,7 +446,7 @@ mod tests { shown("books.create_invoice", &write), "it may preview an invoice" ); - assert!(!shown("books.receive_payment", &write)); + assert!(!shown("books.void_document", &write)); let post = set(&[Scope::Post]); assert!(shown("books.post_entry", &post)); assert!(!shown("books.chart", &post)); diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 6613b11..260283a 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -583,6 +583,7 @@ impl BooksServer { + Self::payouts_router() + Self::tax_router() + Self::write_offs_router() + + Self::drafts_router() } /// The settings in force for this request: the books' own, taken from @@ -1010,14 +1011,10 @@ impl BooksServer { if entry.status() != ledger_core::EntryStatus::Posted || entry.reversal_of().is_some() { return Ok(()); } - let split_group = self - .settings() - .policy + let split_group = ledger_core::evidence::Policy::from_settings(store.ledger().settings()) .split_group(store.ledger(), entry_id, entry); - if !self - .settings() - .policy - .receipt_required(store.ledger(), entry) + if !ledger_core::evidence::requirement(store.ledger(), entry, store.ledger().settings()) + .is_required() && split_group.is_empty() { return Ok(()); @@ -1328,6 +1325,10 @@ thread_local! { /// (set at its start from the settings in force: one version per /// request), so every "today" in it agrees. static TIMEZONE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// The arguments the request running on this thread was called with, + /// as the caller sent them: what the preview digest (plan P4 step 4, + /// D-50) is taken over, so a changed argument is a changed preview. + static ARGS: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } /// Poll `fut` with "today" in the request's zone on this thread, each @@ -1415,6 +1416,8 @@ mod attachments; mod chart; #[path = "tools/documents.rs"] mod documents; +#[path = "tools/drafts.rs"] +mod drafts; #[path = "tools/entries.rs"] mod entries; #[path = "tools/evidence.rs"] @@ -1503,7 +1506,10 @@ impl BooksServer { } "books://reports/balance-sheet" => { let Json(out) = self - .balance_sheet(Parameters(BalanceSheetParams { as_of: None })) + .balance_sheet(Parameters(BalanceSheetParams { + as_of: None, + strict: None, + })) .await?; json(&out) } @@ -1513,13 +1519,20 @@ impl BooksServer { start: None, end: None, class: None, + strict: None, })) .await?; json(&out) } "books://digest" => { let since = digest_since(query, today()); - let Json(out) = self.digest(Parameters(DigestParams { since })).await?; + let Json(out) = self + .digest(Parameters(DigestParams { + since: Some(since), + since_seq: None, + as_of: None, + })) + .await?; json(&out) } handbook::URI => { @@ -1694,6 +1707,7 @@ impl ServerHandler for BooksServer { let result = run_blocking(&self.store, move || { Box::pin(async move { WROTE.with(|w| w.borrow_mut().take()); + ARGS.with(|a| *a.borrow_mut() = request.arguments.clone()); let caller = running_call .caller() .map(|c| (c.token.id.clone(), c.token.name.clone())); diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 7e918cc..e21c92a 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -42,7 +42,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 129] = [ + let table: [(&str, Hints); 133] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -174,6 +174,11 @@ fn annotations_complete() { ("books.back_up_now", WI), // P4b S7: a bad-debt write-off. ("books.write_off_invoice", D), + // P4: drafts. + ("books.list_drafts", R), + ("books.set_draft_state", W), + ("books.discard_draft", D), + ("books.preview_entry", R), ]; let tools = BooksServer::tool_router().list_all(); assert_eq!(tools.len(), table.len()); @@ -213,8 +218,8 @@ fn annotations_complete() { "{name}: openWorldHint" ); } - // docs/mcp-interface.md names the 31 destructive tools. - assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 31); + // docs/mcp-interface.md names the 32 destructive tools. + assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 32); } /// Every refusal an agent can be given whose text is fixed here, or diff --git a/crates/ledger-mcp/src/tools/assets.rs b/crates/ledger-mcp/src/tools/assets.rs index 5b708bd..4d11d13 100644 --- a/crates/ledger-mcp/src/tools/assets.rs +++ b/crates/ledger-mcp/src/tools/assets.rs @@ -930,6 +930,7 @@ impl BooksServer { .receipt_required_for(&code_refs, debits); let out = DisposeAssetOut { + preview: None, status: "preview".to_string(), asset_id, entry_id: 0, @@ -943,6 +944,16 @@ impl BooksServer { lines: preview_lines_for(&store, &raw)?, receipt_required, }; + let digest = self.preview_of( + &store, + "books.dispose_asset", + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?; + let out = DisposeAssetOut { + preview: Some(digest), + ..out + }; if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index ccfe08c..56178db 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -513,6 +513,12 @@ impl BooksServer { .terms .as_ref() .map(ledger_core::Terms::describe); + let digest = self.preview_of( + &store, + "books.create_invoice", + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?; if !p.confirmed.unwrap_or(false) { let printed = ledger_package::draft_invoice_document( store.ledger(), @@ -527,7 +533,7 @@ impl BooksServer { ); let pdf = self.hand_over(&book_name(&store), &file_name, "application/pdf", bytes)?; return Ok(Json(InvoiceOut { - preview: Some(true), + preview: Some(digest), id: None, number: preview.number, total: money(preview.total), @@ -711,9 +717,15 @@ impl BooksServer { ); } } + let digest = self.preview_of( + &store, + "books.create_bill", + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?; if !p.confirmed.unwrap_or(false) { return Ok(Json(BillOut { - preview: Some(true), + preview: Some(digest), id: None, total: money(preview.total), due_date: preview.due_date.to_string(), diff --git a/crates/ledger-mcp/src/tools/drafts.rs b/crates/ledger-mcp/src/tools/drafts.rs new file mode 100644 index 0000000..0688611 --- /dev/null +++ b/crates/ledger-mcp/src/tools/drafts.rs @@ -0,0 +1,276 @@ +//! The drafts group (plan P4; rules.md B-43): which drafts are open and +//! what they wait for, a draft's state, throwing one away, and the full +//! preview of the post. Also what every report says is still open as of +//! its end (`as_of_status`) and the open-draft refusal of the CPA package. +//! +//! ```text +//! GOAL: a draft is never lost and never mistaken for a posted entry; a +//! report or a package says when drafts dated in it are missing +//! from its figures. +//! SOURCE: docs/spec/rules.md B-43; plan/served.md 4.8, D-47. +//! INVARIANTS: +//! 1. A state is a note on the draft: no figure moves. +//! 2. A discard is previewed, then confirmed with the preview's digest; +//! only a draft is discarded. +//! 3. `books.preview_entry` is `books.post_entry` without `confirmed`: +//! it changes nothing and returns the digest a confirm needs. +//! 4. A strict report refuses, naming what is open, instead of warning. +//! ``` + +use super::*; +use ledger_core::DraftState; + +impl BooksServer { + /// A draft as the tools list it. + pub(crate) fn draft_out( + &self, + store: &Store, + e: &ledger_core::JournalEntry, + ) -> Result { + let ledger = store.ledger(); + let required = + ledger_core::evidence::requirement(ledger, e, ledger.settings()).is_required(); + let receipts = store + .list_attachments(Some(e.id())) + .map_err(|e| e.to_string())?; + Ok(DraftOut { + entry_id: e.id(), + date: e.date().to_string(), + description: e.description().to_string(), + total: ledger_core::evidence::Policy::entry_debits(e) + .map(money) + .unwrap_or_else(|_| "too large".to_string()), + state: e.draft_state().key().to_string(), + note: e.draft_state().note().to_string(), + missing_evidence: required && receipts.is_empty(), + }) + } + + /// What is open as of `end`: drafts dated on or before it, accounts + /// not reconciled through the month before it, settings problems. + pub(crate) fn as_of_status( + &self, + store: &Store, + end: NaiveDate, + ) -> Result { + let ledger = store.ledger(); + let mut drafts = Vec::new(); + for e in ledger.drafts() { + if e.date() <= end { + drafts.push(self.draft_out(store, e)?); + } + } + drafts.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id))); + Ok(AsOfStatusOut { + drafts_open_on_or_before: drafts, + unreconciled: self.unreconciled_accounts(store, end)?, + settings_problems: settings_problems(ledger, &self.settings()), + }) + } + + /// A report's `as_of_status`, and under `strict` the refusal that + /// names what is open (INVARIANT 4). + pub(crate) fn report_status( + &self, + store: &Store, + end: NaiveDate, + strict: bool, + ) -> Result, String> { + let status = self.as_of_status(store, end)?; + let open = !status.drafts_open_on_or_before.is_empty() + || !status.unreconciled.is_empty() + || !status.settings_problems.is_empty(); + if strict && open { + let mut what = Vec::new(); + if !status.drafts_open_on_or_before.is_empty() { + what.push(format!( + "{} draft(s) dated on or before {end} are open (books.list_drafts)", + status.drafts_open_on_or_before.len() + )); + } + if !status.unreconciled.is_empty() { + what.push(format!( + "{} account(s) are not reconciled (books.digest)", + status.unreconciled.len() + )); + } + if !status.settings_problems.is_empty() { + what.push(format!( + "{} setting(s) disagree with the chart (books.settings)", + status.settings_problems.len() + )); + } + return Err(format!( + "strict: the figures as of {end} are not final: {}. Post or discard the drafts, or ask again without strict", + what.join("; ") + )); + } + Ok(Some(status)) + } +} + +#[tool_router(router = drafts_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.list_drafts", + title = "List draft entries", + description = "The draft entries still open: id, date, description, total, state (building, or awaiting_evidence with a note saying what it waits for) and whether the receipt policy covers it and it has no receipt yet (missing_evidence). state filters to one state; as_of to drafts dated on or before a day. A draft is not in any report until it posts; a period holding an open draft cannot close.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn list_drafts( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let state = match p.state.as_deref().map(str::trim) { + None | Some("") => None, + Some(s) => Some( + DraftState::parse(s, "") + .ok_or_else(|| format!("unknown state {s:?}: building or awaiting_evidence"))?, + ), + }; + let as_of = p.as_of.as_deref().map(parse_date).transpose()?; + let store = self.lock()?; + let mut drafts = Vec::new(); + for e in store.ledger().drafts() { + if state + .as_ref() + .is_some_and(|s| s.key() != e.draft_state().key()) + { + continue; + } + if as_of.is_some_and(|d| e.date() > d) { + continue; + } + drafts.push(self.draft_out(&store, e)?); + } + drafts.sort_by(|a, b| a.date.cmp(&b.date).then(a.entry_id.cmp(&b.entry_id))); + Ok(Json(ListDraftsOut { drafts })) + } + + #[tool( + name = "books.set_draft_state", + title = "Set a draft's state", + description = "Say where a draft stands: building (still being worked on) or awaiting_evidence with a note saying what it waits for (\"Figma invoice due 11/3\"). A state is a note: no line or figure changes, and the receipt policy is still checked when the draft posts. Only a draft has one. Needs the books' format upgrade (drafts_v2).", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn set_draft_state( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let note = p.note.as_deref().map(str::trim).unwrap_or(""); + let state = DraftState::parse(p.state.trim(), note).ok_or_else(|| { + format!( + "unknown state {:?}: building or awaiting_evidence", + p.state.trim() + ) + })?; + if matches!(state, DraftState::Building) && !note.is_empty() { + return Err("a note goes with awaiting_evidence: say what the draft waits for".into()); + } + let mut store = self.lock()?; + store + .set_draft_state(p.entry_id, state.clone()) + .map_err(|e| e.to_string())?; + Ok(Json(SetDraftStateOut { + entry_id: p.entry_id, + state: state.key().to_string(), + note: state.note().to_string(), + })) + } + + #[tool( + name = "books.discard_draft", + title = "Discard a draft", + description = "Throw away a draft that will never post, with the reason (journaled). Only a draft: a posted entry is never removed, it is reversed. Nothing in any report moves; the draft's id is not reused; a period it held open can close. Without confirmed=true, a preview that changes nothing and returns the preview to pass back with confirmed=true. Needs the books' format upgrade (drafts_v2).", + annotations( + read_only_hint = false, + destructive_hint = true, + open_world_hint = false + ) + )] + async fn discard_draft( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let confirmed = p.confirmed.unwrap_or(false); + let mut store = self.lock()?; + let e = store + .ledger() + .get_entry(p.entry_id) + .ok_or_else(|| format!("unknown entry {}", p.entry_id))? + .clone(); + if e.status() != ledger_core::EntryStatus::Draft { + return Err(format!( + "entry {} is posted: a posted entry is never removed, reverse it (books.reverse_entry)", + p.entry_id + )); + } + if p.reason.trim().is_empty() { + return Err("give the reason the draft will not post".into()); + } + // The draft as it stands, so a line added since is a stale preview. + let payload = serde_json::json!({ + "tool": "books.discard_draft", + "entry": &e, + "reason": p.reason.trim(), + "settings_seq": store.ledger().settings().last_seq(), + }); + let digest = Preview::check( + "books.discard_draft", + &payload, + confirmed, + p.preview.as_deref(), + )?; + let holds_open: Vec = store + .ledger() + .list_periods() + .into_iter() + .filter(|per| per.status == ledger_core::PeriodStatus::Open && per.contains(e.date())) + .map(|per| per.name.clone()) + .collect(); + let mut out = DiscardDraftOut { + status: "preview".into(), + entry_id: p.entry_id, + description: e.description().to_string(), + total: ledger_core::evidence::Policy::entry_debits(&e) + .map(money) + .unwrap_or_else(|_| "too large".to_string()), + holds_open, + preview: Some(digest), + }; + if !confirmed { + return Ok(Json(out)); + } + store + .discard_draft(p.entry_id, &p.reason) + .map_err(|e| e.to_string())?; + out.status = "discarded".into(); + Ok(Json(out)) + } + + #[tool( + name = "books.preview_entry", + title = "Preview posting a draft", + description = "What posting a draft would do, without posting: the lines, the receipt checklist and whether the receipt policy needs one, the party, any approvals missing, and the preview to pass to books.post_entry with confirmed=true. The same answer as books.post_entry without confirmed, as a read-only call.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn preview_entry( + &self, + params: Parameters, + ) -> Result, String> { + self.post_entry(Parameters(PostEntryParams { + entry_id: params.0.entry_id, + confirmed: None, + expected_total: None, + preview: None, + })) + .await + } +} diff --git a/crates/ledger-mcp/src/tools/entries.rs b/crates/ledger-mcp/src/tools/entries.rs index ca48fba..b4d00db 100644 --- a/crates/ledger-mcp/src/tools/entries.rs +++ b/crates/ledger-mcp/src/tools/entries.rs @@ -340,7 +340,7 @@ impl BooksServer { open_world_hint = false ) )] - async fn post_entry( + pub(super) async fn post_entry( &self, params: Parameters, ) -> Result, String> { @@ -368,20 +368,14 @@ impl BooksServer { )); } } - let mut receipt_required = self - .settings() - .policy - .receipt_required(store.ledger(), &entry); + // One function decides (plan P4, I-5): the engine's. A draft that + // joins a split group is covered too (anti-splitting). + let receipt_required = + ledger_core::evidence::requirement(store.ledger(), &entry, store.ledger().settings()) + .is_required(); let receipts = preview_receipts(&store, p.entry_id)?; - // Anti-splitting: if this entry looks like part of a split group, - // the receipt policy applies to the group total. - let split_group = self - .settings() - .policy + let split_group = ledger_core::evidence::Policy::from_settings(store.ledger().settings()) .split_group(store.ledger(), p.entry_id, &entry); - if !split_group.is_empty() { - receipt_required = true; - } let receipt_missing = receipt_required && receipts.is_empty(); let party_policy = self.settings().parties; let party_id = store.ledger().entry_party(p.entry_id); @@ -399,7 +393,8 @@ impl BooksServer { } else { Vec::new() }; - let out = PostEntryOut { + let mut out = PostEntryOut { + preview: None, status: "preview".to_string(), entry_id: p.entry_id, date: entry.date().to_string(), @@ -428,6 +423,26 @@ impl BooksServer { .map(|(rule, approver)| ApprovalNeededOut { rule, approver }) .collect(), }; + // The digest of the draft as it stands (plan P4 step 4, D-50): its + // lines, party, vendor, instrument and evidence, and the settings + // seq. Another session's unrelated post does not disturb it. + let payload = serde_json::json!({ + "tool": "books.post_entry", + "entry": &entry, + "party": out.party_id, + "parties": &out.parties, + "no_party_reason": &out.no_party_reason, + "vendor": store.ledger().entry_vendor(p.entry_id), + "instrument": store.ledger().entry_instrument(p.entry_id), + "evidence": out.receipts.iter().map(|r| r.attachment_id).collect::>(), + "settings_seq": store.ledger().settings().last_seq(), + }); + out.preview = Some(Preview::check( + "books.post_entry", + &payload, + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?); if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } @@ -480,38 +495,11 @@ impl BooksServer { }) .to_string()); } - if receipt_missing { - let split_note = if split_group.is_empty() { - String::new() - } else { - format!( - " Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.", - split_group - ) - }; - return Err(ReceiptRequiredError { - code: "receipt_required".to_string(), - message: format!( - "entry {} moves {} in debits ({}); attach a receipt with books.attach_receipt before posting.{}", - p.entry_id, - money(total), - self.settings().policy.describe(), - split_note - ), - entry_id: Some(p.entry_id), - asset_id: None, - debits: total.to_string(), - policy: self.settings().policy.describe(), - remedy_tool: "books.attach_receipt".to_string(), - remedy_hint: format!( - "attach a receipt to entry {} with books.attach_receipt, then retry books.post_entry with confirmed=true", - p.entry_id - ), - split_group, - } - .to_tool_error()); - } - store.post_entry(p.entry_id).map_err(|e| e.to_string())?; + // The evidence gate, in the store (plan P4, I-5): the same + // decision the preview showed, made again at the moment it posts. + store + .post_entry_checked(p.entry_id) + .map_err(|e| receipt_required_text(e, &self.settings().policy))?; fix_derived_shares(&mut store, &self.settings(), p.entry_id)?; let mut posted = out; posted.status = "posted".to_string(); @@ -560,7 +548,14 @@ impl BooksServer { (l.account, side, l.amount) }) .collect(); + let digest = self.preview_of( + &store, + "books.reverse_entry", + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?; let out = ReverseEntryOut { + preview: Some(digest), status: "preview".to_string(), reversal_id: 0, // filled in on post reverses: p.entry_id, diff --git a/crates/ledger-mcp/src/tools/evidence.rs b/crates/ledger-mcp/src/tools/evidence.rs index 8f717d3..f2ebd41 100644 --- a/crates/ledger-mcp/src/tools/evidence.rs +++ b/crates/ledger-mcp/src/tools/evidence.rs @@ -331,11 +331,18 @@ impl BooksServer { }) .collect(); let withdrawn = out_groups.iter().map(|g| g.withdraw.len()).sum(); + let digest = self.preview_of( + &store, + "books.consolidate_duplicates", + confirmed, + params.0.preview.as_deref(), + )?; if !confirmed { return Ok(Json(ConsolidateDuplicatesOut { status: "preview".into(), groups: out_groups, withdrawn, + preview: Some(digest), })); } for g in &groups { @@ -373,6 +380,7 @@ impl BooksServer { status: "consolidated".into(), groups: out_groups, withdrawn, + preview: Some(digest), })) } } diff --git a/crates/ledger-mcp/src/tools/helpers.rs b/crates/ledger-mcp/src/tools/helpers.rs index 4be05b6..967ad86 100644 --- a/crates/ledger-mcp/src/tools/helpers.rs +++ b/crates/ledger-mcp/src/tools/helpers.rs @@ -7,6 +7,7 @@ use super::*; use ledger_core::InstrumentId; +use ledger_store::StoreError; /// The book's id, as the commands given to agents name it: its folder's /// name in the data root. @@ -34,6 +35,56 @@ pub(crate) fn named( Ok((name, mime)) } +/// A refusal of the store's evidence gate as the tool error the agent +/// reads: the structured `receipt_required` (plan P4 step 2: the error the +/// MCP built before the gate moved into the store, unchanged), any other +/// store error as its text. +pub(crate) fn receipt_required_text(e: StoreError, policy: &ReceiptPolicy) -> String { + let StoreError::ReceiptRequired(info) = e else { + return e.to_string(); + }; + let ledger_store::error::ReceiptRequiredInfo { + entry_id, + debits, + split_group, + .. + } = *info; + let split_note = if split_group.is_empty() { + String::new() + } else { + format!( + " Anti-splitting: this looks like part of a split group with entries {:?}; the policy applies to the group total.", + split_group + ) + }; + // A draft's debits may be past exact arithmetic (rules.md B-28): then + // the refusal says so in place of the sum. + let (total, debits) = match debits { + Some(t) => (money(t), t.to_string()), + None => ( + "an amount too large to add".to_string(), + "too large".to_string(), + ), + }; + ReceiptRequiredError { + code: "receipt_required".to_string(), + message: format!( + "entry {entry_id} moves {total} in debits ({}); attach a receipt with books.attach_receipt before posting.{split_note}", + policy.describe() + ), + entry_id: Some(entry_id), + asset_id: None, + debits, + policy: policy.describe(), + remedy_tool: "books.attach_receipt".to_string(), + remedy_hint: format!( + "attach a receipt to entry {entry_id} with books.attach_receipt, then retry books.post_entry with confirmed=true" + ), + split_group, + } + .to_tool_error() +} + /// The settings as ledger_package reads them: the company, and the /// `[books]` cash, financing, and investing roles. pub(crate) fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> { @@ -764,3 +815,94 @@ pub(crate) fn floor_char_boundary(text: &str, at: usize) -> usize { } cut } + +/// The preview digest (plan P4 step 4, D-50, amending I-6): the SHA-256 of +/// the canonical JSON of what a tool's preview shows. A confirmed call +/// recomputes it from the books as they are now and refuses a different +/// one (`stale_preview`): a changed argument, a post by another session, +/// a changed setting. It detects drift; it does not prove a human saw the +/// preview. No key: a same-user key would prove nothing. +/// +/// ```text +/// GOAL: a commit acts on the thing that was previewed, or not at all. +/// SOURCE: plan/served.md I-6, D-50 (owner decision 3: a post by another +/// session in between forces a re-preview). +/// INVARIANTS: +/// 1. For a draft entry the payload is the entry's canonical state +/// (id, date, description, lines with their classes, party, vendor, +/// instrument, the evidence linked) and the settings seq: other +/// sessions' unrelated posts do not disturb it. +/// 2. For every other operation it is the tool, its canonical arguments +/// (without `confirmed` and `preview`), the settings seq and the +/// journal head seq: any write in between does. +/// 3. Equal payloads give equal digests whatever the key order. +/// ``` +pub struct Preview; + +impl Preview { + /// The digest of `payload`. + pub fn of(payload: &serde_json::Value) -> String { + ledger_store::journal::sha256_hex(ledger_store::journal::canonical_json(payload).as_bytes()) + } + + /// The payload of an operation without a draft: the tool, the + /// arguments it was called with (without `confirmed` and `preview`), + /// the settings seq and the journal head seq. + pub fn operation(store: &Store, tool: &str) -> serde_json::Value { + let mut args = ARGS.with(|a| a.borrow().clone()).unwrap_or_default(); + args.remove("confirmed"); + args.remove("preview"); + args.retain(|_, v| !v.is_null()); + serde_json::json!({ + "tool": tool, + "args": args, + "settings_seq": store.ledger().settings().last_seq(), + "head_seq": store.head().0, + }) + } + + /// The preview check every tool with `confirmed` makes: the digest of + /// `payload` now. Without `confirmed` it is what the preview returns; + /// with it, `given` must equal it, or the call is refused with the + /// structured `stale_preview` guard before anything is written. + pub fn check( + tool: &str, + payload: &serde_json::Value, + confirmed: bool, + given: Option<&str>, + ) -> Result { + let now = Self::of(payload); + if !confirmed { + return Ok(now); + } + let message = match given.map(str::trim) { + None | Some("") => format!( + "{tool} with confirmed=true needs the preview of what is being confirmed: call {tool} without confirmed, show it to the human, then pass its preview" + ), + Some(g) if g == now => return Ok(now), + Some(_) => format!( + "the books or the arguments changed since {tool} was previewed (a post by another session, a changed setting or argument): call {tool} without confirmed again and confirm that preview" + ), + }; + Err(serde_json::json!({ + "code": "stale_preview", + "message": message, + "remedy_tool": tool, + }) + .to_string()) + } +} + +impl BooksServer { + /// [`Preview::check`] for an operation without a draft: the payload is + /// built from the tool, its arguments and the books' seqs. + pub(crate) fn preview_of( + &self, + store: &Store, + tool: &str, + confirmed: bool, + given: Option<&str>, + ) -> Result { + Preview::check(tool, &Preview::operation(store, tool), confirmed, given) + } +} diff --git a/crates/ledger-mcp/src/tools/parties.rs b/crates/ledger-mcp/src/tools/parties.rs index a01ad6d..5f41a54 100644 --- a/crates/ledger-mcp/src/tools/parties.rs +++ b/crates/ledger-mcp/src/tools/parties.rs @@ -1201,6 +1201,7 @@ impl BooksServer { .map_err(|e| e.to_string())?; } let mut out = PurgeTinOut { + preview: None, status: "preview".into(), party_id: id, name: party.name.clone(), @@ -1227,6 +1228,13 @@ impl BooksServer { } ), }; + let digest = self.preview_of( + &store, + "books.purge_tin", + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?; + out.preview = Some(digest); if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } diff --git a/crates/ledger-mcp/src/tools/periods.rs b/crates/ledger-mcp/src/tools/periods.rs index b9f5e44..4e3e747 100644 --- a/crates/ledger-mcp/src/tools/periods.rs +++ b/crates/ledger-mcp/src/tools/periods.rs @@ -94,6 +94,12 @@ impl BooksServer { members, }) }; + let digest = self.preview_of( + &store, + "books.close_period", + p.confirmed, + p.preview.as_deref(), + )?; if !p.confirmed { // Preview: run the real close on a copy of the ledger, so the // preview is exactly what confirming would post. @@ -107,6 +113,7 @@ impl BooksServer { closing_entry: None, lines: describe(&scratch, closing), members: split(&scratch), + preview: Some(digest), })); } let closing = store @@ -119,6 +126,7 @@ impl BooksServer { closing_entry: closing, lines, members: split(store.ledger()), + preview: Some(digest), })) } diff --git a/crates/ledger-mcp/src/tools/reconciliation.rs b/crates/ledger-mcp/src/tools/reconciliation.rs index 96872d4..51d64d4 100644 --- a/crates/ledger-mcp/src/tools/reconciliation.rs +++ b/crates/ledger-mcp/src/tools/reconciliation.rs @@ -266,7 +266,10 @@ impl BooksServer { cleared_lines, problems, reconciliation: None, + preview: None, }; + let digest = self.preview_of(&store, "books.reconcile", confirmed, p.preview.as_deref())?; + out.preview = Some(digest); if !confirmed { return Ok(Json(out)); } diff --git a/crates/ledger-mcp/src/tools/reports.rs b/crates/ledger-mcp/src/tools/reports.rs index 5eb7965..dcf0f54 100644 --- a/crates/ledger-mcp/src/tools/reports.rs +++ b/crates/ledger-mcp/src/tools/reports.rs @@ -26,11 +26,14 @@ fn control_problems(out: &ControlCheckOut) -> Vec { v } -/// Net movement of one account over entries in `[from, to]`, normal side. -fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal { - let movement: Decimal = ledger - .posted_entries() - .filter(|e| e.date() >= from && e.date() <= to) +/// An account's net movement over `entries` in its normal direction. +fn movement_over( + ledger: &Ledger, + account: AccountId, + entries: &[&ledger_core::JournalEntry], +) -> Decimal { + let movement: Decimal = entries + .iter() .flat_map(|e| e.lines()) .filter(|l| l.account == account) .map(|l| match l.side { @@ -83,6 +86,7 @@ fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut { }; let r = &stmt.operating_reconciliation; CashFlowOut { + as_of_status: None, method: "direct".to_string(), start: stmt.start.to_string(), end: stmt.end.to_string(), @@ -278,8 +282,10 @@ impl BooksServer { ) -> Result, String> { let store = self.lock()?; let as_of = self.as_of(params.0.as_of.as_deref())?; + let as_of_status = self.report_status(&store, as_of, params.0.strict.unwrap_or(false))?; let tb = reports::trial_balance_as_of(store.ledger(), as_of); Ok(Json(TrialBalanceOut { + as_of_status, as_of: as_of.to_string(), lines: tb .lines @@ -322,7 +328,9 @@ impl BooksServer { p.end.as_deref(), class_id, )?; + let as_of_status = self.report_status(&store, pl.end, p.strict.unwrap_or(false))?; Ok(Json(ProfitAndLossOut { + as_of_status, start: pl.start.to_string(), end: pl.end.to_string(), revenue: money(pl.total_revenue), @@ -349,7 +357,9 @@ impl BooksServer { let p = params.0; let store = self.lock()?; let bs = self.bs_statement(store.ledger(), p.as_of.as_deref())?; + let as_of_status = self.report_status(&store, bs.as_of, p.strict.unwrap_or(false))?; Ok(Json(BalanceSheetOut { + as_of_status, as_of: bs.as_of.to_string(), assets: money(bs.total_assets), liabilities: money(bs.total_liabilities), @@ -386,7 +396,10 @@ impl BooksServer { &p.financing_accounts, &p.investing_accounts, )?; - Ok(Json(cash_flow_out(&stmt))) + let as_of_status = self.report_status(&store, stmt.end, p.strict.unwrap_or(false))?; + let mut out = cash_flow_out(&stmt); + out.as_of_status = as_of_status; + Ok(Json(out)) } #[tool( @@ -758,19 +771,36 @@ impl BooksServer { params: Parameters, ) -> Result, String> { let p = params.0; - let since = parse_date(&p.since)?; + let since = p.since.as_deref().map(parse_date).transpose()?; + if since.is_none() && p.since_seq.is_none() { + return Err("give since (a date) or since_seq (a journal sequence number)".into()); + } let today = today(); + let as_of = p.as_of.as_deref().map(parse_date).transpose()?; + let end = as_of.unwrap_or(today); let store = self.lock()?; let ledger = store.ledger(); + // The entries: after a journal line (by when they were posted, + // backdated ones included), else by their date. + let selected: Vec<&ledger_core::JournalEntry> = ledger + .posted_entries() + .filter(|e| match p.since_seq { + Some(n) => { + ledger.posted_seq(e.id()).is_some_and(|s| s > n) + && as_of.is_none_or(|d| e.date() <= d) + } + None => since.is_some_and(|s| e.date() >= s) && as_of.is_none_or(|d| e.date() <= d), + }) + .collect(); let mut entries = Vec::new(); - for e in ledger.posted_entries() { - if e.date() < since { - continue; - } + for e in &selected { let receipts = store .list_attachments(Some(e.id())) .map_err(|e| e.to_string())?; - let required = self.settings().policy.receipt_required(ledger, e); + // The one function that decides (plan P4, I-5): what posted + // an entry is part of the answer. + let required = + ledger_core::evidence::requirement(ledger, e, ledger.settings()).is_required(); entries.push(DigestEntry { entry_id: e.id(), date: e.date().to_string(), @@ -783,20 +813,40 @@ impl BooksServer { }); } entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); + // Movement over the entries asked for (by date: those dated from + // `since` through the end, as before). + let in_range: Vec<&ledger_core::JournalEntry> = if p.since_seq.is_some() { + selected.clone() + } else { + selected + .iter() + .copied() + .filter(|e| e.date() <= end) + .collect() + }; + let touched: std::collections::BTreeSet = in_range + .iter() + .flat_map(|e| e.lines()) + .map(|l| l.account) + .collect(); let mut totals = Vec::new(); + let mut zero_net_accounts = Vec::new(); for a in ledger.chart().all() { if a.placeholder { continue; } - let m = movement_in(ledger, a.id, since, today); + let m = movement_over(ledger, a.id, &in_range); if !m.is_zero() { totals.push(DigestTotal { code: a.code.clone(), name: a.name.clone(), movement: money(m), }); + } else if touched.contains(&a.id) { + zero_net_accounts.push(format!("{} {}", a.code, a.name)); } } + zero_net_accounts.sort(); totals.sort_by(|a, b| a.code.cmp(&b.code)); let stale_rules = ledger_core::rules::stale_rules(today) .into_iter() @@ -811,7 +861,9 @@ impl BooksServer { .tail_repairs() .map_err(|e| e.to_string())? .into_iter() - .filter(|(at, _, _)| at.get(..10).is_some_and(|d| d >= p.since.as_str())) + .filter(|(at, _, _)| { + since.is_none_or(|s| at.get(..10).is_some_and(|d| d >= s.to_string().as_str())) + }) // The stamp only: the file's name would name the journal. .map(|(at, bytes, saved_as)| JournalRepair { at, @@ -832,11 +884,40 @@ impl BooksServer { today, &ledger.control_check(today).map_err(|e| e.to_string())?, )); + let mut drafts_by_state = std::collections::BTreeMap::new(); + for e in ledger.drafts() { + *drafts_by_state + .entry(e.draft_state().key().to_string()) + .or_insert(0u64) += 1; + } + let with_statement: std::collections::BTreeSet = store + .statements(None) + .map_err(|e| e.to_string())? + .iter() + .map(|s| s.account_id) + .collect(); + let (mut reconciliation_tracked, mut never_reconciled) = (Vec::new(), Vec::new()); + for id in ledger_store::reconcilable::cash_accounts(ledger, &self.settings().roles.cash) { + let code = code_name(&store, id).0; + if with_statement.contains(&id) { + reconciliation_tracked.push(code); + } else { + never_reconciled.push(code); + } + } + reconciliation_tracked.sort(); + never_reconciled.sort(); Ok(Json(DigestOut { + since_seq: p.since_seq, + head_seq: store.head().0, + zero_net_accounts, + drafts_by_state, + reconciliation_tracked, + never_reconciled, unreconciled_accounts, uncategorized_accounts, control_problems, - since: since.to_string(), + since: since.map(|d| d.to_string()).unwrap_or_default(), entries, totals_by_account: totals, stale_rules, @@ -946,6 +1027,7 @@ impl BooksServer { let store = self.lock()?; let ledger = store.ledger(); let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?; + let as_of_status = self.report_status(&store, end, p.strict.unwrap_or(false))?; // One class, if asked (validated like every reference). let only = match &p.class { Some(c) => Some(resolve_class(&store, c)?), @@ -1018,6 +1100,7 @@ impl BooksServer { }) .collect(); Ok(Json(ProfitAndLossByClassOut { + as_of_status, start: start.to_string(), end: end.to_string(), classes, @@ -1063,7 +1146,55 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let as_of = self.as_of(p.as_of.as_deref())?; - let store = self.lock()?; + let mut store = self.lock()?; + // A package with a draft missing from its figures is not final + // (plan P4, 4.8): refused unless the human says why it goes anyway, + // and that is journaled as a decision. + let open_drafts: Vec<(u64, String)> = store + .ledger() + .drafts() + .into_iter() + .filter(|e| e.date() <= as_of) + .map(|e| (e.id(), e.description().to_string())) + .collect(); + if !open_drafts.is_empty() { + let named: Vec = open_drafts + .iter() + .map(|(id, d)| format!("#{id} {d}")) + .collect(); + let Some(allow) = p.allow_open_drafts.as_ref() else { + return Err(serde_json::json!({ + "code": "open_drafts", + "message": format!( + "{} draft(s) dated on or before {as_of} are open and missing from the package's figures: {}. Post them (books.post_entry) or discard them (books.discard_draft), or pass allow_open_drafts with the reason the package goes anyway", + open_drafts.len(), + named.join("; ") + ), + "remedy_tool": "books.list_drafts", + }) + .to_string()); + }; + let by = CALLER + .with(|c| c.borrow().as_ref().map(|(_, name)| name.clone())) + .unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string()); + store + .record_decision(&ledger_core::conventions::NewDecision { + subject: format!("CPA package as of {as_of} with open drafts"), + decision_md: format!( + "The CPA package as of {as_of} was exported with {} draft(s) open: {}. Reason: {}", + open_drafts.len(), + named.join("; "), + allow.reason.trim() + ), + decided_by: vec![by], + decided_on: today(), + evidence_attachment: None, + supersedes: None, + citation: None, + projects: Vec::new(), + }) + .map_err(|e| e.to_string())?; + } let book = book_name(&store); // The vault, opened once and never creating a key: locked, each // TIN row says so and the package is built anyway (INVARIANT 3). diff --git a/crates/ledger-mcp/src/tools/schedules.rs b/crates/ledger-mcp/src/tools/schedules.rs index 4205d17..ed28cb2 100644 --- a/crates/ledger-mcp/src/tools/schedules.rs +++ b/crates/ledger-mcp/src/tools/schedules.rs @@ -188,6 +188,12 @@ impl BooksServer { .map_err(|e| e.to_string())? .1) }; + let digest = self.preview_of( + &store, + "books.run_schedules", + p.confirmed, + p.preview.as_deref(), + )?; if !p.confirmed { let postings = due .into_iter() @@ -205,6 +211,7 @@ impl BooksServer { status: "preview".to_string(), postings, skipped, + preview: Some(digest), })); } let mut postings = Vec::new(); @@ -225,6 +232,7 @@ impl BooksServer { status: "posted".to_string(), postings, skipped, + preview: Some(digest), })) } diff --git a/crates/ledger-mcp/src/tools/settlements.rs b/crates/ledger-mcp/src/tools/settlements.rs index 90ca18e..5aef5c5 100644 --- a/crates/ledger-mcp/src/tools/settlements.rs +++ b/crates/ledger-mcp/src/tools/settlements.rs @@ -296,6 +296,7 @@ fn payment_out( _ => (None, None), }; Ok(Json(PaymentOut { + preview: None, entry_id, documents, deposited: money(deposited), @@ -305,6 +306,20 @@ fn payment_out( })) } +/// A payment's preview: the figures the settled documents would show +/// (entry 0: nothing is posted), with the digest to confirm it by. +fn payment_preview( + probe: &Ledger, + settle: &ledger_core::SettleParams, + warnings: Vec, + digest: String, +) -> Result, String> { + let mut out = payment_out(probe, 0, settle)?.0; + out.warnings = warnings; + out.preview = Some(digest); + Ok(Json(out)) +} + #[tool_router(router = settlements_router, vis = "pub(super)")] impl BooksServer { #[tool( @@ -324,9 +339,25 @@ impl BooksServer { let p = params.0; let mut store = self.lock()?; let (settle, warnings) = settlement(&store, &p, true)?; - let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; + let confirmed = p.confirmed.unwrap_or(false); + let digest = self.preview_of( + &store, + "books.receive_payment", + confirmed, + p.preview.as_deref(), + )?; + let entry_id = if confirmed { + store.settle(settle.clone()).map_err(|e| e.to_string())? + } else { + // The engine on a clone: every refusal the post would give, + // and the figures it would leave. Nothing is written. + let mut probe = store.ledger().clone(); + probe.settle(settle.clone()).map_err(|e| e.to_string())?; + return payment_preview(&probe, &settle, warnings, digest); + }; let mut out = payment_out(store.ledger(), entry_id, &settle)?.0; out.warnings = warnings; + out.preview = Some(digest); Ok(Json(out)) } @@ -346,9 +377,18 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; - let (settle, _) = settlement(&store, &p, false)?; + let (settle, warnings) = settlement(&store, &p, false)?; + let confirmed = p.confirmed.unwrap_or(false); + let digest = self.preview_of(&store, "books.pay_bill", confirmed, p.preview.as_deref())?; + if !confirmed { + let mut probe = store.ledger().clone(); + probe.settle(settle.clone()).map_err(|e| e.to_string())?; + return payment_preview(&probe, &settle, warnings, digest); + } let entry_id = store.settle(settle.clone()).map_err(|e| e.to_string())?; - payment_out(store.ledger(), entry_id, &settle) + let mut out = payment_out(store.ledger(), entry_id, &settle)?.0; + out.preview = Some(digest); + Ok(Json(out)) } #[tool( diff --git a/crates/ledger-mcp/src/tools/write_offs.rs b/crates/ledger-mcp/src/tools/write_offs.rs index 62b137b..a4a47d0 100644 --- a/crates/ledger-mcp/src/tools/write_offs.rs +++ b/crates/ledger-mcp/src/tools/write_offs.rs @@ -18,6 +18,7 @@ fn write_off_out( .map_err(|e| e.to_string())? .ok_or_else(|| format!("invoice {invoice} not found"))?; Ok(WriteOffInvoiceOut { + preview: None, entry_id, document: WriteOffDocumentOut { r#ref: format!("invoice:{invoice}"), @@ -77,6 +78,12 @@ impl BooksServer { } _ => {} } + let digest = self.preview_of( + &store, + "books.write_off_invoice", + p.confirmed.unwrap_or(false), + p.preview.as_deref(), + )?; if !p.confirmed.unwrap_or(false) { // The engine on a clone: every refusal the post would give, and // the figures the write-off would leave. Nothing is written. @@ -84,7 +91,9 @@ impl BooksServer { probe .write_off(p.invoice, date, amount, &p.reason) .map_err(|e| e.to_string())?; - return Ok(Json(write_off_out(&probe, p.invoice, 0)?)); + let mut out = write_off_out(&probe, p.invoice, 0)?; + out.preview = Some(digest); + return Ok(Json(out)); } let entry_id = match p.upload_id.as_deref() { Some(upload_id) => { diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 6a17706..42642c9 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -191,6 +191,11 @@ pub struct ListSchedulesOut { /// Parameters for `books.run_schedules`. #[derive(Debug, Deserialize, JsonSchema)] pub struct RunSchedulesParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// Post months ending on or before this date (default today). pub through: Option, /// Without true, a preview: nothing posts. @@ -227,6 +232,10 @@ pub struct ScheduleSkippedOut { /// Result of `books.run_schedules`. #[derive(Debug, Serialize, JsonSchema)] pub struct RunSchedulesOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "posted". pub status: String, /// Postings due (preview) or made. @@ -1000,6 +1009,12 @@ pub struct TrialBalanceLineOut { /// Parameters for `books.trial_balance`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct TrialBalanceParams { + /// True: refuse the report, naming what is open, while a draft dated + /// on or before its end, an unreconciled account or a settings + /// problem makes its figures not final (`as_of_status`). Default: + /// the report is given, with `as_of_status` as the warning. + #[serde(default)] + pub strict: Option, /// As of this day ([`AS_OF_DOC`]). #[serde(default)] #[schemars(description = AS_OF_DOC)] @@ -1009,6 +1024,10 @@ pub struct TrialBalanceParams { /// Result of `books.trial_balance`. #[derive(Debug, Serialize, JsonSchema)] pub struct TrialBalanceOut { + /// What is open as of the report's end: drafts dated in it, + /// unreconciled accounts, settings problems (plan P4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_of_status: Option, /// The as-of date, YYYY-MM-DD. pub as_of: String, /// Lines in code order. @@ -1024,6 +1043,12 @@ pub struct TrialBalanceOut { /// Parameters for `books.profit_and_loss`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ProfitAndLossParams { + /// True: refuse the report, naming what is open, while a draft dated + /// on or before its end, an unreconciled account or a settings + /// problem makes its figures not final (`as_of_status`). Default: + /// the report is given, with `as_of_status` as the warning. + #[serde(default)] + pub strict: Option, /// Period start ([`START_DOC`]). #[schemars(description = START_DOC)] pub start: Option, @@ -1039,6 +1064,10 @@ pub struct ProfitAndLossParams { /// Result of `books.profit_and_loss`. #[derive(Debug, Serialize, JsonSchema)] pub struct ProfitAndLossOut { + /// What is open as of the report's end: drafts dated in it, + /// unreconciled accounts, settings problems (plan P4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_of_status: Option, /// Period start, YYYY-MM-DD. pub start: String, /// Period end, YYYY-MM-DD. @@ -1069,6 +1098,12 @@ pub struct StatementLineOut { /// Parameters for `books.cash_flow`. #[derive(Debug, Deserialize, JsonSchema)] pub struct CashFlowParams { + /// True: refuse the report, naming what is open, while a draft dated + /// on or before its end, an unreconciled account or a settings + /// problem makes its figures not final (`as_of_status`). Default: + /// the report is given, with `as_of_status` as the warning. + #[serde(default)] + pub strict: Option, /// Period start ([`START_DOC`]). #[schemars(description = START_DOC)] pub start: Option, @@ -1229,6 +1264,10 @@ pub struct CashFlowWarningOut { /// Result of `books.cash_flow`; `books.export` writes the same JSON. #[derive(Debug, Serialize, JsonSchema)] pub struct CashFlowOut { + /// What is open as of the report's end: drafts dated in it, + /// unreconciled accounts, settings problems (plan P4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_of_status: Option, /// "direct" (ASC 230-10-45-25). pub method: String, /// Period start, YYYY-MM-DD. @@ -1358,6 +1397,12 @@ pub struct ImportChartCsvOut { /// Parameters for `books.balance_sheet`. #[derive(Debug, Deserialize, JsonSchema)] pub struct BalanceSheetParams { + /// True: refuse the report, naming what is open, while a draft dated + /// on or before its end, an unreconciled account or a settings + /// problem makes its figures not final (`as_of_status`). Default: + /// the report is given, with `as_of_status` as the warning. + #[serde(default)] + pub strict: Option, /// As of this day ([`AS_OF_DOC`]). #[schemars(description = AS_OF_DOC)] pub as_of: Option, @@ -1366,6 +1411,10 @@ pub struct BalanceSheetParams { /// Result of `books.balance_sheet`. #[derive(Debug, Serialize, JsonSchema)] pub struct BalanceSheetOut { + /// What is open as of the report's end: drafts dated in it, + /// unreconciled accounts, settings problems (plan P4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_of_status: Option, /// As of this date, YYYY-MM-DD. pub as_of: String, /// Total assets, decimal string. @@ -1561,6 +1610,11 @@ pub struct PreviewReceipt { /// Parameters for `books.post_entry`. #[derive(Debug, Deserialize, JsonSchema)] pub struct PostEntryParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// Draft entry id. pub entry_id: u64, /// Pass true to post after reviewing the preview. Default false: returns @@ -1574,6 +1628,10 @@ pub struct PostEntryParams { /// Result of `books.post_entry`: either a preview or a posted confirmation. #[derive(Debug, Serialize, JsonSchema)] pub struct PostEntryOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "posted". pub status: String, /// The entry id. @@ -1652,6 +1710,11 @@ pub struct PartySuggestionOut { /// Parameters for `books.reverse_entry`. #[derive(Debug, Deserialize, JsonSchema)] pub struct ReverseEntryParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// Id of the posted entry to reverse. pub entry_id: u64, /// Date of the reversing entry, YYYY-MM-DD. @@ -1665,6 +1728,10 @@ pub struct ReverseEntryParams { /// Result of `books.reverse_entry`. #[derive(Debug, Serialize, JsonSchema)] pub struct ReverseEntryOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "posted". pub status: String, /// The new reversing entry's id. @@ -2040,8 +2107,19 @@ pub struct SearchEntriesOut { /// Parameters for `books.digest`. #[derive(Debug, Deserialize, JsonSchema)] pub struct DigestParams { - /// Include entries posted on or after this date, YYYY-MM-DD. - pub since: String, + /// Include entries dated on or after this date, YYYY-MM-DD (by entry + /// date). Give this or `since_seq`. + #[serde(default)] + pub since: Option, + /// Include the entries posted after this journal sequence number: what + /// was posted since a session's head (`head.seq` of an earlier answer), + /// backdated entries included, which `since` by date would miss. + #[serde(default)] + pub since_seq: Option, + /// Count movement through this day ([`AS_OF_DOC`]); default today. + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, } /// One entry in the digest. @@ -2080,8 +2158,27 @@ pub struct DigestTotal { /// Result of `books.digest`: what changed, for human review. #[derive(Debug, Serialize, JsonSchema)] pub struct DigestOut { - /// The since date, YYYY-MM-DD. + /// The since date, YYYY-MM-DD (empty when asked by `since_seq`). pub since: String, + /// The journal sequence number the entries are after, when asked by + /// `since_seq`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since_seq: Option, + /// The journal's head now: pass its `seq` as `since_seq` next time. + pub head_seq: u64, + /// Accounts the entries touched whose net movement is zero ("CODE + /// Name"): they are not in totals_by_account, but something moved in + /// them and back. + pub zero_net_accounts: Vec, + /// How many drafts are open, by state (`building`, + /// `awaiting_evidence`). + pub drafts_by_state: std::collections::BTreeMap, + /// Cash and card accounts (books.cash_accounts) that have a statement + /// recorded, by code. + pub reconciliation_tracked: Vec, + /// Cash and card accounts with no statement ever recorded, by code: + /// reconciliation has not started for them. + pub never_reconciled: Vec, /// Entries posted in the period, newest first. pub entries: Vec, /// Net movement per account over the period. @@ -2117,7 +2214,7 @@ pub struct DigestOut { } /// An account due for reconciliation (`books.digest`). -#[derive(Debug, Serialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct UnreconciledAccountOut { /// Account code. pub code: String, @@ -2741,6 +2838,11 @@ pub struct SkippedDepreciation { /// Parameters for `books.dispose_asset`. #[derive(Debug, Deserialize, JsonSchema)] pub struct DisposeAssetParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// Asset id (or pass `tag`). pub asset_id: Option, /// The asset's tag, instead of its id. @@ -2771,6 +2873,10 @@ pub struct DisposeAssetParams { /// Result of `books.dispose_asset`. #[derive(Debug, Serialize, JsonSchema)] pub struct DisposeAssetOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "posted". pub status: String, /// Asset id. @@ -2864,6 +2970,11 @@ pub struct CreatePeriodOut { /// Parameters for `books.close_period`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ClosePeriodParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// The period to close. pub period_id: u64, /// Without confirmed=true, returns a preview (closing lines) and closes @@ -2875,6 +2986,10 @@ pub struct ClosePeriodParams { /// Output of `books.close_period`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ClosePeriodOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "closed". pub status: String, /// The period id. @@ -2983,6 +3098,11 @@ pub struct MarkClearedOut { /// `statement_balance` for a check against any balance. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ReconcileParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// Account id or code (e.g. "1000") to reconcile. With /// statement_attachment, optional (it must be the statement's). pub account: Option, @@ -3021,6 +3141,10 @@ pub struct ReconcileLineOut { /// Output of `books.reconcile`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ReconcileOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" (nothing recorded) or "reconciled" (recorded). pub status: String, /// Account code. @@ -3134,6 +3258,11 @@ pub struct BillToParam { /// `confirmed`, and posts with it (A-1). #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct CreateInvoiceParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// The customer: a customer id, its party's id, or its name. A name /// that two customers answer to is refused. pub customer: Option, @@ -3164,6 +3293,11 @@ pub struct CreateInvoiceParams { /// and posts with it. #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct CreateBillParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// The vendor: a vendor id, its party's id, or its name. A name that /// two vendors answer to is refused. pub vendor: Option, @@ -3240,6 +3374,15 @@ pub struct FeeParam { /// Parameters for `books.receive_payment` / `books.pay_bill`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PaymentParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, + /// Pass true to post after reviewing the preview. Without it, a + /// preview: nothing posts. + #[serde(default)] + pub confirmed: Option, /// Invoice id (receive_payment) or bill id (pay_bill); the older name /// of `invoice` / `bill`. #[serde(default)] @@ -3311,6 +3454,10 @@ pub struct PaidDocumentOut { /// Output of `books.receive_payment` / `books.pay_bill`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PaymentOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// The payment journal entry's id. pub entry_id: u64, /// Every document it settled, in application order. @@ -4165,6 +4312,10 @@ pub struct ClassPnlOut { /// Output of `books.profit_and_loss_by_class`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ProfitAndLossByClassOut { + /// What is open as of the report's end: drafts dated in it, + /// unreconciled accounts, settings problems (plan P4). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub as_of_status: Option, /// Period start, YYYY-MM-DD. pub start: String, /// Period end, YYYY-MM-DD. @@ -4180,6 +4331,12 @@ pub struct ProfitAndLossByClassOut { /// Parameters for `books.export_cpa_package`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ExportCpaPackageParams { + /// Hand the package over although drafts dated on or before `as_of` + /// are still open, and say why (journaled as a decision). Without it + /// the export is refused while any is open: a package with a draft + /// missing from its figures is not the CPA's final. + #[serde(default)] + pub allow_open_drafts: Option, /// As of this day ([`AS_OF_DOC`]); the period reports cover the /// fiscal year that holds it, through it. #[schemars(description = AS_OF_DOC)] @@ -4477,6 +4634,11 @@ pub struct GetW9Out { /// Parameters for `books.purge_tin`. #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct PurgeTinParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// The party: id or name. pub party: String, /// Why (retention period over, the payee asked, a wrong TIN): @@ -4490,6 +4652,10 @@ pub struct PurgeTinParams { /// Result of `books.purge_tin`. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct PurgeTinOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "purged". pub status: String, /// The party. @@ -5174,9 +5340,11 @@ pub struct IssuedFileOut { /// posted invoice. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct InvoiceOut { - /// True: a preview, and nothing was posted. + /// The digest of what this shows (SHA-256 of its canonical preview): + /// present on a preview, which posted nothing; pass it back as + /// `preview` with confirmed=true. #[serde(default, skip_serializing_if = "Option::is_none")] - pub preview: Option, + pub preview: Option, /// The invoice's id (confirm). #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, @@ -5219,9 +5387,11 @@ pub struct InvoiceOut { /// posted bill. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct BillOut { - /// True: a preview, and nothing was posted. + /// The digest of what this shows (SHA-256 of its canonical preview): + /// present on a preview, which posted nothing; pass it back as + /// `preview` with confirmed=true. #[serde(default, skip_serializing_if = "Option::is_none")] - pub preview: Option, + pub preview: Option, /// The bill's id (confirm). #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, @@ -5331,6 +5501,11 @@ pub struct MarkSentOut { /// Parameters for `books.write_off_invoice`. #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct WriteOffInvoiceParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// The invoice's id (books.documents). pub invoice: u64, /// The write-off's day, YYYY-MM-DD: not before the invoice, and in an @@ -5379,6 +5554,10 @@ pub struct WriteOffDocumentOut { /// Output of `books.write_off_invoice`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct WriteOffInvoiceOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// The write-off's journal entry's id (0 in a preview). pub entry_id: u64, /// The invoice's derived figures after it. @@ -5527,6 +5706,11 @@ pub struct DuplicateGroupOut { #[derive(Debug, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConsolidateDuplicatesParams { + /// The `preview` the call without `confirmed` returned: the digest of + /// what was previewed. Required with confirmed=true; a changed + /// argument or books since the preview is refused (stale_preview). + #[serde(default)] + pub preview: Option, /// True: consolidate. Without it, a preview that changes nothing. pub confirmed: Option, } @@ -5534,6 +5718,10 @@ pub struct ConsolidateDuplicatesParams { /// Result of `books.consolidate_duplicates`. #[derive(Debug, Serialize, JsonSchema)] pub struct ConsolidateDuplicatesOut { + /// The digest of what this shows (SHA-256 of its canonical preview): + /// pass it back as `preview` with confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, /// "preview" or "consolidated". pub status: String, /// Each set of attachments holding one file's bytes. @@ -5541,3 +5729,144 @@ pub struct ConsolidateDuplicatesOut { /// How many attachments were (or would be) withdrawn. pub withdrawn: usize, } + +// --------------------------------------------------------------------------- +// P4: draft states, the evidence gate, the preview digest, the digest tool +// --------------------------------------------------------------------------- + +/// Parameters for `books.list_drafts`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ListDraftsParams { + /// Only drafts in this state: `building` or `awaiting_evidence`. + #[serde(default)] + pub state: Option, + /// Only drafts dated on or before this day ([`AS_OF_DOC`]). Default: + /// all. + #[serde(default)] + #[schemars(description = AS_OF_DOC)] + pub as_of: Option, +} + +/// One open draft entry. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct DraftOut { + /// The draft's id. + pub entry_id: u64, + /// Its date, YYYY-MM-DD. + pub date: String, + /// Its description. + pub description: String, + /// Total debits so far, decimal string ("too large" past exact + /// arithmetic). + pub total: String, + /// `building` or `awaiting_evidence`. + pub state: String, + /// What it waits for (empty when building or when none was said). + pub note: String, + /// The receipt policy covers it and it has no receipt yet. + pub missing_evidence: bool, +} + +/// Result of `books.list_drafts`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ListDraftsOut { + /// Open drafts, by date then id. + pub drafts: Vec, +} + +/// Parameters for `books.set_draft_state`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SetDraftStateParams { + /// The draft's id. + pub entry_id: u64, + /// `building` (still being worked on) or `awaiting_evidence` (finished + /// and waiting for its receipt or other document). + pub state: String, + /// What it waits for ("Figma invoice due 11/3"). Only with + /// `awaiting_evidence`. + #[serde(default)] + pub note: Option, +} + +/// Result of `books.set_draft_state`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct SetDraftStateOut { + /// The draft's id. + pub entry_id: u64, + /// Its state now. + pub state: String, + /// Its note now. + pub note: String, +} + +/// Parameters for `books.discard_draft`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DiscardDraftParams { + /// The draft's id. + pub entry_id: u64, + /// Why it will not post ("entered twice"); journaled with the discard. + pub reason: String, + /// True: discard it. Without it, a preview that changes nothing. + #[serde(default)] + pub confirmed: Option, + /// The `preview` the call without `confirmed` returned. Required with + /// confirmed=true; a changed draft or books since is refused + /// (stale_preview). + #[serde(default)] + pub preview: Option, +} + +/// Result of `books.discard_draft`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct DiscardDraftOut { + /// "preview" or "discarded". + pub status: String, + /// The draft's id. + pub entry_id: u64, + /// Its description. + pub description: String, + /// Its total debits so far. + pub total: String, + /// Whether it held a period open, and the period's name. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub holds_open: Vec, + /// The digest of what this shows: pass it back as `preview` with + /// confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, +} + +/// Parameters for `books.preview_entry`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PreviewEntryParams { + /// The draft's id. + pub entry_id: u64, +} + +/// What a report leaves open as of its end date (`as_of_status`): shown +/// on every report so a number is not taken for final while a draft +/// dated in it waits. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AsOfStatusOut { + /// Drafts dated on or before the report's end: not in these figures, + /// but they will be. + pub drafts_open_on_or_before: Vec, + /// Cash and card accounts with activity not reconciled through the + /// end of the month before the report's end. + pub unreconciled: Vec, + /// Settings that disagree with the chart (books.settings). + pub settings_problems: Vec, +} + +/// `books.export_cpa_package`'s `allow_open_drafts`: hand the package over +/// with drafts dated in it still open, and say why. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AllowOpenDraftsParam { + /// Why the package goes with these drafts open ("the CPA asked for it + /// before the Figma invoice arrives"); journaled as a decision. + pub reason: String, +} diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs index ed6a9b5..fe5ee52 100644 --- a/crates/ledger-package/src/attention.rs +++ b/crates/ledger-package/src/attention.rs @@ -278,10 +278,39 @@ pub fn find_with( }; let money = ledger_core::parties::money_accounts(ledger, &money_cash, &financing); let members = settings.company.party_members(ledger); - let entries = entries(books, &journal, &policy); + let entries = entries(books, &journal); let mut out = Vec::new(); + // Two kinds of open draft (plan P4, R-F): one that says it waits for + // its evidence, and one still being built. for e in entries.iter().filter(|e| e.status == EntryStatus::Draft) { + if let Some(note) = &e.awaiting_evidence { + let waits = if note.is_empty() { + String::new() + } else { + format!(": {note}") + }; + out.push(Attention { + severity: Severity::Todo, + kind: "draft_awaiting_evidence", + title: format!("Draft #{} awaiting receipt: {}", e.id, e.description), + detail: format!( + "#{} · {} · {}. Waiting for its evidence{waits}. It appears in no report until posted.", + e.id, + date(e.date), + dollars_or(e.amount) + ), + target: Target::Entry(e.id), + prompt: format!( + "In wanderbooks, draft entry {} (\"{}\", {}) is waiting for its evidence{waits}. If the receipt has arrived, attach it, preview the entry (books.preview_entry) and post it; if it never will, say why and discard it (books.discard_draft).", + e.id, + e.description, + dollars_or(e.amount) + ), + tool: Some(("books.get_entry".into(), serde_json::json!({ "entry_id": e.id }))), + }); + continue; + } out.push(Attention { severity: Severity::Todo, kind: "draft", @@ -1362,15 +1391,14 @@ struct Entry { parties: Vec, /// Covered by the receipt policy and has no receipt. missing_receipt: bool, + /// A draft that says it is finished and waits for its evidence: the + /// note it was given (plan P4). + awaiting_evidence: Option, } /// Every entry, with the journal-derived facts the items read: its origin /// and whether the receipt policy covers it without a receipt. -fn entries( - books: &impl PackageBooks, - journal: &[JournalLine], - policy: &ledger_config::ReceiptPolicy, -) -> Vec { +fn entries(books: &impl PackageBooks, journal: &[JournalLine]) -> Vec { let ledger = books.ledger(); let mut events: HashMap> = HashMap::new(); for (i, line) in journal.iter().enumerate() { @@ -1405,10 +1433,9 @@ fn entries( let evs = events.remove(&id).unwrap_or_default(); let origin = origin(&evs, journal, closings.contains(&id)); let amount = ledger_config::ReceiptPolicy::entry_debits(e).ok(); - let covered = matches!(origin, "Journal entry" | "Recurring") - && e.reversal_of().is_none() - && receipts.get(&id).copied().unwrap_or(0) == 0 - && policy.receipt_required(ledger, e); + // The engine decides (plan P4, I-5), as the server's gate does. + let covered = receipts.get(&id).copied().unwrap_or(0) == 0 + && ledger_core::evidence::requirement(ledger, e, ledger.settings()).is_required(); out.push(Entry { id, date: e.date(), @@ -1418,6 +1445,14 @@ fn entries( origin, parties: ledger.entry_parties(id).iter().map(|p| p.party).collect(), missing_receipt: covered, + awaiting_evidence: match e.draft_state() { + ledger_core::DraftState::AwaitingEvidence { note } + if e.status() == EntryStatus::Draft => + { + Some(note.clone()) + } + _ => None, + }, }); } out diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index a1d19d9..8bbe1dd 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -38,20 +38,10 @@ pub enum StoreError { /// The receipt policy covers the entry and it has no receipt (plan P4, /// I-5; rules.md B-43): refused before anything is journaled. #[error( - "entry {entry_id} needs a receipt before it posts: {reason} ({policy}); attach one with books.attach_receipt" + "entry {} needs a receipt before it posts: {} ({}); attach one with books.attach_receipt", + .0.entry_id, .0.reason, .0.policy )] - ReceiptRequired { - /// The entry that needs the receipt. - entry_id: u64, - /// Its total debits, `None` when they pass exact arithmetic. - debits: Option, - /// The policy that triggered, in words. - policy: String, - /// Why this entry is covered. - reason: String, - /// The posted entries it appears to split (anti-splitting). - split_group: Vec, - }, + ReceiptRequired(Box), /// Filesystem error. #[error("io error: {0}")] @@ -217,3 +207,18 @@ fn newer_journal_text(seq: u64, event_type: &str, need: &str, verified: bool) -> ) } } + +/// What a refused post tells (boxed in [`StoreError::ReceiptRequired`]). +#[derive(Debug)] +pub struct ReceiptRequiredInfo { + /// The entry that needs the receipt. + pub entry_id: u64, + /// Its total debits, `None` when they pass exact arithmetic. + pub debits: Option, + /// The policy that triggered, in words. + pub policy: String, + /// Why this entry is covered. + pub reason: String, + /// The posted entries it appears to split (anti-splitting). + pub split_group: Vec, +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 8990bad..223047d 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1549,13 +1549,15 @@ impl Store { && self.list_attachments(Some(entry))?.is_empty() { let policy = ledger_core::evidence::Policy::from_settings(self.ledger.settings()); - return Err(StoreError::ReceiptRequired { - entry_id: entry, - debits: ledger_core::evidence::Policy::entry_debits(&e).ok(), - policy: policy.describe(), - split_group: policy.split_group(&self.ledger, entry, &e), - reason, - }); + return Err(StoreError::ReceiptRequired(Box::new( + crate::error::ReceiptRequiredInfo { + entry_id: entry, + debits: ledger_core::evidence::Policy::entry_debits(&e).ok(), + policy: policy.describe(), + split_group: policy.split_group(&self.ledger, entry, &e), + reason, + }, + ))); } self.post_entry(entry) } diff --git a/docs/architecture.md b/docs/architecture.md index d9e3f45..a00682d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -135,6 +135,16 @@ extractions have a table of their own. The viewer's Documents page groups by category and filters by subject; Needs attention reads both through `PackageBooks`. +**The evidence gate (P4, I-5).** `ledger_core::evidence::requirement` +(with `Policy`, the settings' `policy.evidence.*` read in the core) is the +one decision whether an entry needs a receipt; `Store::post_entry_checked`, +the server's previews and digest, the viewer and Needs attention all call +it, and `ReceiptPolicy` in `ledger-config` keeps only the words. A draft +has a state (`DraftState`, `entry.draft_state_set`) and can be discarded +(`entry.draft_discarded`); `Ledger::posted_seq` is the journal line that +posted each entry. Every MCP tool with `confirmed` takes the digest +`Preview` (`tools/helpers.rs`) its preview returned. + ## The journal format gate (`ledger-store`, P0) Every journal line passes a gate after its parse, seq and chain checks diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 528a6f6..b8aa6db 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -354,7 +354,7 @@ Tool descriptions are at most 700 characters; eight longer ones are listed in `tool_descriptions_fit_700_characters`, to be shortened in P5. **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool -that changes anything also has a `destructiveHint`, true for these 30: +that changes anything also has a `destructiveHint`, true for these 31: `books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`, `books.unlink_attachment`, `books.withdraw_attachment`, `books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`, @@ -366,7 +366,7 @@ that changes anything also has a `destructiveHint`, true for these 30: `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.void_document`, `books.write_off_invoice`, `books.run_payroll`, `books.remit_payroll_taxes`, -`books.consolidate_duplicates`; and an +`books.consolidate_duplicates`, `books.discard_draft`; and an `idempotentHint` on the ten that a repeat with the same arguments leaves the journal and the vault as they were: `books.verify`, `books.run_depreciation`, `books.run_schedules`, `books.run_recurring`, @@ -428,6 +428,24 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. + resulting account balances) and post nothing. The agent must present the preview to the human before confirming. This encodes the standing fact-check rule in the protocol itself. +- **The preview digest (P4, D-50).** Every tool called without + `confirmed` returns `preview`, the SHA-256 of what it shows: for a draft + entry, its canonical state (lines, classes, party, vendor, instrument, + the evidence linked) and the settings seq; for any other operation, the + tool, its arguments, the settings seq and the journal head seq. Every + call with `confirmed: true` takes that `preview` and is refused, before + anything is written, with the guard `{ code: "stale_preview", message, + remedy_tool }` when it is missing or no longer matches: an argument + changed, or another session wrote in between (a post by another session + forces a re-preview, by design). No key is involved: it detects drift; + it does not prove a human looked. The tools that take `preview` are all + those with `confirmed`: `books.post_entry`, `books.reverse_entry`, + `books.run_schedules`, `books.dispose_asset`, `books.reconcile`, + `books.close_period`, `books.create_invoice`, `books.create_bill`, + `books.receive_payment`, `books.pay_bill`, `books.write_off_invoice`, + `books.purge_tin`, `books.consolidate_duplicates` and + `books.discard_draft`. (`books.void_document`, `books.run_depreciation` + and `books.run_recurring` post at once and have no `confirmed`.) - **Errors are typed strings** mirroring `LedgerError` variants, with the offending values included (e.g. unbalanced totals). @@ -460,7 +478,7 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. - `books.add_line { entry_id, account: "1000" | , side: "debit" | "credit", amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or class, bad amount) adds nothing. -- `books.post_entry { entry_id, confirmed?, expected_total? }` → `{ status, entry_id, date, +- `books.post_entry { entry_id, confirmed?, preview?, expected_total? }` → `{ status, preview, entry_id, date, description, total, lines (with resulting balances), receipts, receipt_required, receipt_missing, split_detected, party_policy, party_id?, party?, parties, parties_error?, no_party_reason?, @@ -473,7 +491,7 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. share: "amount" | "percent" | "derived", amount?, percent?, resolved }`); when the shares don't add up to what the entry moved, `parties_error` says why and confirming fails with code `party_shares`. -- `books.reverse_entry { entry_id, date, description, confirmed }` → same +- `books.reverse_entry { entry_id, date, description, confirmed, preview? }` → same preview/confirm pattern as post. A reversed depreciation run or schedule month is un-posted (its month is due again); refused, at the preview already (P3): an asset's disposal entry (it would stay disposed), a @@ -578,10 +596,23 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. one; no registered asset's account as cash). - Changed in P3 (breaking): `net_income`, `depreciation` and `working_capital` are gone (net income is in `operating_reconciliation`). -- `books.digest { since: "2026-10-01" }` - → `{ since, entries: [...], totals_by_account: [...], stale_rules: [...], +- `books.digest { since: "2026-10-01" | since_seq: 412, as_of? }` + → `{ since, since_seq?, head_seq, entries: [...], totals_by_account: [...], + zero_net_accounts: [...], drafts_by_state: { building?, awaiting_evidence? }, + reconciliation_tracked: [...], never_reconciled: [...], stale_rules: [...], journal_repairs: [...], settings_problems: [...], unreconciled_accounts: - [...], uncategorized_accounts: [...] }`. + [...], uncategorized_accounts: [...] }`. `since` selects entries by their + date; `since_seq` selects the entries posted after a journal line (the + `head_seq` of an earlier answer), backdated entries included, which a + date would miss (P4, defect 6). `as_of` (default today) bounds the + movement. An account the entries touched whose net movement is zero is + listed under `zero_net_accounts` rather than dropped silently. + `drafts_by_state` counts the open drafts; `reconciliation_tracked` and + `never_reconciled` split the cash and card accounts (`books.cash_accounts`) + by whether a statement was ever recorded for them. Each entry's + `receipt_required` is the engine's one decision + (`ledger_core::evidence::requirement`): a document's entry, a close, a + depreciation run and a reversal need none of their own. The human review surface: "what did the AI do with my books lately." The digest is the answer to "trust, but verify." `stale_rules` lists legal amounts wanderbooks may no longer know as of today (`{ name, @@ -724,7 +755,7 @@ life's last day is 9999-12-31 at the latest. run-all's assets not yet in service and any whose schedule can't be computed. A run before the in-service date is refused. - `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account, - gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` (the + gain_loss_account, buyer?, confirmed?, preview?, receipt_attachment_ids? }` (the date never before the acquired date) → without confirmation: `{ status: "preview", asset_id, entry_id (0 in a preview), accumulated, book_value, @@ -808,7 +839,7 @@ through the policy. compute: then it says why, `remaining` and `next` are null and `months` empty, and `books.run_schedules` refuses until it is dealt with. - `books.list_schedules {}` → `{ schedules: [...] }`, same shape. -- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" | +- `books.run_schedules { through?, confirmed?, preview? }` → `{ status: "preview" | "posted", postings: [{ schedule_id, date, description, amount, entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every unposted month ending on or before `through` (default today). @@ -1482,7 +1513,7 @@ and the CPA package carries them. of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault doesn't hold written another way, are not detected: P7 takes full TINs out of the agent's reach. -- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" | +- `books.purge_tin { party, reason, confirmed?, preview? }` → `{ status: "preview" | "purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?, journaled, note }`. Without `confirmed`, a preview that changes nothing. Confirmed, deletes the party's vault records (needs no key) and, when the @@ -1667,7 +1698,7 @@ checks the text blob's presence and hash. none may be the paying entry itself or be named twice. It changes no amount. "The member was paid back" is read from this record only, never guessed (IZ-18); Needs attention asks until it is recorded. -- `books.consolidate_duplicates { confirmed? }` → `{ status: "preview" | +- `books.consolidate_duplicates { confirmed?, preview? }` → `{ status: "preview" | "consolidated", groups: [{ sha256, keep, withdraw, links }], withdrawn }`. For every set of attachments holding the same bytes it keeps the oldest (`keep`), links it to every subject the others had, unlinks the @@ -1707,10 +1738,18 @@ posting. violation, and the append-only audit log would show it anyway. (`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]` when the split check triggered.) -- **Not checked yet:** `books.receive_payment`, `books.pay_bill`, - `books.run_payroll` and `books.remit_payroll_taxes` post at once with no - receipt check: attach their evidence right after, to the entry they - return. (`books.create_invoice` and `books.create_bill` preview and +- **One gate (P4, I-5, D-47).** `ledger_core::evidence::requirement` + decides whether an entry needs evidence; the store's gate + (`Store::post_entry_checked`), the previews, the digest, the viewer and + Needs attention all call it. A reversal needs no receipt of its own (its + evidence is its original's, D-12); neither does a closing entry, a + schedule month's or a depreciation run's posting, an asset's disposal, + a document's entry or a payroll run or remittance. A refusal is the + `receipt_required` error above, unchanged. +- **Not checked:** `books.receive_payment`, `books.pay_bill`, + `books.run_payroll` and `books.remit_payroll_taxes` are exempt from the + receipt policy by origin (above): attach their evidence right after, to + the entry they return. (`books.create_invoice` and `books.create_bill` preview and confirm, and a bill's vendor invoice is required, in books that have declared `documents_v2`.) - **Anti-splitting.** Posting an entry triggers a split check: posted @@ -1971,7 +2010,7 @@ accounting only: the caller supplies every figure), tax filing. - `books.create_period { name, start, end }` → `{ period_id }`. Dates are YYYY-MM-DD, inclusive. -- `books.close_period { period_id, confirmed? }` → without confirmation: +- `books.close_period { period_id, confirmed?, preview? }` → without confirmation: `{ status: "preview", period_id, closing_entry: null, lines: [...], members }`: the exact closing entry (the close run on a copy of the ledger), posting nothing; with `confirmed: true`: `{ status: "closed", @@ -2030,7 +2069,7 @@ accounting only: the caller supplies every figure), tax filing. the same attachment can record it again. Refused while a reconciliation not voided rests on it. - `books.reconcile { statement_attachment? | account, statement_date, - statement_balance, confirmed? }` → `{ status: "preview" | "reconciled", + statement_balance, confirmed?, preview? }` → `{ status: "preview" | "reconciled", account_code, statement_date, statement_attachment?, book_balance, cleared_balance, statement_balance, difference, uncleared_lines, cleared_lines, problems, reconciliation? }`, each line `{ entry_id, @@ -2069,7 +2108,7 @@ snapshots; see plan 8.2 S4. lines: [{ description, amount, quantity?, unit_price?, account?, class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?, phone?, contact?, billing_address?, shipping_address?, billing_email? }, - confirmed? }` → without `confirmed`: `{ preview: true, number?, total, + confirmed?, preview? }` → without `confirmed`: `{ preview, number?, total, due_date, terms?, lines: [{ account, name, side, amount, class? }], pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?, total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 }, @@ -2124,7 +2163,7 @@ snapshots; see plan 8.2 S4. - `books.create_bill { vendor | party_id, date, received_on?, vendor_ref?, terms? | due_date?, lines: [{ description, amount, account, class? }], memo?, upload_id | existing_attachment_id, duplicate_reason?, - confirmed? }` → without `confirmed`: `{ preview: true, total, due_date, + confirmed?, preview? }` → without `confirmed`: `{ preview, total, due_date, terms?, lines: [...], discount_by?, max_discount? }` and nothing is written; with `confirmed: true`: `{ id, total, due_date, terms?, entry_id, lines?, discount_by?, max_discount?, file: { attachment_id, @@ -2158,9 +2197,12 @@ snapshots; see plan 8.2 S4. - `books.receive_payment { date, deposit_to | cash_account, instrument?, reference?, invoice | document_id, amount? | applications: [{ invoice, amount, discount?, discount_reason? }], fee?: { amount, account?, - vendor? }, paid_in_full_marking? }` → `{ entry_id, documents: [{ ref, - number?, outstanding, status }], deposited, remaining?, status?, - warnings? }`. Settles one or several invoices of one customer in one + vendor? }, paid_in_full_marking?, confirmed?, preview? }` → `{ preview?, + entry_id, documents: [{ ref, number?, outstanding, status }], deposited, + remaining?, status?, warnings? }`. Without `confirmed: true` a preview + (entry 0, the figures the documents would show, `preview` to pass + back) and nothing is written (P4: the S6 tools preview and confirm like + the create tools). Settles one or several invoices of one customer in one entry (B-42): Dr the deposit account the cash less the fee, Dr the payment-processing-fees account the fee, Dr sales allowances the discounts, Cr A/R each invoice's cash and discount, every receivable @@ -2180,7 +2222,7 @@ snapshots; see plan 8.2 S4. payments; they never collect them (D-45). - `books.pay_bill { date, paid_from | cash_account, instrument?, reference?, bill | document_id, amount? | applications: [{ bill, amount, - discount?, discount_reason? }] }` → as `receive_payment`, per bill and + discount?, discount_reason? }], confirmed?, preview? }` → as `receive_payment`, per bill and one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer account the cash, Cr purchase discounts the discounts. A discount on a bill with an asset or prepaid line is refused with its remedy (record @@ -2208,7 +2250,7 @@ snapshots; see plan 8.2 S4. (`format_upgrade_pending`). `sends` is every send on the invoice, in order, the new one last. - `books.write_off_invoice { invoice, date, amount?, reason, upload_id | - existing_attachment_id, confirmed? }` → `{ entry_id, document: { ref, + existing_attachment_id, confirmed?, preview? }` → `{ entry_id, document: { ref, status, paid, written_off, outstanding } }`. Writes off an invoice that will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr `6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable @@ -2425,10 +2467,15 @@ until the owner upgrades them. ## Tools — CPA handoff - `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, - investing_accounts?, include_receipts?, include_tins? }` → `{ file, + investing_accounts?, include_receipts?, include_tins?, + allow_open_drafts?: { reason } }` → `{ file, tins_included, tin_gaps, warnings?, note }`: the zip, `cpa-package-.zip`, handed over as `file` (a `HandedFile`, "Tools — the TIN vault"). + **Open drafts** (P4, plan 4.8): refused with `open_drafts` while a draft + dated on or before `as_of` is open (post it or discard it); with + `allow_open_drafts: { reason }` the package goes and the reason is + journaled as a decision. **As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects anything dated after `as_of` (default today): no figure, entry, receipt or worksheet row; the chart and `documents/` are as they stand. @@ -2520,3 +2567,40 @@ until the owner upgrades them. count and what each file is). Period reports cover the fiscal year holding `as_of`, through it. What the CPA needs; they never log into the system. + +## Tools — drafts (P4) + +A draft is an entry being built (`books.draft_entry`, `books.add_line`) that +is not in any report until it posts. P4 gives it a state, a way out, and a +read-only preview (rules.md B-43; the journal feature `drafts_v2`: the +state and discard tools wait for the books' format upgrade). + +- `books.list_drafts { state?, as_of? }` → `{ drafts: [{ entry_id, date, + description, total, state, note, missing_evidence }] }`. `state` is + `building` or `awaiting_evidence`; `as_of` keeps drafts dated on or + before a day. `missing_evidence` is true when the receipt policy covers + the draft and it has no receipt yet. +- `books.set_draft_state { entry_id, state, note? }` → `{ entry_id, state, + note }`. `awaiting_evidence` with a note ("Figma invoice due 11/3") says + the draft is finished and waits for its document; `building` says it is + being worked on. A state is a note: no line or figure changes, and the + receipt policy is still checked when the draft posts. Only a draft has + one. +- `books.discard_draft { entry_id, reason, confirmed?, preview? }` → + `{ status: "preview" | "discarded", entry_id, description, total, + holds_open?, preview }`. Throws away a draft that will never post; only a + draft (a posted entry is reversed, never removed). Nothing in any report + moves, the id is not reused, and a period the draft held open can close + (`holds_open` names those). Previewed first, then confirmed with the + `preview` digest. +- `books.preview_entry { entry_id }` → the `books.post_entry` preview + (`status: "preview"`, lines, receipts, `receipt_required`, + `receipt_missing`, party and approval checks, `preview`) as a read-only + call; pass its `preview` to `books.post_entry` with `confirmed: true`. + +**What a report leaves open.** `books.trial_balance`, `books.profit_and_loss`, +`books.balance_sheet`, `books.cash_flow` and +`books.profit_and_loss_by_class` return `as_of_status: { +drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems: +[...] }` for the report's end date, and take `strict: true` to be refused, +naming what is open, instead of warned. diff --git a/docs/parity.md b/docs/parity.md index a344771..0930ee6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -207,3 +207,19 @@ what a payment settles (`entry.settles`, the only source of "reimbursed"), and gives company records four more homes (`notices`, `correspondence`, `plans`, `history`, D-20). Rules.md B-44 states it; `docs/testing.md`'s P6 section lists its tests and the P12 mutation targets. + +### P4: drafts, the evidence gate, the preview digest (2026-10-29) + +Three new rows, none above changed status, and the floor stands. + +| Area | Counterpart (a cross-check, not the spec) | Status | Notes | +|---|---|---|---| +| Draft states and discard (`books.set_draft_state`, `books.discard_draft`, `books.list_drafts`; Lean `Step.discard`) | Draft/pending transactions (QBO "pending"), voiding an unposted entry | partial | `entry.draft_state_set` and `entry.draft_discarded` under `drafts_v2`; B-43; tests `discard_unblocks_close`, `draft_states_replay_and_project`, `a_draft_waits_for_its_receipt_and_posts`; the fixture `p4_before` was written by the version before and opens to the same figures; `partial` until a hand-verified golden covers a discard | +| One evidence gate (`evidence::requirement`) | Attach-required rules on posting | partial | the store, the previews, the digest, the viewer and Needs attention call one function; `gui_and_server_gate_agree`, `a_reversal_needs_no_receipt_of_its_own` (D-47); same tests as above for the golden | +| Preview digest on every confirm (D-50) | Confirm dialogs | partial | `stale_preview_refused`, `every_confirm_tool_takes_a_preview`; detects drift, not a human's look | + +The digest also gained `since_seq` (what was posted since a journal line, +backdated entries included: defect 6), `zero_net_accounts`, +`drafts_by_state` and the reconciliation split; reports gained +`as_of_status` and `strict`; the CPA package is refused while a draft +dated in it is open (4.8). diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 395afed..ff76422 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -3431,6 +3431,77 @@ check_discount}`, `arap::{Application, Fee, SettleParams, SettlementRow}`, The Lean step is `Documents.Step.document` with every control line tagged (`Documents.settle_unattached`); no new `Step` constructor. +## B-43 A draft waits on its evidence, and one that will never post can be discarded + +**bookspec:** `BS-entry-5` + +**Status:** AUTHORITY (the evidence a draft waits for, Pub. 583 and Rev. +Proc. 98-25); DECISION D-47 (owner-adopted recommendation, 2026-10-26: +reversals stay posted and need no evidence of their own); CONVENTION (the +two states, and discarding a draft rather than posting and reversing it). + +```text +GOAL: no entry the receipt policy covers posts without the document that + supports it, decided in one place; a draft that is finished and + waits for that document says so; a draft that will never post can + be thrown away, so it stops holding its period open. +SOURCE: IRS Pub. 583 (docs/spec/sources/irs-records.md: "you must keep + supporting documents ... they support the entries in your + books"); Rev. Proc. 98-25 §5.01 (the records must let the books be + demonstrated); DECISION D-47 (a reversal's evidence is its + original's, D-12); CONVENTION (a draft's state is a note: it moves + no figure). +INVARIANTS: + 1. One function, `evidence::requirement`, decides whether an entry needs + evidence: `Required` (with why) or `Exempt` (with the source of the + exemption). The store's gate, the server's previews and digest, the + viewer and Needs attention all call it; none decides on its own. + 2. Exempt by origin, each with its source: a period's closing entry + (derived from the books), a schedule month's or a depreciation run's + posting (the schedule is the evidence), an asset's disposal, an entry + a document or a payroll command posts (it carries its own evidence + or is posted at once by design: an invoice, a bill, a payment, a + write-off, a payroll run or its tax remittance), and a reversal (its + evidence is its original's, D-47, D-12). Every other entry, hand + entries and recurring drafts included, is covered by the policy + settings (`policy.evidence.*`): required above the threshold, on a + required account, or as part of a split group; exempt only when every + line is on an exempt account. + 3. A draft has a state: `building` (the default) or `awaiting_evidence` + with a note. A state is a note on the draft; it moves no line, no + balance and no report, and a posted entry has none. + 4. A draft may be discarded, with a reason, and only a draft: a posted + entry is never removed (B-8). Discarding moves no balance and spends + no id, and what was recorded about the draft (its vendor, instrument + and parties) goes with it; the journal still holds its lines and why + it went. A discarded draft no longer holds a period open. + 5. Evidence is checked when the entry posts, not when it is drafted: a + draft awaiting evidence posts the moment the evidence is attached + and the post is confirmed. + 6. Every refusal leaves the journal unchanged. +SCENARIOS: + 1. A hand entry of 40.00 under a 25.00 threshold, no receipt: the post + is refused with `receipt_required`; the draft may be set + `awaiting_evidence` with the note "Figma invoice due". + 2. The receipt is attached; the post succeeds; the entry has no state. + 3. A reversal of that entry: it posts with no receipt of its own. + 4. A draft dated in January, January closing: the close is refused + while the draft stands; discarded, the close succeeds. + 5. A posted entry: set-state and discard are both refused. +``` + +**Enforced:** `ledger_core::evidence::{requirement, Requirement}`, +`Ledger::{set_draft_state, discard_draft, posted_seq}`, +`Store::{set_draft_state, discard_draft, post_entry_checked}` (the gate the server posts a hand draft by; `Store::post_entry` stays the primitive commands and fixtures post through); events +`entry.draft_state_set`, `entry.draft_discarded` (feature `drafts_v2`). +**Checked:** Lean `Step.discard`, `Step.discard_keeps_books`, +`Step.discard_unblocks_close`; `ledger-core` `tests/invariants.rs` +(`discard_unblocks_close`); `ledger-store` `tests/store.rs` +(`draft_states_replay_and_project`, `drafts_v2_waits_for_the_upgrade`, +`posted_seq_is_the_posting_lines_seq`); `ledger-cli` `tests/drafts.rs` (`a_draft_waits_for_its_receipt_and_posts`, +`a_reversal_needs_no_receipt_of_its_own`, `gui_and_server_gate_agree`, +`stale_preview_refused`). + ## B-44 Evidence is read from the bytes, never trusted over them; identical files are kept once **bookspec:** `BS-ledger-12` diff --git a/docs/testing.md b/docs/testing.md index 9699fdd..bd944d8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3594,3 +3594,78 @@ token" must die on `an_unreadable_token_file_is_an_error_never_no_token`), `Keeper::delete`'s `NotFound` arm, and `Account::key`'s three parts (`an_account_names_the_root_by_hash_and_the_book_and_token`); `bridge.rs::token_file_refusal`'s `holds_token` guard. + +## P4: draft states, the evidence gate, the preview digest (2026-10-29) + +A draft has a state (`building`, or `awaiting_evidence` with a note) and a +way out (`books.discard_draft`); one function, `evidence::requirement`, +decides whether an entry needs evidence; every tool with `confirmed` takes +the `preview` digest it returned; `books.digest` can be asked "since a +journal line". Journal feature `drafts_v2`; events `entry.draft_state_set` +and `entry.draft_discarded`; schema 19 (taken as the next after the last, +renumbered at merge): `entries.posted_seq` and the `draft_states` table +(drafts are not rows of `entries`, so the plan's `entries.draft_state` +column became a table). + +- **`ledger-core` `tests/invariants.rs`:** `discard_unblocks_close` (a + period with an open draft can't close, a state changes no figure, + discarded it closes; balances, order and ids as they were; a posted + entry is never discarded or given a state). +- **Lean:** `Step.discard` (preservation of `Inv`, including + `draftsOpen`), `Step.discard_keeps_books`, `Step.discard_drafts`, + `Step.discard_unblocks_close`, in `Audit.lean`. +- **`ledger-store` `tests/store.rs`:** `draft_states_replay_and_project` + (both events journaled as written, `draft_states` holds only the waiting + draft and loses it when it posts, live equals replay, the refusals), + `drafts_v2_waits_for_the_upgrade`, `posted_seq_is_the_posting_lines_seq` + (hand, backdated, reversal and closing entries: the number is the line + that posted them, live, replayed and projected); + `books_written_under_older_rules_still_open` hand-writes both events. + `tests/format.rs`: `an_older_version_stops_at_the_draft_events`; + `format.rs`'s `drafts_v2_is_its_own_feature`. +- **8.0.6:** the fixture `old-books/p4_before` (writer + `old-books-writer/src/bin/p4_before.rs`, run against 25b52580) holds + hand entries posted late, a reversal, a document and its payment, a + schedule month, a recurring draft, two open drafts and a closed month; + `every_book_an_earlier_version_wrote_upgrades_and_still_verifies` opens + it to the writer's figures, so no figure moved (serialized entries omit + a building state). `books_p2_served_cut_over_whole` refuses the two draft + tools before consent and takes them after. +- **`ledger-cli` `tests/drafts.rs`:** `a_draft_waits_for_its_receipt_and_posts`, + `the_gate_moved_and_the_mcp_error_is_unchanged`, + `a_reversal_needs_no_receipt_of_its_own` (D-47), + `discard_unblocks_the_close_over_the_wire`, + `report_warns_on_draft_before_as_of` (and `strict`), + `cpa_package_refuses_open_draft` (and `allow_open_drafts` journaling a + decision), `stale_preview_refused` (a changed line, a changed argument, + a write between preview and confirm, no preview), + `every_confirm_tool_takes_a_preview`, `digest_since_seq_is_posting_time` + (with `zero_net_accounts`, `drafts_by_state`, `never_reconciled`), + `legacy_reversed_replays_identically` (the fixture over the wire), + `gui_and_server_gate_agree` (three policies over random books; the + viewer's missing-evidence set equals the engine's and equals the old + reading of an entry's origin from the journal's events). +- **`ledger-cli` `tests/attention_parity.rs`:** + `a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists`. +- **The sweep:** every over-the-wire harness confirms the way an agent does + (`common::tools_call`: the call without `confirmed`, then with the + `preview`); `books.receive_payment` and `books.pay_bill` gained + `confirmed`; `ledger-mcp` `scopes.rs` tests pin them as preview-then-post. + +**For P12 (mutation targets):** `evidence::requirement`'s exemption arms +(reversal, closing, schedule month, depreciation run, disposal origin, +payroll remittance origin, a tagged document line, a payroll run) and +their order, the split-group branch taken only for a `Draft`, and +`Policy::reason_for`'s three comparisons (required account, `<=` the +threshold, every code exempt); `Ledger::origin_exemption`; the replay +arms that note `AssetDisposal` and `PayrollRemittance`; +`Ledger::discard_draft`'s empty-reason and posted refusals and the side +tables it clears; `set_draft_state`'s `check_text`; `stamp_unstamped` and +`expect_posted_seq` (a mutant that stamps the wrong line shows in +`posted_seq_is_the_posting_lines_seq`); `Preview::operation` (the keys it +drops: `confirmed`, `preview`, nulls) and `Preview::check`'s three arms +(missing, equal, different); `post_entry`'s draft payload (each field of +it); the digest's `since_seq` comparison (`>`), its `as_of` bound, the +`zero_net_accounts` branch and the `never_reconciled` split; +`report_status`'s three open conditions; the open-drafts refusal's +`<= as_of` and its decision; the attention `awaiting_evidence` branch. diff --git a/plan/served.md b/plan/served.md index 70ffeb5..5124f44 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1479,7 +1479,23 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-core/src/evidence.rs`, `crates/ledger-mcp/src/tools/drafts.rs`, `crates/ledger-cli/tests/drafts.rs`, `handbook/drafts-and-reversals.md`; touches `journal.rs`, `ledger.rs`, `store.rs`, every tool with `confirmed` (the sweep), `crates/ledger-gui/src/books.rs`, `crates/ledger-package/src/attention.rs`, the 8.0.3 files. 6. **Tests and proofs:** named above; Lean `Step.discard`; no kernel change. 7. **Done when:** (1) every tool with `confirmed` takes `preview` and refuses a stale one (`every_confirm_tool_takes_a_preview`, `stale_preview_refused`); (2) one function decides evidence and the GUI's string match is gone (`gui_and_server_gate_agree`); (3) reversals still post at once with no receipt of their own (`a_reversal_needs_no_receipt_of_its_own`); (4) the drafts tools and the digest fields exist and are documented; (5) 8.0.6 in the PR (a `figures.json` from the pre-P4 version over a fixture holding reversals and drafts: no figure moves); (6) `just check`, `just prove` green. -8. **As built:** none yet. +8. **As built (2026-10-29):** departures from Build, each with its reason. + - **`crates/ledger-core/src/evidence.rs` was not new.** P6 created it (annotations, settlements); `Policy`, `Requirement`, `Origin` and `requirement` were appended to it. + - **Schema 19 is a table, not a column.** Drafts are not rows of `entries` (they never touch the projection until posted), so `entries.draft_state` had nowhere to live: a `draft_states(entry_id, state, note)` table holds the drafts awaiting evidence (a draft being built is the default and has no row), and `entries.posted_seq` is a column. The number 19 is the next after the current last (18); the EM renumbers at merge. + - **`Store::post_entry` stays the primitive; the gate is `Store::post_entry_checked`.** About 176 call sites in tests, fixtures, the demo and bookspec post hand entries over 25.00 with no receipt through `Store::post_entry`; a gate there would refuse all of them. The server posts a hand draft only through `post_entry_checked`, which calls `evidence::requirement` and refuses with `StoreError::ReceiptRequired`; the MCP maps it to the unchanged `receipt_required` error (`the_gate_moved_and_the_mcp_error_is_unchanged`). + - **Payouts are not exempt.** The unit lists payouts among the exempt origins; `books.record_payout` makes an ordinary draft whose statement is attached as its receipt (its own description says so), and the viewer has always covered it, so it stays covered (no behaviour change). The exempt origins are exactly the ones the viewer's journal-event reading exempted: a close, a schedule month, a depreciation run, a disposal, a document's entry (any line tagged with a document), a payroll run or remittance, a reversal. The ledger learned the two it could not see (a disposal, a remittance) from `asset.disposed` and `payroll.taxes_remitted` (`Origin`), live and on replay. + - **A split group counts for a draft only.** `requirement` applies the anti-splitting rule to drafts (as the post always did) and not to posted entries, whose decision was made when they posted; otherwise the viewer would flag legitimately posted neighbours. + - **`posted_seq` is stamped for every posting, not only `entry.posted`/`entry.reversed`**: a close (`period.closed`) and a schedule month (`schedule.posted`) post an entry too. `Ledger::expect_posted_seq` (set when the store journals `entry.posted`/`entry.reversed`, before the command that posts) and `stamp_unstamped` (after each replayed line, and after the two live single-line commands) make live equal replay (`posted_seq_is_the_posting_lines_seq`). + - **The preview digest is taken over the arguments as the caller sent them** (a thread-local set in `call_tool`, `ARGS`), not over re-serialized parameters (the params structs are `Deserialize` only); `Preview::of`/`check` and `BooksServer::preview_of` live in `tools/helpers.rs`, not `server.rs`. + - **`confirmed` was added to `books.receive_payment` and `books.pay_bill`** (S6 deferred it): without it a preview on a clone of the ledger (entry 0), with it the post; their scope rows are now preview-then-post. `books.void_document`, `books.run_depreciation` and `books.run_recurring` have no `confirmed` today (the unit's list names them, the grep does not) and post at once as before; not changed. `InvoiceOut.preview`/`BillOut.preview` changed from `true` to the digest. + - **The digest tool**: `since` became optional beside `since_seq`; `books.digest`'s `receipt_required` is now the engine's decision, so a document's, a close's or a depreciation run's entry no longer reads "receipt missing". + - **Reports**: `as_of_status` and `strict` are on `books.trial_balance`, `books.profit_and_loss`, `books.balance_sheet`, `books.cash_flow` and `books.profit_and_loss_by_class`. + - **`books.export_cpa_package` journals a decision** when `allow_open_drafts` is given (its doc said "read-only"); with open drafts and no reason it is refused with code `open_drafts`. + - **A discarded draft's receipts stay filed** (retention, Pub. 583); a recurring draft's occurrence stays generated. Not unlinked. + - **Harness**: every over-the-wire test harness confirms the way an agent does (`common::tools_call`, `Wire::call`, the async `call`s: the call without `confirmed`, then with the `preview`); `Wire::call_raw` sends exactly what it is given. + - **8.0.6**: `books_written_under_older_rules_still_open` hand-writes both events; fixture `old-books/p4_before` (writer `p4_before.rs`, run against 25b52580) with its `expected.json`; `books_p2_served_cut_over_whole` refuses `books.set_draft_state` and `books.discard_draft` before consent. No figure moved: `JournalEntry` serializes a building state as nothing, so no golden changed. The statement for the PR: merge does not need the owner's go-ahead; the live books refuse the two draft writes until he gives it. + - **Lean**: `Step.discard` with three theorems in `Audit.lean`; no kernel change, no new Kani harness. + - **Not done in this unit**: the viewer has no screenshot of the awaiting-evidence item (the item is tested through the parity test, `a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists`). **Struck from P4 (D-47):** ~~reversal as a draft (`entry.reversal_drafted`, `Step.draftReversal`, `evidence: link_original | {attachment_ids} | later`, "reversal draft needs evidence", `reversal_needs_evidence`, `inherited_evidence_posts`)~~; ~~`preview_token` as an HMAC with `keys/preview.key`~~ → D-50; ~~`ledger_core::evidence::requirement` used by "every path" including the at-once tools~~ → the at-once tools are unchanged (IZ-1h); ~~"defect 2"~~ → the 2026-10-16 audit's defect 2 (a reversal posted with no evidence of its own) is closed by D-47 and D-12, not by code.