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

+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();