diff --git a/crates/ledger-cli/tests/agent_evals.rs b/crates/ledger-cli/tests/agent_evals.rs new file mode 100644 index 0000000..53facb9 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals.rs @@ -0,0 +1,185 @@ +//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a +//! JSON file in `agent_evals/`, played against a real books server through +//! the wire as an agent reaches it. The rule they enforce is the point of +//! the result envelope: **an agent that begins with `books.start` can do +//! its work by calling only what the previous result's `next_steps` named.** +//! +//! A transcript is `{ "setup": [..], "settings": "", "steps": [..] }`. +//! `setup` calls are made directly (the chart and the books an agent finds +//! when it arrives; they are not the agent's moves). A step is one of: +//! +//! - `{"call": "books.start", "args": {..}}`: only the first step, and +//! only `books.start`. +//! - `{"follow": "books.x", "with": {..}}`: the previous result's +//! `next_steps` must name tool `books.x`; the call is made with that +//! step's `args` and `with` laid over them (what the agent decides: an +//! amount, a date). +//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps` +//! must name that prompt; it is fetched with `prompts/get`. +//! - `{"expect_next_steps_contains": "books.x"}` and +//! `{"expect": {"/json/pointer": value}}` on the previous result. +//! +//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the +//! upload_id of that file, uploaded first as an agent does, and +//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x": +//! "/pointer"}`. +//! +//! Expected behavior comes from `docs/mcp-interface.md` ("The result +//! envelope") and plan 4.6. + +mod common; + +use common::{Books, Wire, answer_of}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; + +/// A value with `$upload` and `$var` laid in. +fn resolve(w: &mut Wire, vars: &HashMap, v: &Value) -> Value { + match v { + Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => { + let u = &o["$upload"]; + let text = u["text"].as_str().expect("an upload has text"); + json!(w.upload( + text.as_bytes(), + u["name"].as_str().unwrap_or("file.pdf"), + u["mime"].as_str().unwrap_or("application/pdf"), + u["purpose"].as_str().unwrap_or("receipt"), + )) + } + Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars + .get(o["$var"].as_str().unwrap()) + .unwrap_or_else(|| panic!("no saved value {}", o["$var"])) + .clone(), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, x)| (k.clone(), resolve(w, vars, x))) + .collect(), + ), + Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()), + other => other.clone(), + } +} + +/// Play one transcript; returns the tools it called after `books.start`. +fn play(name: &str, script: &Value) -> Vec { + let books = Books::with( + &format!("eval-{name}"), + "USD", + script["settings"].as_str(), + ); + let mut w = books.session("agent-evals"); + let mut vars: HashMap = HashMap::new(); + for s in script["setup"].as_array().into_iter().flatten() { + let args = resolve(&mut w, &vars, &s["args"]); + let tool = s["call"].as_str().unwrap(); + let out = answer_of(&w.call(tool, args)) + .unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}")); + for (var, pointer) in s["save"].as_object().into_iter().flatten() { + vars.insert( + var.clone(), + out.pointer(pointer.as_str().unwrap()) + .unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}")) + .clone(), + ); + } + } + let mut last: Option = None; + let mut called = Vec::new(); + for (i, step) in script["steps"].as_array().expect("steps").iter().enumerate() { + let here = format!("{name} step {i}"); + if let Some(tool) = step["call"].as_str() { + assert_eq!(i, 0, "{here}: only the first step may call without following"); + assert_eq!(tool, "books.start", "{here}: the first call is books.start"); + let args = resolve(&mut w, &vars, &step["args"]); + last = Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}"))); + } else if let Some(want) = step["follow"].as_str() { + let prev = last.as_ref().unwrap_or_else(|| panic!("{here}: follow before start")); + let offered = prev["next_steps"].as_array().cloned().unwrap_or_default(); + let named = offered + .iter() + .find(|s| s["tool"] == json!(want)) + .unwrap_or_else(|| { + panic!( + "{here}: {want} is not in the previous result's next_steps: {:?}", + offered.iter().map(|s| s["tool"].clone()).collect::>() + ) + }); + let mut args: Map = named["args"].as_object().cloned().unwrap_or_default(); + for (k, v) in step["with"].as_object().into_iter().flatten() { + args.insert(k.clone(), resolve(&mut w, &vars, v)); + } + let out = answer_of(&w.call_raw(want, Value::Object(args))) + .unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}")); + called.push(want.to_string()); + last = Some(out); + } else if let Some(want) = step["follow_prompt"].as_str() { + let prev = last.as_ref().unwrap_or_else(|| panic!("{here}: follow before start")); + assert!( + prev["next_steps"] + .as_array() + .into_iter() + .flatten() + .any(|s| s["prompt"] == json!(want)), + "{here}: prompt {want} is not in next_steps" + ); + let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}})); + let text = got["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}")); + assert!(text.contains("books."), "{here}: {text}"); + } else if let Some(tool) = step["expect_next_steps_contains"].as_str() { + let prev = last.as_ref().unwrap(); + assert!( + prev["next_steps"] + .as_array() + .into_iter() + .flatten() + .any(|s| s["tool"] == json!(tool)), + "{here}: next_steps lack {tool}: {}", + prev["next_steps"] + ); + } else if let Some(expect) = step["expect"].as_object() { + let prev = last.as_ref().unwrap(); + for (pointer, want) in expect { + assert_eq!(prev.pointer(pointer), Some(want), "{here}: {pointer} in {prev}"); + } + } else { + panic!("{here}: not a step: {step}"); + } + } + called +} + +/// Every transcript follows only what it was told, and ends where it says. +#[test] +fn agent_evals_follow_only_next_steps() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(); + files.sort(); + let names: Vec = files + .iter() + .map(|p| p.file_stem().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + names, + [ + "draft_awaiting_a_receipt", + "month_end_close", + "onboarding_to_first_post", + "receipt_backed_post", + "reconciliation", + "reversal" + ], + "the six evals the plan names" + ); + for (name, path) in names.iter().zip(&files) { + let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()) + .unwrap_or_else(|e| panic!("{name}: {e}")); + let called = play(name, &script); + assert!(!called.is_empty(), "{name} calls nothing"); + } +} diff --git a/crates/ledger-cli/tests/agent_evals/draft_awaiting_a_receipt.json b/crates/ledger-cli/tests/agent_evals/draft_awaiting_a_receipt.json new file mode 100644 index 0000000..d8b5f92 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/draft_awaiting_a_receipt.json @@ -0,0 +1,102 @@ +{ + "description": "A draft that waits for its receipt, and posts when it arrives.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-10", + "description": "Landlord, January", + "no_party_reason": "test" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "6000", + "side": "debit", + "amount": "40.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "credit", + "amount": "40.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.set_draft_state", + "with": { + "state": "awaiting_evidence", + "note": "the landlord's receipt" + } + }, + { + "expect": { + "/state": "awaiting_evidence" + } + }, + { + "follow": "books.attach_receipt", + "with": { + "upload_id": { + "$upload": { + "name": "receipt.pdf", + "mime": "application/pdf", + "text": "%PDF-1.4 a receipt for rent", + "purpose": "receipt" + } + } + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/month_end_close.json b/crates/ledger-cli/tests/agent_evals/month_end_close.json new file mode 100644 index 0000000..d096cab --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/month_end_close.json @@ -0,0 +1,83 @@ +{ + "description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + }, + { + "call": "books.create_period", + "args": { + "name": "2025-12", + "start": "2025-12-01", + "end": "2025-12-31" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": { + "focus": "close" + } + }, + { + "expect_next_steps_contains": "books.list_periods" + }, + { + "follow": "books.list_periods", + "with": {} + }, + { + "follow": "books.close_period", + "with": {} + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.close_period", + "with": {} + }, + { + "expect": { + "/status": "closed" + } + }, + { + "expect_next_steps_contains": "books.verify" + }, + { + "follow": "books.verify", + "with": {} + }, + { + "expect": { + "/status": "ok" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/onboarding_to_first_post.json b/crates/ledger-cli/tests/agent_evals/onboarding_to_first_post.json new file mode 100644 index 0000000..e823f26 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/onboarding_to_first_post.json @@ -0,0 +1,102 @@ +{ + "description": "A new book: onboarding to the first posted entry, following only next_steps.", + "steps": [ + { + "call": "books.start", + "args": { + "agent": { + "model": "scripted", + "reasoning_effort": "none", + "harness": "agent_evals" + } + } + }, + { + "expect": { + "/state": "needs_onboarding" + } + }, + { + "expect_next_steps_contains": "books.set_setting" + }, + { + "follow_prompt": "onboard" + }, + { + "follow": "books.set_setting", + "with": { + "changes": [ + { + "key": "company.name", + "value": "Eval Co" + } + ], + "decided_by": "Avery Quill" + } + }, + { + "expect_next_steps_contains": "books.add_account" + }, + { + "follow": "books.add_account", + "with": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "follow": "books.add_account", + "with": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-05", + "description": "Opening capital", + "no_party_reason": "opening balance" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "debit", + "amount": "10.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "3000", + "side": "credit", + "amount": "10.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + }, + { + "expect_next_steps_contains": "books.show" + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/receipt_backed_post.json b/crates/ledger-cli/tests/agent_evals/receipt_backed_post.json new file mode 100644 index 0000000..f783ef4 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/receipt_backed_post.json @@ -0,0 +1,96 @@ +{ + "description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "expect_next_steps_contains": "books.draft_entry" + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-10", + "description": "January rent", + "no_party_reason": "test" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "6000", + "side": "debit", + "amount": "40.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "credit", + "amount": "40.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect_next_steps_contains": "books.attach_receipt" + }, + { + "follow": "books.attach_receipt", + "with": { + "upload_id": { + "$upload": { + "name": "receipt.pdf", + "mime": "application/pdf", + "text": "%PDF-1.4 a receipt for rent", + "purpose": "receipt" + } + } + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/reconciliation.json b/crates/ledger-cli/tests/agent_evals/reconciliation.json new file mode 100644 index 0000000..96acb5e --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/reconciliation.json @@ -0,0 +1,148 @@ +{ + "description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + }, + { + "call": "books.draft_entry", + "args": { + "date": "2026-01-05", + "description": "Opening capital", + "no_party_reason": "opening balance" + }, + "save": { + "entry": "/entry_id" + } + }, + { + "call": "books.add_line", + "args": { + "entry_id": { + "$var": "entry" + }, + "account": "1000", + "side": "debit", + "amount": "10.00" + } + }, + { + "call": "books.add_line", + "args": { + "entry_id": { + "$var": "entry" + }, + "account": "3000", + "side": "credit", + "amount": "10.00" + } + }, + { + "call": "books.post_entry", + "args": { + "entry_id": { + "$var": "entry" + }, + "confirmed": true + } + }, + { + "call": "books.file_document", + "args": { + "category": "statement", + "file_name": "jan.pdf", + "mime_type": "application/pdf", + "upload_id": { + "$upload": { + "name": "jan.pdf", + "mime": "application/pdf", + "text": "%PDF-1.4 January statement", + "purpose": "document" + } + } + }, + "save": { + "statement": "/attachment_id" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "expect_next_steps_contains": "books.record_statement" + }, + { + "follow": "books.record_statement", + "with": { + "attachment_id": { + "$var": "statement" + }, + "account": "1000", + "period_start": "2026-01-01", + "period_end": "2026-01-31", + "opening_balance": "0.00", + "closing_balance": "10.00" + } + }, + { + "follow": "books.mark_cleared", + "with": { + "entry_id": { + "$var": "entry" + }, + "line_index": 0, + "cleared": true + } + }, + { + "follow": "books.reconcile", + "with": { + "account": "1000", + "statement_date": "2026-01-31", + "statement_balance": "10.00", + "statement_attachment": { + "$var": "statement" + } + } + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.reconcile", + "with": {} + }, + { + "expect": { + "/status": "reconciled" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/reversal.json b/crates/ledger-cli/tests/agent_evals/reversal.json new file mode 100644 index 0000000..b98fb63 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/reversal.json @@ -0,0 +1,94 @@ +{ + "description": "A posted entry reversed: previewed, confirmed, never edited.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-10", + "description": "Coffee beans", + "no_party_reason": "test" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "6000", + "side": "debit", + "amount": "10.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "credit", + "amount": "10.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + }, + { + "follow": "books.reverse_entry", + "with": { + "date": "2026-01-11", + "description": "Reverse: entered in error" + } + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.reverse_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_surface.rs b/crates/ledger-cli/tests/agent_surface.rs new file mode 100644 index 0000000..8802bf4 --- /dev/null +++ b/crates/ledger-cli/tests/agent_surface.rs @@ -0,0 +1,421 @@ +//! The agent surface over the wire (plan P5): `books.start` v2, the +//! journal in words, source corrections, the prompts and the resource +//! templates. Every call goes through a real books server as an agent +//! reaches it (`common.rs`). +//! +//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the +//! journal in words (P5)", "The result envelope", "Prompts", "Resources") +//! and plan 4.2, 4.4, 4.5. + +mod common; + +use common::{Books, answer_of}; +use serde_json::{Value, json}; +use std::path::Path; + +const SETTINGS: &str = "[company]\nname = \"Surface 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"), + ("6000", "Rent", "expense"), + ] { + answer_of(&w.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + )) + .unwrap(); + } +} + +/// A posted entry of `amount` rent paid from cash (under the receipt +/// threshold); its id. +fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 { + let id = answer_of(&w.call( + "books.draft_entry", + json!({"date": date, "description": what, "no_party_reason": "test"}), + )) + .unwrap()["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(); + } + answer_of(&w.call("books.post_entry", json!({"entry_id": id, "confirmed": true}))).unwrap(); + id +} + +/// `books.journal_history` reads the journal in order, in words; filters +/// by subject and type; pages with `more`; refuses a bad subject; and a +/// refusal changes nothing. +#[test] +fn journal_history_traces_what_the_books_did() { + let books = Books::with("p5-history", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + chart(&mut w); + let first = post(&mut w, "2026-01-05", "January rent", "10.00"); + let second = post(&mut w, "2026-01-06", "Printer paper", "11.00"); + + let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap(); + let lines = all["lines"].as_array().unwrap(); + let seqs: Vec = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect(); + assert!(seqs.windows(2).all(|p| p[0] < p[1]), "journal order: {seqs:?}"); + assert_eq!(all["head_seq"], *seqs.last().unwrap()); + assert_eq!(all["more"], false); + let posted = lines + .iter() + .find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first)) + .unwrap_or_else(|| panic!("no entry.posted for {first}: {all}")); + assert!( + posted["summary"].as_str().unwrap().contains("January rent"), + "{posted}" + ); + + // A subject keeps the lines about it, and only those. + let about = answer_of(&w.call( + "books.journal_history", + json!({"subject": format!("entry:{second}")}), + )) + .unwrap(); + let about = about["lines"].as_array().unwrap(); + assert!(about.len() >= 3, "created, lines, posted: {about:?}"); + assert!(about.iter().all(|l| l["entry_id"] == json!(second)), "{about:?}"); + + // A type group, and an exact type. + let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap(); + assert!(group["lines"] + .as_array() + .unwrap() + .iter() + .all(|l| l["type"].as_str().unwrap().starts_with("entry."))); + let exact = answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap(); + assert_eq!(exact["lines"].as_array().unwrap().len(), 2); + + // Paging: two at a time, from where the last page ended. + let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap(); + assert_eq!(page["lines"].as_array().unwrap().len(), 2); + assert_eq!(page["more"], true); + let after = page["lines"][1]["seq"].as_u64().unwrap(); + let next = answer_of(&w.call( + "books.journal_history", + json!({"after_seq": after, "limit": 2}), + )) + .unwrap(); + assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1); + + // A bad subject is refused with the shapes; the journal is unchanged. + let before = books.journal(); + let err = answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err(); + assert!(err.contains("entry:12"), "{err}"); + assert_eq!(books.journal(), before); + // It is a read: no head in the envelope. + assert!(all.get("head").is_none(), "{all}"); +} + +/// A source-only correction over the wire: the three tools take `reason` +/// (or `revises` with it), the words stay, the journal says so, and a +/// change of the words is not a correction. +#[test] +fn a_source_only_revision_over_the_wire() { + let books = Books::with("p5-source", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + let convention = |by: &str, body: &str| { + json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist", + "body_md": body, "decided_by": by, "decided_on": "2026-10-20"}) + }; + answer_of(&w.call("books.set_convention", convention("Owner", "Reconcile, then close."))).unwrap(); + let same = answer_of(&w.call("books.set_convention", convention("Pat Smith", "Reconcile, then close."))).unwrap_err(); + assert!( + same.contains("already says exactly that") && same.contains("reason"), + "{same}" + ); + let mut fixed = convention("Pat Smith", "Reconcile, then close."); + fixed["reason"] = json!("the CPA decided it"); + fixed["citation"] = json!("IRS Pub. 538"); + let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap(); + assert_eq!(out["convention"]["source"]["decided_by"], "Pat Smith", "{out}"); + assert_eq!(out["convention"]["revision"], 1, "the words did not change"); + let mut other = convention("Pat Smith", "Close first."); + other["reason"] = json!("x"); + let err = answer_of(&w.call("books.set_convention", other)).unwrap_err(); + assert!(err.contains("more than its source"), "{err}"); + + // A decision and an open item. + let decision = answer_of(&w.call( + "books.record_decision", + json!({"subject": "Materiality", "decision_md": "Five hundred dollars.", + "decided_by": ["Owner"], "decided_on": "2026-10-20"}), + )) + .unwrap()["id"] + .clone(); + let fixed_decision = json!({"revises": decision, "reason": "both decided it", + "decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"}); + answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap(); + let d = answer_of(&w.call("books.decisions", json!({}))).unwrap(); + assert_eq!(d["decisions"][0]["decided_by"], json!(["Owner", "Pat Smith"]), "{d}"); + assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars."); + let no_reason = answer_of(&w.call( + "books.record_decision", + json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}), + )) + .unwrap_err(); + assert!(no_reason.contains("reason"), "{no_reason}"); + let item = answer_of(&w.call( + "books.raise_item", + json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}), + )) + .unwrap()["id"] + .clone(); + answer_of(&w.call( + "books.raise_item", + json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith", + "decided_on": "2026-10-21"}), + )) + .unwrap(); + let items = answer_of(&w.call("books.open_items", json!({}))).unwrap(); + assert_eq!(items["items"][0]["title"], "Ask the CPA"); + assert_eq!(items["items"][0]["raised"]["decided_by"], "Pat Smith", "{items}"); + + // The journal says what happened, in order. + let h = answer_of(&w.call( + "books.journal_history", + json!({"types": ["convention.", "decision.", "open_item."]}), + )) + .unwrap(); + let types: Vec<&str> = h["lines"] + .as_array() + .unwrap() + .iter() + .map(|l| l["type"].as_str().unwrap()) + .collect(); + assert_eq!( + types, + [ + "convention.set", + "convention.source_revised", + "decision.recorded", + "decision.source_revised", + "open_item.raised", + "open_item.source_revised" + ] + ); + // A restart reads the same books. + books.restart(); + let mut w = books.session("p5"); + let c = answer_of(&w.call("books.conventions", json!({}))).unwrap(); + assert_eq!(c["conventions"][0]["source"]["decided_by"], "Pat Smith", "{c}"); +} + +/// Prompts: six, listed with their arguments, rendered with the company's +/// name and the arguments; an argument is one token of the right shape. +#[test] +fn the_prompts_list_and_render() { + let books = Books::with("p5-prompts", "USD", Some(SETTINGS)); + let mut w = books.wire(); + let init = w.initialize("p5"); + assert!(init["result"]["capabilities"]["prompts"].is_object(), "{init}"); + let listed = w.rpc("prompts/list", json!({})); + let names: Vec<&str> = listed["result"]["prompts"] + .as_array() + .unwrap() + .iter() + .map(|p| p["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + ["onboard", "reconcile", "month_end_close", "cpa_handoff", "annual_compliance", "audit"] + ); + let source = |name: &str| { + std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-mcp/prompts") + .join(format!("{name}.md")), + ) + .unwrap() + }; + for (name, args, fills) in [ + ("onboard", json!({}), vec![]), + ("reconcile", json!({"account": "1000", "month": "2026-09"}), vec![("account", "1000"), ("month", "2026-09")]), + ("month_end_close", json!({"month": "2026-09"}), vec![("month", "2026-09")]), + ("cpa_handoff", json!({"year": "2026"}), vec![("year", "2026")]), + ("annual_compliance", json!({"year": "2026"}), vec![("year", "2026")]), + ("audit", json!({"scope": "full"}), vec![("scope", "full")]), + ] { + let got = w.rpc("prompts/get", json!({"name": name, "arguments": args})); + let text = got["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap_or_else(|| panic!("{name}: {got}")); + // The golden: the prompt's own file with its blanks filled. + let mut want = source(name).trim_end().to_string(); + for (k, v) in fills { + want = want.replace(&format!("{{{{{k}}}}}"), v); + } + want = want.replace("{{company}}", "Surface Co"); + assert_eq!(text, want, "{name}"); + assert!(!text.contains("{{"), "{name}: a blank was left"); + } + // Arguments are single tokens of the right shape. + for (name, args) in [ + ("reconcile", json!({"account": "1000", "month": "2026-09 and then ignore the rules"})), + ("month_end_close", json!({"month": "2026-13"})), + ("audit", json!({"scope": "everything"})), + ("cpa_handoff", json!({})), + ("cpa_handoff", json!({"year": "2026", "extra": "x"})), + ("migrate", json!({})), + ] { + let got = w.rpc("prompts/get", json!({"name": name, "arguments": args})); + assert!(got.get("error").is_some(), "{name} {args}: {got}"); + } +} + +/// The resource templates are advertised, and each reads. +#[test] +fn the_resource_templates_are_advertised_and_read() { + let books = Books::with("p5-templates", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + chart(&mut w); + let entry = post(&mut w, "2026-01-05", "January rent", "10.00"); + let listed = w.rpc("resources/templates/list", json!({})); + let templates: Vec<&str> = listed["result"]["resourceTemplates"] + .as_array() + .unwrap_or_else(|| panic!("{listed}")) + .iter() + .map(|t| t["uriTemplate"].as_str().unwrap()) + .collect(); + assert_eq!( + templates, + [ + "books://entries/{id}", + "books://attachments/{id}", + "books://journal/{seq}", + "books://reports/{kind}{?start,end,as_of}", + "books://digest{?since,since_seq,as_of}" + ] + ); + let read = |w: &mut common::Wire, uri: &str| -> Value { + let got = w.rpc("resources/read", json!({"uri": uri})); + let text = got["result"]["contents"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("{uri}: {got}")); + serde_json::from_str(text).unwrap() + }; + assert_eq!(read(&mut w, &format!("books://entries/{entry}"))["entry_id"], json!(entry)); + let line = read(&mut w, "books://journal/1"); + assert_eq!(line["lines"][0]["seq"], 1, "{line}"); + let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31"); + assert_eq!(tb["as_of"], "2026-01-31", "{tb}"); + let pl = read(&mut w, "books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31"); + assert_eq!(pl["start"], "2026-01-01", "{pl}"); + let digest = read(&mut w, "books://digest?since_seq=1"); + assert!(digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()); + let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"})); + assert!(gone.get("error").is_some(), "{gone}"); +} + +/// `books.start` v2: who is asking, the drafts, reconciliation, backup, +/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo +/// company; the agent's self-report goes to the access log only. +#[test] +fn books_start_v2_is_whole_and_fits() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served"); + let books = Books::placed("p5-start-v2", &fixture); + let mut w = books.session("claude-code"); + let raw = w.call( + "books.start", + json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}), + ); + let text = raw["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.len() <= 8 * 1024, "books.start is {} bytes", text.len()); + let start = answer_of(&raw).unwrap(); + assert!(start["book"]["id"].is_string()); + assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/")); + assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp")); + books.home.assert_hidden(text); + assert_eq!(start["you"]["kind"], "agent", "{start}"); + assert_eq!(start["you"]["client_id"], "claude-code"); + assert!(start["you"]["scopes"].as_array().unwrap().len() >= 1); + assert!(start["drafts"]["building"].is_u64()); + assert!(start["drafts"]["awaiting_evidence"].is_array()); + for k in ["tracked", "never_reconciled", "behind"] { + assert!(start["reconciliation"][k].is_array(), "{k}"); + } + assert!(start["backup"]["stale"].is_boolean()); + assert_eq!(start["prompts"].as_array().unwrap().len(), 6); + assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12); + assert!(start["needs_attention"]["counts"].is_object()); + for s in start["next_steps"].as_array().unwrap() { + assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}"); + assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}"); + } + // The envelope: a read has steps and no head. + assert!(start.get("guards").is_some_and(Value::is_array)); + + // The self-report is in the access log, beside the call. + let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap(); + let agent = log["records"] + .as_array() + .unwrap() + .iter() + .find(|r| r["subject"] == "books.start" && r.get("agent").is_some()) + .unwrap_or_else(|| panic!("no agent in the log: {log}")); + assert_eq!(agent["agent"]["model"], "opus"); + assert_eq!(agent["agent"]["harness"], "claude-code"); + // And nowhere else: not in the answer. + assert!(!text.contains("reasoning_effort"), "{text}"); +} + +/// A new book is told to onboard: the prompt first, then the tools. +#[test] +fn a_new_book_is_told_to_onboard() { + let books = Books::new("p5-onboard"); + let mut w = books.session("p5"); + let start = answer_of(&w.call("books.start", json!({}))).unwrap(); + assert_eq!(start["state"], "needs_onboarding"); + let steps = start["next_steps"].as_array().unwrap(); + assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}"); + assert!(steps.iter().any(|s| s["tool"] == "books.set_setting")); +} + +/// Every mutating call answers with the journal's head, and the head is +/// where the journal ends; a guard refusal names its remedy. +#[test] +fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() { + let books = Books::with("p5-head", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + let added = answer_of(&w.call( + "books.add_account", + json!({"code": "7000", "name": "Misc", "account_type": "expense"}), + )) + .unwrap(); + let head = added["head"]["seq"].as_u64().unwrap(); + let start = answer_of(&w.call("books.start", json!({}))).unwrap(); + assert_eq!(start["head"]["seq"], json!(head), "the head the call answered is the journal's"); + assert!(!added["next_steps"].as_array().unwrap().is_empty()); + // A confirm with no preview is refused as stale_preview, naming the + // tool that previews. + chart(&mut w); + let draft = answer_of(&w.call( + "books.draft_entry", + json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}), + )) + .unwrap()["entry_id"] + .clone(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + answer_of(&w.call( + "books.add_line", + json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}), + )) + .unwrap(); + } + let refused = w.call_raw("books.post_entry", json!({"entry_id": draft, "confirmed": true})); + assert_eq!(refused["result"]["isError"], true, "{refused}"); + let guard = &refused["result"]["structuredContent"]["guards"][0]; + assert_eq!(guard["code"], "stale_preview", "{refused}"); + assert_eq!(guard["remedy_tool"], "books.post_entry"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 771f10e..ccf05c0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,7 +31,7 @@ the specification in `bookspec/` (which never depends on the engine). | `ledger-core` | the semantics engine: money, chart, journal, `Ledger` commands, reports, periods, assets, schedules, parties, A/R and A/P, payroll, projects, settings and conventions, the kernel and its Kani harnesses | nothing | | `ledger-vault` | the TIN vault: XChaCha20-Poly1305 records under the book's key | nothing | | `ledger-config` | the `wanderbooks.toml` codec (adoption in, `books.export_settings` out) and the receipt policy until P4 moves it into the core | `ledger-core` | -| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) | +| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `describe.rs` (the journal in words, for `books.journal_history`), `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) | | `ledger-reader` | the one reader: a PDF's text and pages, an XLSX's cells as exact decimal strings, plain text (P6; a plain library, in-process, D-61); `ledger-import` re-exports it as `extract` | none (`hayro`, `zip`, `quick-xml`) | | `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` | | `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` | @@ -353,3 +353,20 @@ package, which repacks the release's tarball), `build-linux.sh`, `repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a throwaway-keyed feed on this machine) and `test-linux-packages.sh`. `.github/workflows/release.yml` builds them on a version tag. + +## The result envelope and the guards (`ledger-mcp`, P5) + +Every tool result is given the same fields in one place: `call_tool` runs the +tool, then `next_steps::apply` adds `next_steps`, `guards`, `warnings` and +(on a tool that is not read-only) `head`, in the structured content and in +the text block alike, never overwriting a field the tool wrote. Next steps +come from the tool's own result (`next_steps::tool_steps`: the confirm after +a preview, what a post, a draft, a statement or a close leads to) and, after +a change to an entry or an invoice, from the viewer's own Needs-attention +items about it (`ledger_package::find_with`). `guards.rs` is the registry of +named refusals, each with the tool that remedies it; a refusal whose text +carries a guard gets it as `structuredContent.guards`. The prompts +(`tools/prompts.rs`, text in `prompts/*.md`) and the resource templates are +served by the same `ServerHandler`; `books.start` is loaded up front by +clients that defer tools (`_meta`). `ledger-store`'s `describe.rs` is the +one place that turns a journal line into a sentence for an agent. diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index d91f42b..4280e22 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -12,12 +12,16 @@ locked to one assistant. | Crate | Role | Spec | |---|---|---| +| `ledger-core` | the engine: money, entries, the laws; every rule the tools enforce | `docs/spec/rules.md`, `docs/verification.md` | +| `ledger-package` | the read-only views shared with the viewer: reports, aging, the CPA package, Needs attention (the one list `books.start` and the viewer both draw) | `docs/architecture.md` | | `ledger-store` | the hash-chained journal (the source of truth), the SQLite projection, the blob store | `docs/receipt-store.md` | | `ledger-mcp` | the books server: the tools, resources and handbook over store + core | this document | | `ledger-host` | the agent bridge (`wanderbooks mcp`), the host and its leases, tokens | `plan/served.md` 3.3, 3.9 | -(The overview sections of this document date from the first MCP phase; P5 -rewrites them. The per-tool sections are held to the code by a test.) +(P5 rewrote the overview sections for the surface as it is: the result +envelope, `books.start` v2, the prompts, the resource templates and the +journal in words, below. The per-tool sections are held to the code by a +test: `every_tool_is_documented_and_every_documented_tool_exists`.) Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting logic: it translates tool calls into `Ledger` commands and formats results. @@ -389,8 +393,8 @@ books' files; no tool title or description, connect text or handbook section names `wanderbooks.toml` (`tool_text_lint`). A rule set by law that the books don't know for a year names its official source (the statute or publication, with its address) and says to tell the human. -Tool descriptions are at most 700 characters; eight longer ones are listed -in `tool_descriptions_fit_700_characters`, to be shortened in P5. +Tool descriptions are at most 700 characters (`tool_descriptions_fit_700_characters` +allows none longer; the long text lives in the handbook). **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool that changes anything also has a `destructiveHint`, true for these 31: diff --git a/docs/parity.md b/docs/parity.md index 0930ee6..968da06 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -223,3 +223,18 @@ 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). + +### P5: the agent surface (2026-10-29) + +No semantic feature moved, so no status changed and the floor stands. P5 +changes how an agent is told what to do, not how a figure is computed: the +result envelope on every tool result (`next_steps`, `guards`, `warnings`, +`head`), `books.start` v2, the prompts, the resource templates, +`books.journal_history` (the journal in words) and source corrections +(`source_revisions`: `convention.source_revised`, `decision.source_revised`, +`open_item.source_revised`). One new row, `partial` until a hand-verified +golden covers it: + +| Area | Counterpart (a cross-check, not the spec) | Status | Notes | +|---|---|---|---| +| Audit trail in words (`books.journal_history`, the `describe` sentences) | QBO "Audit log", Xero "History and notes" | partial | tests `journal_history_traces_what_the_books_did`, `every_event_type_has_a_sentence`; the viewer keeps its own sentences (the shared describer is the server's) | diff --git a/docs/testing.md b/docs/testing.md index d75e105..22432b9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3745,3 +3745,89 @@ covers the feature one; a case with a higher format number is a gap to add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy` (`failed_update_rolls_back`), `install_kind` (the marker test), and `parse_version`'s rejections. + +## P5: the agent surface (2026-10-29) + +Every tool result carries the envelope (`next_steps`, `guards`, `warnings`, +`head`), `books.start` is v2, the prompts and resource templates are +served, `books.journal_history` reads the journal in words, and a +convention, decision or open item can have its source corrected. Journal +feature `source_revisions`; events `convention.source_revised`, +`decision.source_revised`, `open_item.source_revised`; no schema change +(the projection updates the rows' existing source columns). + +- **`ledger-mcp` `src/server_tests.rs`:** + `every_mutating_result_has_head_and_next_steps` (every tool of the + router is given the envelope; the non-read-only ones have `head` equal to + the journal's and a step each with `why` and `required`; the text block + says what the structure says), + `the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`, + `books_start_is_always_loaded`, `tool_descriptions_fit_700_characters` + (the allowance list is gone: every description is at most 700 + characters). `guards.rs`: `every_guard_names_an_existing_tool`, + `a_refusal_becomes_its_guard`. `next_steps.rs`: + `a_preview_gets_its_confirm_step`, `every_step_a_tool_names_exists`. + `tools/prompts.rs`: `the_prompts_name_only_tools_that_exist`, + `an_argument_is_one_token_of_the_right_shape`. +- **`ledger-store`:** `tests/source_revisions.rs`: + `a_source_only_revision_is_journaled` (the three events, words and + revision unchanged, live equals replay equals a rebuilt database), + `a_source_revision_must_change_only_the_source` (other words, the same + source, no reason, unknown record: refused, journal unchanged), + `source_revisions_wait_for_the_upgrade` (refused before the go-ahead with + `format_upgrade_pending`, journaling nothing). `format.rs`: + `source_revisions_is_its_own_feature`. `describe.rs`: + `every_event_type_has_a_sentence`, `settings_show_their_keys_never_their_values`, + `a_full_tin_in_a_description_is_masked`, + `history_filters_in_order_and_says_when_more_remain`. +- **8.0.6:** `books_written_under_older_rules_still_open` hand-writes the + three events; the fixture `old-books/p5_before` (writer + `old-books-writer/src/bin/p5_before.rs`, run against b97234ac, the + revision before P5: a convention revised once, two decisions, two open + items, a draft) opens to that version's figures, so no figure moved; + `books_p2_served_cut_over_whole` refuses `books.raise_item` with `revises` + before consent and takes it after. +- **`ledger-cli` `tests/agent_surface.rs`:** + `journal_history_traces_what_the_books_did`, + `a_source_only_revision_over_the_wire`, `the_prompts_list_and_render` + (each prompt rendered equals its file with the blanks filled; an + argument that is not one token of the right shape, a missing or extra + one, and a struck prompt are refused), + `the_resource_templates_are_advertised_and_read`, + `books_start_v2_is_whole_and_fits` (at most 8 KiB on the P2 fixture; the + agent's self-report in the access log only), + `a_new_book_is_told_to_onboard`, + `a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy`. +- **`ledger-cli` `tests/agent_evals.rs`** (transcripts in `tests/agent_evals/`): + `agent_evals_follow_only_next_steps` plays six scripted transcripts (no + model): onboarding to a first post, a receipt-backed post, a draft that + waits for its receipt and posts when it arrives, a reversal, a + reconciliation, a month-end close. After `books.start` a transcript may + call only a tool (or fetch only a prompt) the previous result's + `next_steps` named, with the arguments the step gave and the ones the + agent decides. `setup` calls (the chart, the books an agent finds) are + not the agent's moves. +- **The sweep:** the `party_id`, `document_id` and `cash_account` aliases of + `books.create_invoice`, `books.create_bill`, `books.receive_payment` and + `books.pay_bill` are gone (S4, S6 said "until P5"); the over-the-wire + tests that used them name `customer`/`vendor`, `invoice`/`bill` and + `deposit_to`/`paid_from`. + +**For P12 (mutation targets):** `next_steps::apply`'s three branches (an +error, a result without an object, the normal path) and the `mutating` +test that gives `head`; `tool_steps`'s confirm branch (`!confirmed` and a +`preview`), the `receipt_missing` pair before it, and each tool's arm; +`attention_steps`'s subject match (entry against invoice) and its +`Problem` severity for `required`; `subject_of`'s precedence; +`guards::from_refusal`'s structured and worded branches and +`Guard::remedy_tool`'s table; `prompts::checked`'s token test and each +`Check` shape (month length and calendar, year length, the word list); +`prompt_get`'s extra-argument and missing-argument refusals; +`describe::subjects`'s per-prefix id choice, `describe`'s `settings.set` +key list and TIN mask, `history`'s `>` on `after_seq`, its `limit` cut and +`more`; `check_convention_source`'s three equalities (title/body/structure, +source, kind), `check_decision_source` and `check_open_item_source`'s +equal-source refusals and the empty-reason refusals; the three +`apply_*_source` arms (a mutant that also bumps `revision` or `seq` shows +in `a_source_only_revision_is_journaled`); `books.start`'s fit order and +`reconciliation` split (`reconciled_through.is_some()`).