feat(drafts): the drafts tools, the preview digest on every confirm, the digest tool (P4)

Accounting reason: a confirm must act on the thing that was previewed.
Every tool with confirmed (post_entry, reverse_entry, run_schedules,
dispose_asset, reconcile, close_period, create_invoice, create_bill,
write_off_invoice, purge_tin, consolidate_duplicates, and the S6
settlement tools receive_payment and pay_bill, which gain confirmed and
preview on a clone of the ledger) returns preview (SHA-256 of the
canonical preview) and refuses a confirm whose preview is missing or
stale (stale_preview, before anything is written). A draft's digest is
its own state and the settings seq; an operation's is the tool, its
arguments as sent, the settings seq and the journal head seq, so another
session's write forces a re-preview (D-50).

New tools books.list_drafts, books.set_draft_state, books.discard_draft,
books.preview_entry; reports carry as_of_status (drafts open on or before
their end, unreconciled accounts, settings problems) and take strict; the
CPA package is refused while a draft dated in it is open unless
allow_open_drafts gives a reason, journaled as a decision;
books.digest takes since_seq (what was posted since a journal line,
backdated entries included: defect 6) and lists zero_net_accounts,
drafts_by_state and the reconciliation split. Needs attention gains
'draft awaiting receipt' beside 'draft not posted'; the drafts handbook
is rewritten. Every over-the-wire harness confirms the way an agent does.

Docs: mcp-interface, rules B-43, parity, testing (with the For-P12
paragraph), architecture, and the unit's As-built block.

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, discard_unblocks_the_close_over_the_wire,
report_warns_on_draft_before_as_of, cpa_package_refuses_open_draft,
stale_preview_refused, every_confirm_tool_takes_a_preview,
digest_since_seq_is_posting_time, legacy_reversed_replays_identically,
gui_and_server_gate_agree), attention_parity.rs, format_upgrade.rs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:55:38 -06:00
1 parent 7299cf90f2
commit 6a66a2855b
49 files changed
+2526 -202

No files matched your search

@@ -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<String> = 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:?}"
);
}
+16 -1
View File
@@ -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<Value, String> {
/// 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<Value, String> {
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<Value, String> {
let args = args.as_object().cloned().unwrap_or_default();
let r = tokio::time::timeout(
Duration::from_secs(60),
+64 -3
View File
@@ -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<Value, String> {
@@ -985,7 +1031,22 @@ pub async fn connect_at(run: &Path, book: &str, token: Option<String>) -> Result
}
/// Call a tool: Ok(structured result) or Err(the error text).
pub async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
/// 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<Value, String> {
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<Value, String> {
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
+16 -13
View File
@@ -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<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"];
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",
+779
View File
@@ -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<u64> = 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<u64> = (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<u64, Vec<String>> = 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<u64> = (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<u64> = (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);
}
+33 -1
View File
@@ -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<Value, String> {
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).
+1 -1
View File
@@ -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}"
+4 -3
View File
@@ -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()
+3 -3
View File
@@ -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"));
+3 -1
View File
@@ -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<Value, String> {
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());
}
+14 -3
View File
@@ -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!(
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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 {
+7 -1
View File
@@ -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);
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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"),
+34 -8
View File
@@ -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();
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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();
+1 -1
View File
@@ -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()
}
+1 -1
View File
@@ -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 {