refactor!: files only by handle: uploads in, downloads out (D-46)
check / check (push) Canceled after 0s

The owner (2026-10-23, via the question tool): "Uploads only"; and
before it: "What we would DEFINITELY want to avoid is passing a bunch
of BLOBs through AI context windows. That's just a pure waste." No file
passes through an agent's context, in either direction.

In: the inline arguments are gone with the 256 KiB limit (INLINE_MAX,
take_file's inline branch): content_base64 and its sha256 on
books.attach_receipt and books.file_document, w9_content_base64 and
w9_sha256 on books.set_w9, csv_text on the two CSV imports. A file comes
only by books.begin_upload and `wanderbooks upload`, named by its
upload_id; existing_attachment_id still names bytes stored already. The
five filing tools deny unknown fields, and any inline argument to any
tool is refused before it runs as an unknown argument pointing to
books.begin_upload (access log: refused inline_file).

Out: books.export (every format), books.asset_labels and
books.get_receipt view bytes hand the file over (HandedFile: download
command and expiry, D-29), as the CPA package and the W-9 do. Resource
reads stay text. A token keeps up to 64 handed files (was 16). The
access log's digest no longer exempts file bytes.

Docs: handbook files-in-out and parties-and-tins, mcp-interface.md,
rules.md, receipt-store.md, parties-and-attachments.md, plan/served.md
(D-46; the inline decisions marked superseded), STATUS.md, testing.md
(D-46: tests migrated to uploads and downloads, rewritten, removed,
added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 09:18:14 -06:00
1 parent d7fa16e974
commit ebb5fcb46f
38 files changed
+1702 -1172

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ fn print_usage() {
eprintln!(" mcp an agent's way to the books, over stdio (what `connect` registers)"); eprintln!(" mcp an agent's way to the books, over stdio (what `connect` registers)");
eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)"); eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)");
eprintln!(" upload send a file for an upload an agent began (books.begin_upload)"); eprintln!(" upload send a file for an upload an agent began (books.begin_upload)");
eprintln!(" download save a copy of a stored file (one too large to come back inline)"); eprintln!(" download save a copy of a stored file or of one the books handed over");
eprintln!(" serve hold the books open until Ctrl-C"); eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go"); eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong"); eprintln!(" doctor whether the books are open, what holds them, what went wrong");
+67 -9
View File
@@ -162,7 +162,49 @@ impl Drop for Server {
} }
} }
async fn connect(home: &Home, book: &str, token: String) -> Client { /// A session with one book, and what it takes to send the book a file as
/// an agent does (D-46: a file comes only by upload).
struct Agent {
client: Client,
http: reqwest::Client,
book: String,
token: String,
}
impl std::ops::Deref for Agent {
type Target = Client;
fn deref(&self) -> &Client {
&self.client
}
}
impl Agent {
/// Upload `bytes` as `wanderbooks upload` does: books.begin_upload,
/// then the bytes PUT with this session's token. The upload_id.
async fn upload(&self, bytes: &[u8], file_name: &str, mime_type: &str) -> String {
let begun = call(
self,
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": mime_type, "file_name": file_name, "purpose": "receipt"}),
)
.await
.unwrap();
let id = begun["upload_id"].as_str().unwrap().to_string();
let r = self
.http
.put(format!("http://localhost/b/{}/u/{id}", self.book))
.bearer_auth(&self.token)
.body(bytes.to_vec())
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200);
id
}
}
async fn connect(home: &Home, book: &str, token: String) -> Agent {
// The short path is good in this process only, while it lives: kept. // The short path is good in this process only, while it lives: kept.
let short = ledger_store::root::SocketPath::new(&home.root().join("run"), "mcp.sock").unwrap(); let short = ledger_store::root::SocketPath::new(&home.root().join("run"), "mcp.sock").unwrap();
let path = short.path().to_path_buf(); let path = short.path().to_path_buf();
@@ -173,11 +215,20 @@ async fn connect(home: &Home, book: &str, token: String) -> Client {
.unwrap(); .unwrap();
let config = let config =
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp")) StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"))
.auth_header(token); .auth_header(token.clone());
ClientConfig::default() let client = ClientConfig::default()
.serve(StreamableHttpClientTransport::with_client(http, config)) .serve(StreamableHttpClientTransport::with_client(
http.clone(),
config,
))
.await .await
.unwrap() .unwrap();
Agent {
client,
http,
book: book.to_string(),
token,
}
} }
/// Call a tool: Ok(structured result) or Err(the error text). /// Call a tool: Ok(structured result) or Err(the error text).
@@ -228,7 +279,7 @@ async fn set_targets(c: &Client, remote: Option<&Path>, folder: Option<&Path>) {
} }
/// Draft, receipt and post one $n entry. /// Draft, receipt and post one $n entry.
async fn post_one(c: &Client, n: u64) { async fn post_one(c: &Agent, n: u64) {
for (code, name, ty) in [("1000", "Cash", "asset"), ("6000", "Supplies", "expense")] { for (code, name, ty) in [("1000", "Cash", "asset"), ("6000", "Supplies", "expense")] {
let _ = call( let _ = call(
c, c,
@@ -254,12 +305,19 @@ async fn post_one(c: &Client, n: u64) {
.await .await
.unwrap(); .unwrap();
} }
// Bytes of each entry's own: the same bytes stored already would be
// named for linking, not uploaded again.
let upload = c
.upload(
format!("receipt {n}").as_bytes(),
&format!("r{n}.txt"),
"text/plain",
)
.await;
call( call(
c, c,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"), json!({"entry_id": id, "upload_id": upload}),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
) )
.await .await
.unwrap(); .unwrap();
+127 -2
View File
@@ -5,8 +5,8 @@
//! GOAL: a test drives books exactly as an agent reaches them: a //! GOAL: a test drives books exactly as an agent reaches them: a
//! `wanderbooks server` on a data root of the test's own, spoken to //! `wanderbooks server` on a data root of the test's own, spoken to
//! over the root's MCP socket with a bearer token, with the headers //! over the root's MCP socket with a bearer token, with the headers
//! the agents' bridge sends; a file the books make comes back by //! the agents' bridge sends; a file goes in by its upload and comes
//! its download, never inline. //! back by its download, never through the session (D-46).
//! SOURCE: plan/served.md D-44 (one way to serve books), 3.1 (the //! SOURCE: plan/served.md D-44 (one way to serve books), 3.1 (the
//! server's sockets, the admin socket), 3.3 (tokens), 4.7 //! server's sockets, the admin socket), 3.3 (tokens), 4.7
//! (uploads and downloads); ledger-mcp local.rs INVARIANTS 8 and //! (uploads and downloads); ledger-mcp local.rs INVARIANTS 8 and
@@ -23,6 +23,10 @@
//! reads from the text, made with books.set_setting. //! reads from the text, made with books.set_setting.
//! 4. The harness answers nothing itself: every message a test is //! 4. The harness answers nothing itself: every message a test is
//! given is one the server sent. //! given is one the server sent.
//! 5. A file a test gives the books is uploaded as an agent uploads it:
//! books.begin_upload, then its bytes PUT to the upload route with
//! the session's token, as `wanderbooks upload` sends them; the
//! upload_id is what the tool is given (D-46).
//! ``` //! ```
#![allow(dead_code)] #![allow(dead_code)]
@@ -511,6 +515,68 @@ impl Wire {
bytes bytes
} }
/// Upload `bytes` as an agent does (INVARIANT 5): books.begin_upload
/// with their SHA-256, size, type, name and purpose, then, if it began
/// one, the bytes PUT to the upload route. books.begin_upload's answer:
/// its upload_id, or the existing_attachment_id of the same bytes
/// stored already (nothing sent then).
pub fn begin_and_send(
&mut self,
bytes: &[u8],
file_name: &str,
mime_type: &str,
purpose: &str,
) -> Value {
let r = self.call(
"books.begin_upload",
upload_args(bytes, file_name, mime_type, purpose),
);
let begun = answer_of(&r).unwrap_or_else(|e| panic!("books.begin_upload: {e}"));
if let Some(id) = begun["upload_id"].as_str() {
let (status, said) = self.put(&format!("u/{id}"), bytes);
assert_eq!(status, 200, "sending {id}: {said}");
}
begun
}
/// [`Wire::begin_and_send`] for bytes not stored yet: the upload_id to
/// name in the tool that files them.
pub fn upload(
&mut self,
bytes: &[u8],
file_name: &str,
mime_type: &str,
purpose: &str,
) -> String {
let begun = self.begin_and_send(bytes, file_name, mime_type, purpose);
begun["upload_id"]
.as_str()
.unwrap_or_else(|| panic!("no upload begun: {begun}"))
.to_string()
}
/// PUT `bytes` to `path` (under `/b/{book}/`) with this session's
/// token, as `wanderbooks upload` does: the status and the text.
pub fn put(&mut self, path: &str, bytes: &[u8]) -> (u16, String) {
let mut req = self
.http
.put(format!("http://localhost/b/{}/{path}", self.book))
.body(bytes.to_vec());
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
self.rt.block_on(async {
let r = req.send().await.expect("the server's socket answers");
let status = r.status().as_u16();
(status, r.text().await.unwrap_or_default())
})
}
/// [`Wire::fetch`], as text.
pub fn fetch_text(&mut self, file: &Value) -> String {
String::from_utf8(self.fetch(file)).expect("the file is text")
}
/// End the session (DELETE, as the bridge does when its agent goes). /// End the session (DELETE, as the bridge does when its agent goes).
pub fn end(&mut self) { pub fn end(&mut self) {
let Some(s) = self.session.take() else { return }; let Some(s) = self.session.take() else { return };
@@ -525,6 +591,29 @@ impl Wire {
} }
} }
/// books.begin_upload's arguments for `bytes`.
pub fn upload_args(bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> Value {
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": mime_type, "file_name": file_name, "purpose": purpose})
}
/// 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> {
if let Some(e) = r.get("error") {
return Err(e["message"].as_str().unwrap_or_default().to_string());
}
let res = &r["result"];
let text = res["content"][0]["text"].as_str().unwrap_or_default();
if res["isError"] == json!(true) {
return Err(text.to_string());
}
Ok(res
.get("structuredContent")
.cloned()
.unwrap_or_else(|| serde_json::from_str(text).unwrap_or(Value::String(text.into()))))
}
/// A test's own books: a home, its server, and the books [`BOOK`] made in /// A test's own books: a home, its server, and the books [`BOOK`] made in
/// it, with the settings the test gives. /// it, with the settings the test gives.
pub struct Books { pub struct Books {
@@ -921,6 +1010,42 @@ pub async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String>
} }
} }
/// Upload `bytes` for `book` on the rmcp session `c` (to the server whose
/// `run/` folder is `run`) as an agent does (INVARIANT 5), with `token`
/// (the session's), declared as `(file_name, mime_type, purpose)`: the
/// upload_id.
pub async fn upload_on(
c: &Client,
run: &Path,
book: &str,
token: &str,
bytes: &[u8],
(file_name, mime_type, purpose): (&str, &str, &str),
) -> String {
let begun = call(
c,
"books.begin_upload",
upload_args(bytes, file_name, mime_type, purpose),
)
.await
.unwrap_or_else(|e| panic!("books.begin_upload: {e}"));
let id = begun["upload_id"]
.as_str()
.unwrap_or_else(|| panic!("no upload begun: {begun}"))
.to_string();
let r = mcp_http_builder_at(run)
.build()
.unwrap()
.put(format!("http://localhost/b/{book}/u/{id}"))
.bearer_auth(token)
.body(bytes.to_vec())
.send()
.await
.unwrap();
assert_eq!(r.status().as_u16(), 200, "sending {id}");
id
}
/// Fetch the export a tool handed over in `file` (its `download` /// Fetch the export a tool handed over in `file` (its `download`
/// command's id) from `book` with `token`: its bytes, checked against its /// command's id) from `book` with `token`: its bytes, checked against its
/// size and SHA-256. /// size and SHA-256.
+33 -40
View File
@@ -193,6 +193,11 @@ impl Mcp {
} }
/// A tool's structured result; panics on any refusal (fixture setup). /// A tool's structured result; panics on any refusal (fixture setup).
/// Upload `bytes` as an agent does (D-46): the upload_id.
fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
self.wire.upload(bytes, file_name, mime_type, purpose)
}
fn ok(&mut self, tool: &str, args: Value) -> Value { fn ok(&mut self, tool: &str, args: Value) -> Value {
let r = self.rpc("tools/call", json!({"name": tool, "arguments": args})); let r = self.rpc("tools/call", json!({"name": tool, "arguments": args}));
assert!( assert!(
@@ -258,12 +263,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
json!({"name": "Member A", "roles": ["member"]}), json!({"name": "Member A", "roles": ["member"]}),
); );
// An unlinked receipt (attachment 1). // An unlinked receipt (attachment 1).
m.ok( let up = m.upload(b"%PDF-1.4 a receipt", "r.pdf", "application/pdf", "receipt");
"books.attach_receipt", m.ok("books.attach_receipt", json!({"upload_id": up}));
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a receipt")}),
);
for (date, dr, cr, amount) in [ for (date, dr, cr, amount) in [
("2026-01-02", "1000", "3000", "5000.00"), ("2026-01-02", "1000", "3000", "5000.00"),
("2026-01-02", "1000", "3001", "5000.00"), ("2026-01-02", "1000", "3001", "5000.00"),
@@ -282,11 +283,10 @@ fn fixture(m: &mut Mcp, p: &Places) {
); );
} }
let pdf = format!("%PDF-1.4 receipt for {date} {dr} {cr}"); let pdf = format!("%PDF-1.4 receipt for {date} {dr} {cr}");
let up = m.upload(pdf.as_bytes(), "r.pdf", "application/pdf", "receipt");
m.ok( m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf", json!({"entry_id": id, "upload_id": up}),
"content_base64": b64(pdf.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(pdf.as_bytes())}),
); );
m.ok( m.ok(
"books.post_entry", "books.post_entry",
@@ -325,11 +325,15 @@ fn fixture(m: &mut Mcp, p: &Places) {
"books.set_tin", "books.set_tin",
json!({"party": "Member A", "tin": "241-40-4915", "tin_type": "ssn"}), json!({"party": "Member A", "tin": "241-40-4915", "tin_type": "ssn"}),
); );
let up = m.upload(
b"%PDF-1.4 a statement",
"s.pdf",
"application/pdf",
"document",
);
m.ok( m.ok(
"books.file_document", "books.file_document",
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf", json!({"category": "statement", "upload_id": up}),
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ=",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a statement")}),
); );
let _ = p; let _ = p;
} }
@@ -624,19 +628,14 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.prop_map(Value::String) .prop_map(Value::String)
.boxed(); .boxed();
} }
if n.contains("base64") { // A file's SHA-256 (books.begin_upload's): of one of the files
likely.extend(INLINE_FILES.map(String::from)); // below (so some calls go on past the check), or not.
}
// A file's SHA-256: of one of the files above (so some calls carry a
// matching pair and go on past the check), or not.
if n.contains("sha256") { if n.contains("sha256") {
use base64::Engine; likely.extend(
likely.extend(INLINE_FILES.iter().filter_map(|f| { HOSTILE_FILES
base64::engine::general_purpose::STANDARD .iter()
.decode(f) .map(|b| ledger_store::journal::sha256_hex(b)),
.ok() );
.map(|b| ledger_store::journal::sha256_hex(&b))
}));
likely.push("0".repeat(64)); likely.push("0".repeat(64));
likely.push("not hex".into()); likely.push("not hex".into());
} }
@@ -705,14 +704,14 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
.boxed() .boxed()
} }
/// Files given inline (base64), hostile and not. /// Files an upload may declare, hostile and not (no tool takes a file's
const INLINE_FILES: [&str; 6] = [ /// bytes: D-46).
"JVBERi0xLjQ=", const HOSTILE_FILES: [&[u8]; 5] = [
"", b"%PDF-1.4",
"!!!not base64", b"",
"AAAA", b"\0\0\0",
"NTcxLTkzLTI4NDY=", b"571-93-2846",
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==", b"Date,Amount\n2026-01-01,1\n",
]; ];
/// An object's value: each property present or not (required ones mostly /// An object's value: each property present or not (required ones mostly
@@ -1169,12 +1168,11 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
"books.set_entry_vendor", "books.set_entry_vendor",
json!({"entry_id": id, "vendor": "Acme"}), json!({"entry_id": id, "vendor": "Acme"}),
); );
let up = m.upload(b"%PDF-1.4 huge", "h.pdf", "application/pdf", "receipt");
pinned( pinned(
&mut m, &mut m,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf", json!({"entry_id": id, "upload_id": up}),
"content_base64": "JVBERi0xLjQgaHVnZQ==",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 huge")}),
); );
pinned( pinned(
&mut m, &mut m,
@@ -1292,11 +1290,6 @@ fn hostile_resource_uris_never_stop_the_server() {
reopens(m.into_inner(), &p, "the resources"); reopens(m.into_inner(), &p, "the resources");
} }
fn b64(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The arguments, cut for a failure message. /// The arguments, cut for a failure message.
fn truncated(v: &Value) -> String { fn truncated(v: &Value) -> String {
let s = v.to_string(); let s = v.to_string();
+30 -25
View File
@@ -15,25 +15,15 @@ mod common;
const CLIENTS: usize = 6; const CLIENTS: usize = 6;
const ENTRIES: usize = 12; const ENTRIES: usize = 12;
fn b64(bytes: &[u8]) -> String { /// Draft a `cents` entry tagged `tag` on `c` (a session with `token`, of
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; /// the server whose `run/` folder is `run`), with a receipt uploaded for
let mut out = String::new(); /// it as an agent uploads one, if `with_receipt`.
for c in bytes.chunks(3) { async fn draft(
let n = (u32::from(c[0]) << 16) (c, run, token): (&Client, &std::path::Path, &str),
| (u32::from(*c.get(1).unwrap_or(&0)) << 8) tag: &str,
| u32::from(*c.get(2).unwrap_or(&0)); cents: u64,
for (i, shift) in [18, 12, 6, 0].iter().enumerate() { with_receipt: bool,
if i <= c.len() { ) -> u64 {
out.push(T[((n >> shift) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
let e = call( let e = call(
c, c,
"books.draft_entry", "books.draft_entry",
@@ -53,12 +43,19 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
.unwrap(); .unwrap();
} }
if with_receipt { if with_receipt {
let upload = common::upload_on(
c,
run,
common::BOOK,
token,
tag.as_bytes(),
(&format!("{tag}.pdf"), "application/pdf", "receipt"),
)
.await;
call( call(
c, c,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("{tag}.pdf"), json!({"entry_id": id, "upload_id": upload}),
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
) )
.await .await
.unwrap(); .unwrap();
@@ -102,7 +99,15 @@ async fn agents_posting_at_once_leave_whole_books() {
// Drafts every client will race to post. // Drafts every client will race to post.
let mut contested = Vec::new(); let mut contested = Vec::new();
for k in 0..ENTRIES { for k in 0..ENTRIES {
contested.push(draft(&setup, &format!("contested {k}"), 100 + k as u64, true).await); contested.push(
draft(
(&setup, &run, &token),
&format!("contested {k}"),
100 + k as u64,
true,
)
.await,
);
} }
let mut tasks = Vec::new(); let mut tasks = Vec::new();
@@ -110,13 +115,13 @@ async fn agents_posting_at_once_leave_whole_books() {
let (run, token) = (run.clone(), token.clone()); let (run, token) = (run.clone(), token.clone());
let contested = contested.clone(); let contested = contested.clone();
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(async move {
let c = connect(run, token).await; let c = connect(run.clone(), token.clone()).await;
let (mut posted, mut refused, mut won) = (0usize, 0usize, 0usize); let (mut posted, mut refused, mut won) = (0usize, 0usize, 0usize);
for (k, &race) in contested.iter().enumerate() { for (k, &race) in contested.iter().enumerate() {
// A receipted entry posts; a bare one is refused. // A receipted entry posts; a bare one is refused.
let receipted = k % 3 != 0; let receipted = k % 3 != 0;
let id = draft( let id = draft(
&c, (&c, &run, &token),
&format!("c{n} e{k}"), &format!("c{n} e{k}"),
(n * 1000 + k + 1) as u64, (n * 1000 + k + 1) as u64,
receipted, receipted,
+131 -82
View File
@@ -50,8 +50,10 @@ async fn chart(c: &Client) {
} }
} }
/// Draft a receipted $1.00 + n entry; post it. Ok(entry id) or Err(why). /// Draft a receipted $1.00 + n entry on `c`, a session with acme's books
async fn post_one(c: &Client, n: u64) -> Result<u64, String> { /// with `token` (of the server whose `run/` folder is `run`), its receipt
/// uploaded as an agent uploads one; post it. Ok(entry id) or Err(why).
async fn post_one(c: &Client, (run, token): (&Path, &str), n: u64) -> Result<u64, String> {
let e = call( let e = call(
c, c,
"books.draft_entry", "books.draft_entry",
@@ -68,12 +70,37 @@ async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
) )
.await?; .await?;
} }
// Bytes of each entry's own: the same bytes stored already would be
// named for linking, not uploaded again.
let bytes = format!("receipt {n}");
let begun = call(
c,
"books.begin_upload",
common::upload_args(
bytes.as_bytes(),
&format!("r{n}.txt"),
"text/plain",
"receipt",
),
)
.await?;
let upload = begun["upload_id"].as_str().unwrap_or_default().to_string();
let put = common::mcp_http_builder_at(run)
.build()
.unwrap()
.put(format!("http://localhost/b/acme/u/{upload}"))
.bearer_auth(token)
.body(bytes.into_bytes())
.send()
.await
.map_err(|e| e.to_string())?;
if put.status().as_u16() != 200 {
return Err(put.text().await.unwrap_or_default());
}
call( call(
c, c,
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"), json!({"entry_id": id, "upload_id": upload}),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
) )
.await?; .await?;
call( call(
@@ -115,7 +142,9 @@ async fn every_book_is_served_on_its_own_route() {
assert_ne!(a, b, "both routes answered from the same books"); assert_ne!(a, b, "both routes answered from the same books");
assert!(a.to_string().contains("Supplies"), "{a}"); assert!(a.to_string().contains("Supplies"), "{a}");
assert!(!b.to_string().contains("Supplies"), "{b}"); assert!(!b.to_string().contains("Supplies"), "{b}");
let id = post_one(&acme, 1).await.unwrap(); let id = post_one(&acme, (&home.run(), &home.token("acme")), 1)
.await
.unwrap();
let entry = call(&acme, "books.get_entry", json!({"entry_id": id})) let entry = call(&acme, "books.get_entry", json!({"entry_id": id}))
.await .await
.unwrap(); .unwrap();
@@ -227,11 +256,12 @@ async fn closing_during_a_burst_of_posts_loses_nothing() {
let mut tasks = Vec::new(); let mut tasks = Vec::new();
for n in 0..4u64 { for n in 0..4u64 {
let c = connect(&home, "acme").await.unwrap(); let c = connect(&home, "acme").await.unwrap();
let (run, token) = (home.run(), home.token("acme"));
tasks.push(tokio::spawn(async move { tasks.push(tokio::spawn(async move {
// Until the close turns it away: it can't finish first. // Until the close turns it away: it can't finish first.
let mut posted = Vec::new(); let mut posted = Vec::new();
for k in 0.. { for k in 0.. {
match post_one(&c, n * 100_000 + k).await { match post_one(&c, (&run, &token), n * 100_000 + k).await {
Ok(id) => posted.push(id), Ok(id) => posted.push(id),
Err(why) => return (posted, why), Err(why) => return (posted, why),
} }
@@ -1175,7 +1205,8 @@ async fn every_call_is_recorded_once_refusals_included() {
let admin_text = home.token("acme"); let admin_text = home.token("acme");
let full = connect(&home, "acme").await.unwrap(); let full = connect(&home, "acme").await.unwrap();
chart(&full).await; // 3 ok chart(&full).await; // 3 ok
let id = post_one(&full, 7).await; // draft, 2 lines, post: ok or an error // draft, 2 lines, begin_upload, attach, post: ok or an error
let id = post_one(&full, (&home.run(), &admin_text), 7).await;
let _ = id; let _ = id;
// A call that errs (bad arguments) and one refused by scope. // A call that errs (bad arguments) and one refused by scope.
assert!( assert!(
@@ -1211,8 +1242,8 @@ async fn every_call_is_recorded_once_refusals_included() {
assert_eq!(seqs, sorted, "newest first"); assert_eq!(seqs, sorted, "newest first");
let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect(); let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect();
// Every tool call made so far, one record each: 3 accounts, the // Every tool call made so far, one record each: 3 accounts, the
// posting (draft, two lines, post: up to four), the failed lookup, // posting (draft, two lines, its receipt's upload and filing, post),
// the refusal. // the failed lookup, the refusal.
let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count(); let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count();
assert_eq!(by("books.add_account"), 4); assert_eq!(by("books.add_account"), 4);
assert_eq!(by("books.get_entry"), 1); assert_eq!(by("books.get_entry"), 1);
@@ -1794,8 +1825,9 @@ async fn an_upload_is_filed_once_by_its_own_token_exactly_as_declared() {
assert_eq!(meta["file_name"], json!("big receipt.pdf")); assert_eq!(meta["file_name"], json!("big receipt.pdf"));
assert_eq!(meta["mime_type"], json!("application/pdf")); assert_eq!(meta["mime_type"], json!("application/pdf"));
assert_eq!(meta["size_bytes"], json!(bytes.len())); assert_eq!(meta["size_bytes"], json!(bytes.len()));
assert!(meta.get("content_base64").is_none_or(Value::is_null)); assert!(meta.get("file").is_none_or(Value::is_null), "{meta}");
// Too large to come back inline: the command that saves a copy. // Its bytes: handed over (D-46), the command that saves a copy, and
// the copy is the stored bytes.
let big = call( let big = call(
&c, &c,
"books.get_receipt", "books.get_receipt",
@@ -1803,12 +1835,17 @@ async fn an_upload_is_filed_once_by_its_own_token_exactly_as_declared() {
) )
.await .await
.unwrap(); .unwrap();
assert!(big.get("content_base64").is_none_or(Value::is_null)); assert!(big.get("content_base64").is_none(), "{big}");
let command = big["file"]["download"].as_str().unwrap();
assert!(
command.starts_with("wanderbooks download --book acme --token test ex_")
&& command.ends_with(" --to <a folder>"),
"{command}"
);
assert_eq!(big["file"]["file_name"], json!("big receipt.pdf"));
assert_eq!( assert_eq!(
big["download"], common::fetch_export(&home, "acme", &home.token("acme"), &big["file"]).await,
json!(format!( bytes
"wanderbooks download --book acme --token test {att} --to <a folder>"
))
); );
// The same bytes again: the attachment that holds them, no upload. // The same bytes again: the attachment that holds them, no upload.
let again = call( let again = call(
@@ -1943,11 +1980,19 @@ async fn stored_bytes_are_named_only_where_they_can_be_linked() {
home.create("acme", "USD"); home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap(); let c = connect(&home, "acme").await.unwrap();
let csv = b"code,name,type,parent_code\n1000,Cash,asset,\n".to_vec(); let csv = b"code,name,type,parent_code\n1000,Cash,asset,\n".to_vec();
let up = common::upload_on(
&c,
&home.run(),
"acme",
&home.token("acme"),
&csv,
("chart.csv", "text/csv", "document"),
)
.await;
let filed = call( let filed = call(
&c, &c,
"books.file_document", "books.file_document",
json!({"category": "import_source", "file_name": "chart.csv", "mime_type": "text/csv", json!({"category": "import_source", "upload_id": up}),
"content_base64": b64(&csv), "sha256": ledger_store::journal::sha256_hex(&csv)}),
) )
.await .await
.unwrap(); .unwrap();
@@ -1997,65 +2042,56 @@ async fn stored_bytes_are_named_only_where_they_can_be_linked() {
assert!(server.close().success()); assert!(server.close().success());
} }
/// D-46: a file comes only by its upload. The inline arguments that once
/// carried one are refused as unknown, whatever else comes with them,
/// pointing to books.begin_upload; a filing tool given no upload is
/// refused; the name and type a call gives are checked as the upload's
/// are; and the file comes back only handed over, its download the
/// stored bytes.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn bytes_given_inline_are_small_and_carry_their_hash() { async fn a_file_comes_only_by_its_upload_and_goes_back_by_its_download() {
let home = Home::new("inline"); let home = Home::new("handles");
let server = Server::start(&home); let server = Server::start(&home);
home.create("acme", "USD"); home.create("acme", "USD");
let c = connect(&home, "acme").await.unwrap(); let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme");
let small = pdf("small", 1000); let small = pdf("small", 1000);
let sha = ledger_store::journal::sha256_hex(&small); let sha = ledger_store::journal::sha256_hex(&small);
let up = common::upload_on(
&c,
&home.run(),
"acme",
&token,
&small,
("s.pdf", "application/pdf", "document"),
)
.await;
let file = |extra: Value| { let file = |extra: Value| {
let mut args = json!({"category": "other", "file_name": "s.pdf", let mut args = json!({"category": "other"});
"mime_type": "application/pdf"});
args.as_object_mut() args.as_object_mut()
.unwrap() .unwrap()
.extend(extra.as_object().unwrap().clone()); .extend(extra.as_object().unwrap().clone());
args args
}; };
for (extra, why) in [ for extra in [
(json!({"content_base64": b64(&small)}), "sha256"), json!({"content_base64": "JVBERi0xLjQ="}),
( json!({"content_base64": "JVBERi0xLjQ=", "sha256": sha}),
json!({"content_base64": b64(&small), "sha256": ledger_store::journal::sha256_hex(b"x")}), json!({"sha256": sha, "upload_id": up}),
"lost on the way", json!({"content_base64": "JVBERi0xLjQ=", "upload_id": up}),
),
(
json!({"content_base64": "not base64!", "sha256": sha}),
"not valid base64",
),
(
json!({"content_base64": b64(&small), "sha256": sha, "upload_id": "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}),
"not both",
),
(json!({}), "missing"),
] { ] {
let e = call(&c, "books.file_document", file(extra)) let e = call(&c, "books.file_document", file(extra.clone()))
.await .await
.unwrap_err(); .unwrap_err();
assert!(e.contains(why), "{why}: {e}"); assert!(
e.contains("unknown argument") && e.contains("books.begin_upload"),
"{extra}: {e}"
);
} }
// At most 256 KiB inline (the call's own limit is 1 MiB of base64). let e = call(&c, "books.file_document", file(json!({})))
let over = pdf("over", 256 * 1024 + 1); .await
let e = call( .unwrap_err();
&c, assert!(e.contains("upload_id"), "{e}");
"books.file_document", // A plain name and a real type, given or the upload's.
file(json!({"content_base64": b64(&over),
"sha256": ledger_store::journal::sha256_hex(&over)})),
)
.await
.unwrap_err();
assert!(e.contains("upload it with books.begin_upload"), "{e}");
// Text longer than the most bytes' base64 is refused unread.
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
let e = call(
&c,
"books.file_document",
file(json!({"content_base64": long, "sha256": sha})),
)
.await
.unwrap_err();
assert!(e.contains("inline is at most"), "{e}");
// A plain name and a real type.
for (name, mime, why) in [ for (name, mime, why) in [
("../escape.pdf", "application/pdf", "no folder"), ("../escape.pdf", "application/pdf", "no folder"),
("a\\b.pdf", "application/pdf", "no folder"), ("a\\b.pdf", "application/pdf", "no folder"),
@@ -2068,8 +2104,7 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
let e = call( let e = call(
&c, &c,
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": name, "mime_type": mime, json!({"category": "other", "file_name": name, "mime_type": mime, "upload_id": up}),
"content_base64": b64(&small), "sha256": sha}),
) )
.await .await
.unwrap_err(); .unwrap_err();
@@ -2078,20 +2113,15 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
let e = call( let e = call(
&c, &c,
"books.file_document", "books.file_document",
file(json!({"file_name": "x".repeat(256), "content_base64": b64(&small), "sha256": sha})), file(json!({"file_name": "x".repeat(256), "upload_id": up})),
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(e.contains("at most 255 bytes"), "{e}"); assert!(e.contains("at most 255 bytes"), "{e}");
let at = pdf("at", 256 * 1024); // Every refusal left the upload: filed now, under its own name.
let filed = call( let filed = call(&c, "books.file_document", file(json!({"upload_id": up})))
&c, .await
"books.file_document", .unwrap();
file(json!({"content_base64": b64(&at),
"sha256": ledger_store::journal::sha256_hex(&at).to_uppercase()})),
)
.await
.unwrap();
let att = filed["attachment_id"].as_u64().unwrap(); let att = filed["attachment_id"].as_u64().unwrap();
let back = call( let back = call(
&c, &c,
@@ -2100,8 +2130,13 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
) )
.await .await
.unwrap(); .unwrap();
assert_eq!(back["content_base64"], json!(b64(&at))); assert!(back.get("content_base64").is_none(), "{back}");
assert!(back.get("download").is_none_or(Value::is_null)); assert_eq!(back["file"]["file_name"], json!("s.pdf"));
assert_eq!(back["file"]["sha256"], json!(sha));
assert_eq!(
common::fetch_export(&home, "acme", &token, &back["file"]).await,
small
);
let e = call( let e = call(
&c, &c,
"books.get_receipt", "books.get_receipt",
@@ -2122,12 +2157,19 @@ async fn a_download_is_the_stored_bytes_for_a_reader_and_is_recorded() {
let c = connect(&home, "acme").await.unwrap(); let c = connect(&home, "acme").await.unwrap();
let token = home.token("acme"); let token = home.token("acme");
let bytes = pdf("down", 2000); let bytes = pdf("down", 2000);
let up = common::upload_on(
&c,
&home.run(),
"acme",
&token,
&bytes,
("Résumé \"final\".pdf", "application/pdf", "document"),
)
.await;
let filed = call( let filed = call(
&c, &c,
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": "Résumé \"final\".pdf", json!({"category": "other", "upload_id": up}),
"mime_type": "application/pdf", "content_base64": b64(&bytes),
"sha256": ledger_store::journal::sha256_hex(&bytes)}),
) )
.await .await
.unwrap(); .unwrap();
@@ -2491,14 +2533,21 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
assert_eq!(records.len(), 7, "{log}"); assert_eq!(records.len(), 7, "{log}");
assert!(records.iter().filter(|r| r["subject"] == json!(id)).count() >= 5); assert!(records.iter().filter(|r| r["subject"] == json!(id)).count() >= 5);
// Given back to be filed, it is refused: the server made it with TINs. // Given back to be filed, it is refused: the server made it with TINs.
let up = common::upload_on(
&c,
&home.run(),
"acme",
&token,
&zip,
("p.zip", "application/zip", "document"),
)
.await;
let e = call( let e = call(
&c, &c,
"books.file_document", "books.file_document",
json!({"category": "tax", "file_name": "p.zip", "mime_type": "application/zip", json!({"category": "tax", "upload_id": up}),
"content_base64": b64(&zip), "sha256": ledger_store::journal::sha256_hex(&zip)}),
) )
.await; .await;
assert!(zip.len() <= 256 * 1024, "small enough to give inline");
let e = e.unwrap_err(); let e = e.unwrap_err();
assert!(e.contains("TIN"), "{e}"); assert!(e.contains("TIN"), "{e}");
// Nothing loose in the book's folder: the export is in its own. // Nothing loose in the book's folder: the export is in its own.
+327 -175
View File
@@ -85,6 +85,21 @@ impl Mcp {
assert!(file["expires_at"].is_string(), "{file}"); assert!(file["expires_at"].is_string(), "{file}");
self.wire.fetch(file) self.wire.fetch(file)
} }
/// A file handed over, as text.
fn handed_text(&mut self, file: &Value) -> String {
String::from_utf8(self.handed(file)).expect("the file is text")
}
/// Upload `bytes` as an agent does (D-46): the upload_id.
fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
self.wire.upload(bytes, file_name, mime_type, purpose)
}
/// Upload a CSV for an import: the upload_id.
fn csv_upload(&mut self, csv: &str) -> String {
self.upload(csv.as_bytes(), "import.csv", "text/csv", "import")
}
} }
#[test] #[test]
@@ -98,7 +113,7 @@ fn mcp_end_to_end() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks"); assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -188,16 +203,10 @@ fn mcp_end_to_end() {
assert_eq!(err_json["remedy_tool"], "books.attach_receipt"); assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Attach the receipt, then the confirmed post succeeds. // Attach the receipt, then the confirmed post succeeds.
let png = base64_like(b"fake-png-bytes"); let png = mcp.upload(b"fake-png-bytes", "rent.png", "image/png", "receipt");
let attached = mcp.call( let attached = mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({ json!({"entry_id": entry_id, "upload_id": png}),
"entry_id": entry_id,
"file_name": "rent.png",
"mime_type": "image/png",
"content_base64": png,
"sha256": sha(b"fake-png-bytes"),
}),
); );
assert_eq!(attached["deduplicated"], false); assert_eq!(attached["deduplicated"], false);
assert_eq!(attached["sha256"].as_str().unwrap().len(), 64); assert_eq!(attached["sha256"].as_str().unwrap().len(), 64);
@@ -243,7 +252,7 @@ fn mcp_end_to_end() {
"books.export", "books.export",
json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}), json!({"format": "csv", "report": "journal", "start": "2026-01-01", "end": "2026-12-31"}),
); );
assert!(export["content"].as_str().unwrap().contains("January rent")); assert!(mcp.handed_text(&export["file"]).contains("January rent"));
// Reversal: preview then post; the book still balances. // Reversal: preview then post; the book still balances.
let rev_preview = mcp.call( let rev_preview = mcp.call(
@@ -270,7 +279,7 @@ fn mcp_assets_end_to_end() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks"); assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -300,7 +309,7 @@ fn mcp_assets_end_to_end() {
"useful_life_months": 60, "useful_life_months": 60,
"cost_account": "1500", "cost_account": "1500",
"accum_depr_account": "1501", "accum_depr_account": "1501",
"expense_account": "6100", "expense_account": "6100"
}), }),
); );
let asset_id = reg["asset_id"].as_u64().unwrap(); let asset_id = reg["asset_id"].as_u64().unwrap();
@@ -358,7 +367,7 @@ fn mcp_assets_end_to_end() {
"date": "2026-02-28", "date": "2026-02-28",
"proceeds": "60000", "proceeds": "60000",
"proceeds_account": "1000", "proceeds_account": "1000",
"gain_loss_account": "4900", "gain_loss_account": "4900"
}), }),
); );
assert_eq!(preview["status"], "preview"); assert_eq!(preview["status"], "preview");
@@ -376,7 +385,7 @@ fn mcp_assets_end_to_end() {
"proceeds": "60000", "proceeds": "60000",
"proceeds_account": "1000", "proceeds_account": "1000",
"gain_loss_account": "4900", "gain_loss_account": "4900",
"confirmed": true, "confirmed": true
}), }),
); );
let err_json: serde_json::Value = serde_json::from_str(&err).unwrap(); let err_json: serde_json::Value = serde_json::from_str(&err).unwrap();
@@ -384,14 +393,17 @@ fn mcp_assets_end_to_end() {
assert_eq!(err_json["remedy_tool"], "books.file_document"); assert_eq!(err_json["remedy_tool"], "books.file_document");
// File the bill of sale, then confirm with its attachment id. // File the bill of sale, then confirm with its attachment id.
let up1 = mcp.upload(
b"fake bill of sale",
"bill-of-sale.pdf",
"application/pdf",
"document",
);
let bill = mcp.call( let bill = mcp.call(
"books.file_document", "books.file_document",
json!({ json!({
"category": "legal", "category": "legal",
"file_name": "bill-of-sale.pdf", "upload_id": up1
"mime_type": "application/pdf",
"content_base64": base64_like(b"fake bill of sale"),
"sha256": sha(b"fake bill of sale"),
}), }),
); );
let aid = bill["attachment_id"].as_u64().unwrap(); let aid = bill["attachment_id"].as_u64().unwrap();
@@ -404,7 +416,7 @@ fn mcp_assets_end_to_end() {
"proceeds_account": "1000", "proceeds_account": "1000",
"gain_loss_account": "4900", "gain_loss_account": "4900",
"confirmed": true, "confirmed": true,
"receipt_attachment_ids": [aid], "receipt_attachment_ids": [aid]
}), }),
); );
assert_eq!(posted["status"], "posted"); assert_eq!(posted["status"], "posted");
@@ -440,7 +452,7 @@ fn mcp_receipt_policy_rejects_split_entries() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks"); assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -532,7 +544,7 @@ fn mcp_receipt_corrections() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -556,11 +568,10 @@ fn mcp_receipt_corrections() {
"books.add_line", "books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}), json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
); );
let up2 = mcp.upload(receipt, "receipt.eml", "message/rfc822", "receipt");
let att = mcp.call( let att = mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822", json!({"entry_id": id, "upload_id": up2}),
"content_base64": base64_like(receipt),
"sha256": sha(receipt)}),
); );
let posted = mcp.call( let posted = mcp.call(
"books.post_entry", "books.post_entry",
@@ -584,11 +595,10 @@ fn mcp_receipt_corrections() {
assert_eq!(err_json["remedy_tool"], "books.attach_receipt"); assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
// Replace: attach the PDF first, then unlink the .eml. // Replace: attach the PDF first, then unlink the .eml.
let up3 = mcp.upload(b"pdf-bytes", "receipt.pdf", "application/pdf", "receipt");
let pdf = mcp.call( let pdf = mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf", json!({"entry_id": big, "upload_id": up3}),
"content_base64": base64_like(b"pdf-bytes"),
"sha256": sha(b"pdf-bytes")}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -669,7 +679,7 @@ fn mcp_resources() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks"); assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
@@ -722,7 +732,7 @@ fn init(mcp: &mut Mcp) -> Value {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -754,11 +764,11 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
"books.add_party", "books.add_party",
json!({"name": "Acme", "roles": ["contractor"]}), json!({"name": "Acme", "roles": ["contractor"]}),
); );
let w9_upload = mcp.upload(w9, "w9.pdf", "application/pdf", "w9");
mcp.call( mcp.call(
"books.set_w9", "books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual", json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": "291-12-2846", "tin_type": "ssn", "tin": "291-12-2846", "tin_type": "ssn", "w9_upload_id": w9_upload}),
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
); );
// Each comes back by its download. // Each comes back by its download.
let got = mcp.call("books.get_w9", json!({"party": "Acme"})); let got = mcp.call("books.get_w9", json!({"party": "Acme"}));
@@ -779,19 +789,13 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
// filed. // filed.
let journal = books.journal(); let journal = books.journal();
for (n, bytes) in handed_over.iter().enumerate() { for (n, bytes) in handed_over.iter().enumerate() {
// One upload, refused by each tool and so left for the next.
let up = mcp.upload(bytes, "receipt.pdf", "application/pdf", "receipt");
for (tool, args) in [ for (tool, args) in [
( ("books.attach_receipt", json!({"upload_id": up})),
"books.attach_receipt",
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
"content_base64": base64_like(bytes),
"sha256": sha(bytes)}),
),
( (
"books.file_document", "books.file_document",
json!({"category": "statement", "file_name": "statement.csv", json!({"category": "statement", "upload_id": up}),
"mime_type": "text/csv",
"content_base64": base64_like(bytes),
"sha256": sha(bytes)}),
), ),
] { ] {
let why = mcp.call_err(tool, args); let why = mcp.call_err(tool, args);
@@ -808,10 +812,10 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
assert!(plain["warning"].is_null(), "{plain}"); assert!(plain["warning"].is_null(), "{plain}");
assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv"); assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv");
let bytes = mcp.handed(&plain["csv"]); let bytes = mcp.handed(&plain["csv"]);
let up6 = mcp.upload(&bytes, "plain.csv", "text/csv", "document");
mcp.call( mcp.call(
"books.file_document", "books.file_document",
json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv", json!({"category": "statement", "upload_id": up6}),
"content_base64": base64_like(&bytes), "sha256": sha(&bytes)}),
); );
} }
@@ -840,7 +844,7 @@ fn every_receipt_account_left_out_of_the_connect_text_is_in_books_settings() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -902,7 +906,7 @@ fn digest_bare_reads_once(attempt: u32) -> bool {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -972,7 +976,7 @@ fn handbook_tool_and_resources() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
let instructions = init["result"]["instructions"].as_str().unwrap(); let instructions = init["result"]["instructions"].as_str().unwrap();
@@ -1057,30 +1061,6 @@ fn handbook_tool_and_resources() {
assert!(resp.get("error").is_some()); assert!(resp.get("error").is_some());
} }
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn base64_like(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let mut n: u32 = 0;
for (i, &b) in chunk.iter().enumerate() {
n |= (b as u32) << (16 - 8 * i);
}
let pad = 3 - chunk.len();
for i in 0..4 - pad {
out.push(ALPHABET[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
for _ in 0..pad {
out.push('=');
}
}
out
}
#[test] #[test]
fn mcp_import_export() { fn mcp_import_export() {
let books = Books::new("import_export"); let books = Books::new("import_export");
@@ -1090,30 +1070,37 @@ fn mcp_import_export() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
let chart = mcp.call( let up1 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n");
"books.import_chart_csv", let chart = mcp.call("books.import_chart_csv", json!({"upload_id": up1}));
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n"}),
);
assert_eq!(chart["count"], 3); assert_eq!(chart["count"], 3);
// The IIF is plain ASCII text with CR LF line endings, inline; what it // The IIF is plain ASCII text with CR LF line endings, handed over
// writes differently from the books is in warnings (rules.md B-34). // (D-46); what it writes differently from the books is in warnings
// (rules.md B-34).
let iif = mcp.call( let iif = mcp.call(
"books.export", "books.export",
json!({"format": "iif", "report": "journal"}), json!({"format": "iif", "report": "journal"}),
); );
let text = iif["content"].as_str().unwrap(); let text = &mcp.handed_text(&iif["file"]);
assert!(text.contains("!TRNS") && text.is_ascii() && text.ends_with("\r\n")); assert!(text.contains("!TRNS") && text.is_ascii() && text.ends_with("\r\n"));
assert!( assert!(
text.contains("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"), text.contains("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"),
"{text}" "{text}"
); );
assert!(iif.get("content_encoding").is_none(), "{iif}"); assert!(iif.get("content").is_none(), "{iif}");
assert_eq!(
iif["file"]["file_name"]
.as_str()
.unwrap()
.rsplit('.')
.next(),
Some("iif")
);
assert_eq!( assert_eq!(
iif["warnings"], iif["warnings"],
json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""]) json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""])
@@ -1123,14 +1110,15 @@ fn mcp_import_export() {
"books.export", "books.export",
json!({"format": "xlsx", "report": "trial_balance"}), json!({"format": "xlsx", "report": "trial_balance"}),
); );
assert_eq!(xlsx["content_encoding"], "base64"); assert_eq!(
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs")); xlsx["file"]["mime_type"],
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
assert!(mcp.handed(&xlsx["file"]).starts_with(b"PK"), "a zip");
} }
#[test] #[test]
fn mcp_statement_pdfs_and_cpa_package() { fn mcp_statement_pdfs_and_cpa_package() {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD;
let books = Books::new("statement-pdfs"); let books = Books::new("statement-pdfs");
let mut mcp = Mcp::spawn(&books); let mut mcp = Mcp::spawn(&books);
mcp.request( mcp.request(
@@ -1138,14 +1126,12 @@ fn mcp_statement_pdfs_and_cpa_package() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
mcp.call( let up2 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n");
"books.import_chart_csv", mcp.call("books.import_chart_csv", json!({"upload_id": up2}));
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n"}),
);
for (desc, debit, credit, amount) in [ for (desc, debit, credit, amount) in [
("Owner contribution", "1000", "3000", "1000.00"), ("Owner contribution", "1000", "3000", "1000.00"),
("Domain", "6000", "1000", "12.00"), ("Domain", "6000", "1000", "12.00"),
@@ -1163,11 +1149,10 @@ fn mcp_statement_pdfs_and_cpa_package() {
"books.add_line", "books.add_line",
json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}), json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}),
); );
let up = mcp.upload(desc.as_bytes(), "r.pdf", "application/pdf", "receipt");
mcp.call( mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf", json!({"entry_id": id, "upload_id": up}),
"content_base64": base64_like(desc.as_bytes()),
"sha256": sha(desc.as_bytes())}),
); );
mcp.call( mcp.call(
"books.post_entry", "books.post_entry",
@@ -1187,8 +1172,8 @@ fn mcp_statement_pdfs_and_cpa_package() {
.unwrap() .unwrap()
.extend(extra.as_object().unwrap().clone()); .extend(extra.as_object().unwrap().clone());
let out = mcp.call("books.export", args); let out = mcp.call("books.export", args);
assert_eq!(out["content_encoding"], "base64"); assert_eq!(out["file"]["mime_type"], "application/pdf");
let bytes = b64.decode(out["content"].as_str().unwrap()).unwrap(); let bytes = mcp.handed(&out["file"]);
assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF"); assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF");
} }
// The cash flow statement reconciles to named cash accounts: required. // The cash flow statement reconciles to named cash accounts: required.
@@ -1297,14 +1282,12 @@ fn mcp_period_statements() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
mcp.call( let up3 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n");
"books.import_chart_csv", mcp.call("books.import_chart_csv", json!({"upload_id": up3}));
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
);
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| { let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
let e = mcp.call( let e = mcp.call(
"books.draft_entry", "books.draft_entry",
@@ -1328,11 +1311,10 @@ fn mcp_period_statements() {
json!({"entry_id": id, "confirmed": true}), json!({"entry_id": id, "confirmed": true}),
); );
assert!(err.contains("5.00"), "{err}"); assert!(err.contains("5.00"), "{err}");
let up7 = mcp.upload(b"r", "r.pdf", "application/pdf", "receipt");
mcp.call( mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf", json!({"entry_id": id, "upload_id": up7}),
"content_base64": base64_like(b"r"),
"sha256": sha(b"r")}),
); );
mcp.call( mcp.call(
"books.post_entry", "books.post_entry",
@@ -1388,7 +1370,7 @@ fn mcp_period_statements() {
"books.export", "books.export",
json!({"format": "json", "report": "general_ledger", "start": "2026-02-01", "end": "2026-02-28"}), json!({"format": "json", "report": "general_ledger", "start": "2026-02-01", "end": "2026-02-28"}),
); );
let gl: serde_json::Value = serde_json::from_str(gl["content"].as_str().unwrap()).unwrap(); let gl: serde_json::Value = serde_json::from_str(&mcp.handed_text(&gl["file"])).unwrap();
let cash = &gl["accounts"][0]; let cash = &gl["accounts"][0];
assert_eq!(cash["beginning_balance"], "1004.00"); assert_eq!(cash["beginning_balance"], "1004.00");
assert_eq!(cash["postings"][0]["split"], "6000 Software"); assert_eq!(cash["postings"][0]["split"], "6000 Software");
@@ -1403,7 +1385,7 @@ fn mcp_period_statements() {
"books.export", "books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}), json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
); );
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); let me: serde_json::Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["members"][0]["name"], "Owner"); assert_eq!(me["members"][0]["name"], "Owner");
assert_eq!(me["total"]["contributions"], "1000.00"); assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "3.50"); assert_eq!(me["total"]["net_income"], "3.50");
@@ -1412,7 +1394,7 @@ fn mcp_period_statements() {
"books.export", "books.export",
json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}), json!({"format": "pdf", "report": "members_equity", "end": "2026-02-28"}),
); );
assert_eq!(pdf["content_encoding"], "base64"); assert!(mcp.handed(&pdf["file"]).starts_with(b"%PDF"));
// Closing January: a one-member LLC closes into the member's capital // Closing January: a one-member LLC closes into the member's capital
// account, not Retained Earnings. The preview is exactly what posts. // account, not Retained Earnings. The preview is exactly what posts.
@@ -1442,7 +1424,7 @@ fn mcp_period_statements() {
"books.export", "books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}), json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
); );
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); let me: serde_json::Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["total"]["contributions"], "1000.00"); assert_eq!(me["total"]["contributions"], "1000.00");
assert_eq!(me["total"]["net_income"], "4.00"); assert_eq!(me["total"]["net_income"], "4.00");
assert_eq!(me["total"]["ending"], "1004.00"); assert_eq!(me["total"]["ending"], "1004.00");
@@ -1488,17 +1470,15 @@ fn mcp_period_statements() {
"books.export", "books.export",
json!({"format": "json", "report": "tax_summary", "start": "2026-01-01", "end": "2026-02-28"}), json!({"format": "json", "report": "tax_summary", "start": "2026-01-01", "end": "2026-02-28"}),
); );
let ts: serde_json::Value = serde_json::from_str(ts["content"].as_str().unwrap()).unwrap(); let ts: serde_json::Value = serde_json::from_str(&mcp.handed_text(&ts["file"])).unwrap();
assert_eq!(ts["form"], "Form 1065"); assert_eq!(ts["form"], "Form 1065");
assert_eq!(ts["income"][0]["book"], "6.50"); assert_eq!(ts["income"][0]["book"], "6.50");
assert_eq!(ts["deductions"][0]["category"], "other_deductions"); assert_eq!(ts["deductions"][0]["category"], "other_deductions");
assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0); assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0);
assert_eq!(ts["tax_net"], "3.50"); assert_eq!(ts["tax_net"], "3.50");
// A chart import validates categories before creating anything. // A chart import validates categories before creating anything.
let err = mcp.call_err( let up4 = mcp.csv_upload("code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n");
"books.import_chart_csv", let err = mcp.call_err("books.import_chart_csv", json!({"upload_id": up4}));
json!({"csv_text": "code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n"}),
);
assert!(err.contains("4100"), "{err}"); assert!(err.contains("4100"), "{err}");
let chart = mcp.call("books.chart", json!({})); let chart = mcp.call("books.chart", json!({}));
assert!( assert!(
@@ -1555,7 +1535,8 @@ fn mcp_partnership_from_template() {
); );
let mut mcp = Mcp::spawn(&books); let mut mcp = Mcp::spawn(&books);
init(&mut mcp); init(&mut mcp);
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]})); let up5 = mcp.csv_upload(t["csv"].as_str().unwrap());
mcp.call("books.import_chart_csv", json!({"upload_id": up5}));
mcp.call( mcp.call(
"books.set_setting", "books.set_setting",
json!({"changes": settings, "decided_by": "Ann"}), json!({"changes": settings, "decided_by": "Ann"}),
@@ -1589,7 +1570,7 @@ fn mcp_partnership_from_template() {
"books.export", "books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}), json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-01-31"}),
); );
let me: serde_json::Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); let me: serde_json::Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["members"][1]["distributions"], "50.00"); assert_eq!(me["members"][1]["distributions"], "50.00");
assert_eq!(me["members"][1]["ending"], "1150.00"); assert_eq!(me["members"][1]["ending"], "1150.00");
@@ -1662,7 +1643,8 @@ fn close_period_shows_each_members_running_split() {
mcp mcp
}; };
let mut mcp = spawn(&books); let mut mcp = spawn(&books);
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]})); let up6 = mcp.csv_upload(t["csv"].as_str().unwrap());
mcp.call("books.import_chart_csv", json!({"upload_id": up6}));
mcp.call( mcp.call(
"books.set_setting", "books.set_setting",
json!({"changes": t["settings"], "decided_by": "Ann"}), json!({"changes": t["settings"], "decided_by": "Ann"}),
@@ -1759,7 +1741,7 @@ fn close_period_shows_each_members_running_split() {
"books.export", "books.export",
json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}), json!({"format": "json", "report": "members_equity", "start": "2026-01-01", "end": "2026-02-28"}),
); );
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); let me: Value = serde_json::from_str(&mcp.handed_text(&me["file"])).unwrap();
assert_eq!(me["members"][0]["ending"], "0.01"); assert_eq!(me["members"][0]["ending"], "0.01");
assert_eq!(me["members"][1]["ending"], "0.01"); assert_eq!(me["members"][1]["ending"], "0.01");
} }
@@ -1934,7 +1916,7 @@ fn mcp_schedules_and_recurring() {
"months": 3, "months": 3,
"balance_account": "1300", "balance_account": "1300",
"pl_account": "6300", "pl_account": "6300",
"convention": "monthly_even", "convention": "monthly_even"
}), }),
); );
assert_eq!(s["convention"], "monthly_even"); assert_eq!(s["convention"], "monthly_even");
@@ -1950,7 +1932,7 @@ fn mcp_schedules_and_recurring() {
json!({ json!({
"kind": "prepaid_expense", "description": "x", "total": "10", "kind": "prepaid_expense", "description": "x", "total": "10",
"start": "2026-11-01", "months": 2, "start": "2026-11-01", "months": 2,
"balance_account": "6300", "pl_account": "6300", "balance_account": "6300", "pl_account": "6300"
}), }),
); );
assert!(err.contains("Asset"), "{err}"); assert!(err.contains("Asset"), "{err}");
@@ -1989,7 +1971,7 @@ fn mcp_schedules_and_recurring() {
"lines": [ "lines": [
{"account": "6600", "side": "debit", "amount": "2000.00"}, {"account": "6600", "side": "debit", "amount": "2000.00"},
{"account": "1000", "side": "credit", "amount": "2000.00"}, {"account": "1000", "side": "credit", "amount": "2000.00"},
], ]
}), }),
); );
let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"})); let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"}));
@@ -2025,7 +2007,7 @@ fn mcp_payout_gross_up() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2082,14 +2064,17 @@ fn mcp_payout_gross_up() {
// The statement is the receipt; then it posts like any entry. // The statement is the receipt; then it posts like any entry.
let entry_id = out["entry_id"].as_u64().unwrap(); let entry_id = out["entry_id"].as_u64().unwrap();
let up8 = mcp.upload(
b"%PDF-1.4 statement",
"payout.pdf",
"application/pdf",
"receipt",
);
mcp.call( mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({ json!({
"entry_id": entry_id, "entry_id": entry_id,
"file_name": "payout.pdf", "upload_id": up8
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 statement"),
"sha256": sha(b"%PDF-1.4 statement"),
}), }),
); );
mcp.call( mcp.call(
@@ -2178,14 +2163,17 @@ fn mcp_vendor_1099() {
json!({"entry_id": id, "account": account, "side": side, "amount": "2500.00"}), json!({"entry_id": id, "account": account, "side": side, "amount": "2500.00"}),
); );
} }
let up9 = mcp.upload(
b"%PDF-1.4 invoice",
"invoice.pdf",
"application/pdf",
"receipt",
);
mcp.call( mcp.call(
"books.attach_receipt", "books.attach_receipt",
json!({ json!({
"entry_id": id, "entry_id": id,
"file_name": "invoice.pdf", "upload_id": up9
"mime_type": "application/pdf",
"content_base64": base64_like(b"%PDF-1.4 invoice"),
"sha256": sha(b"%PDF-1.4 invoice"),
}), }),
); );
mcp.call( mcp.call(
@@ -2283,7 +2271,7 @@ fn mcp_receipts_always_required_when_threshold_is_zero() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
let instructions = init["result"]["instructions"].as_str().unwrap(); let instructions = init["result"]["instructions"].as_str().unwrap();
@@ -2341,7 +2329,7 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2440,7 +2428,7 @@ fn cash_flow_refuses_contradictory_roles() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2487,7 +2475,7 @@ fn a_chart_file_with_a_bad_row_imports_nothing() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2506,14 +2494,14 @@ fn a_chart_file_with_a_bad_row_imports_nothing() {
// A child of another type than its parent. // A child of another type than its parent.
"code,name,type,parent_code\n1000,Cash,asset,\n4000,Sales,revenue,1000\n", "code,name,type,parent_code\n1000,Cash,asset,\n4000,Sales,revenue,1000\n",
] { ] {
let err = mcp.call_err("books.import_chart_csv", json!({"csv_text": bad})); let up7 = mcp.csv_upload(bad);
let err = mcp.call_err("books.import_chart_csv", json!({"upload_id": up7}));
assert!(!err.is_empty()); assert!(!err.is_empty());
assert_eq!(count(&mut mcp), before, "half-imported: {bad}"); assert_eq!(count(&mut mcp), before, "half-imported: {bad}");
} }
let ok = mcp.call( let up8 =
"books.import_chart_csv", mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n");
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n"}), let ok = mcp.call("books.import_chart_csv", json!({"upload_id": up8}));
);
assert_eq!(ok["count"], 2); assert_eq!(ok["count"], 2);
} }
@@ -2529,7 +2517,7 @@ fn bank_import_reads_dates_rules_and_files_whole() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2544,9 +2532,10 @@ fn bank_import_reads_dates_rules_and_files_whole() {
json!({"code": code, "name": name, "account_type": typ}), json!({"code": code, "name": name, "account_type": typ}),
); );
} }
let base = |csv: &str| { // The CSV's upload: one refused is left for the next call.
let base = |upload: &str| {
json!({ json!({
"csv_text": csv, "upload_id": upload,
"bank_account_code": "1000", "bank_account_code": "1000",
"default_offset_code": "6000", "default_offset_code": "6000",
"rules": {"coffee": "6100", "coffee beans": "6200"}, "rules": {"coffee": "6100", "coffee beans": "6200"},
@@ -2554,18 +2543,17 @@ fn bank_import_reads_dates_rules_and_files_whole() {
}; };
// 3/4/2026 reads both ways and nothing in the file settles it. // 3/4/2026 reads both ways and nothing in the file settles it.
let ambiguous = "Date,Description,Amount\n3/4/2026,Coffee shop,-4.50\n"; let ambiguous = "Date,Description,Amount\n3/4/2026,Coffee shop,-4.50\n";
let err = mcp.call_err("books.import_bank_csv", base(ambiguous)); let up = mcp.csv_upload(ambiguous);
let err = mcp.call_err("books.import_bank_csv", base(&up));
assert!(err.contains("either way"), "{err}"); assert!(err.contains("either way"), "{err}");
let mut args = base(ambiguous); let mut args = base(&up);
args["date_order"] = json!("day_first"); args["date_order"] = json!("day_first");
let out = mcp.call("books.import_bank_csv", args); let out = mcp.call("books.import_bank_csv", args);
assert_eq!(out["imported"][0]["date"], "2026-04-03"); assert_eq!(out["imported"][0]["date"], "2026-04-03");
// Both patterns match "Coffee beans": the longer wins, every time. // Both patterns match "Coffee beans": the longer wins, every time.
for _ in 0..5 { for _ in 0..5 {
let out = mcp.call( let up = mcp.csv_upload("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n");
"books.import_bank_csv", let out = mcp.call("books.import_bank_csv", base(&up));
base("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n"),
);
assert_eq!(out["imported"][0]["offset_account"], "6200"); assert_eq!(out["imported"][0]["offset_account"], "6200");
} }
// A transaction dated in a closed period refuses the file: nothing of // A transaction dated in a closed period refuses the file: nothing of
@@ -2578,12 +2566,10 @@ fn bank_import_reads_dates_rules_and_files_whole() {
"books.close_period", "books.close_period",
json!({"period_id": p["period_id"], "confirmed": true}), json!({"period_id": p["period_id"], "confirmed": true}),
); );
let err = mcp.call_err( let up = mcp.csv_upload(
"books.import_bank_csv", "Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
base(
"Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
),
); );
let err = mcp.call_err("books.import_bank_csv", base(&up));
assert!(err.contains("closed"), "{err}"); assert!(err.contains("closed"), "{err}");
let journal = String::from_utf8(books.journal()).unwrap(); let journal = String::from_utf8(books.journal()).unwrap();
assert!( assert!(
@@ -2608,7 +2594,7 @@ fn renumbering_an_account_keeps_its_history() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2690,7 +2676,7 @@ fn renumbering_an_account_keeps_its_history() {
json!({ json!({
"protocolVersion": "2025-06-18", "protocolVersion": "2025-06-18",
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}, "clientInfo": {"name": "smoke-test", "version": "0.1.0"}
}), }),
); );
mcp.notify("notifications/initialized"); mcp.notify("notifications/initialized");
@@ -2756,10 +2742,8 @@ fn one_default_period_everywhere_defect_16() {
&books, &books,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", "[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
); );
mcp.call( let up9 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n");
"books.import_chart_csv", mcp.call("books.import_chart_csv", json!({"upload_id": up9}));
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital A,equity,,\n3001,Capital B,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let first = post_pair(&mut mcp, "2025-03-15", "1000", "3000", "500.00"); let first = post_pair(&mut mcp, "2025-03-15", "1000", "3000", "500.00");
let mid = post_pair(&mut mcp, "2025-09-10", "1000", "4000", "120.00"); let mid = post_pair(&mut mcp, "2025-09-10", "1000", "4000", "120.00");
let late = post_pair(&mut mcp, "2026-02-01", "6000", "1000", "20.00"); let late = post_pair(&mut mcp, "2026-02-01", "6000", "1000", "20.00");
@@ -2786,7 +2770,7 @@ fn one_default_period_everywhere_defect_16() {
"books.export", "books.export",
json!({"report": report, "format": "json", "end": "2026-03-31"}), json!({"report": report, "format": "json", "end": "2026-03-31"}),
); );
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
assert_eq!(pair(&v), want, "{report}"); assert_eq!(pair(&v), want, "{report}");
} }
// The general journal: the period's entries, in json and csv alike. // The general journal: the period's entries, in json and csv alike.
@@ -2794,7 +2778,7 @@ fn one_default_period_everywhere_defect_16() {
"books.export", "books.export",
json!({"report": "general_journal", "format": "json", "end": "2026-03-31"}), json!({"report": "general_journal", "format": "json", "end": "2026-03-31"}),
); );
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
let ids: Vec<u64> = v let ids: Vec<u64> = v
.as_array() .as_array()
.unwrap() .unwrap()
@@ -2806,7 +2790,7 @@ fn one_default_period_everywhere_defect_16() {
"books.export", "books.export",
json!({"report": "journal", "format": "csv", "end": "2026-03-31"}), json!({"report": "journal", "format": "csv", "end": "2026-03-31"}),
); );
let rows = csv["content"].as_str().unwrap().lines().count(); let rows = mcp.handed_text(&csv["file"]).lines().count();
assert_eq!(rows, 1 + 4, "header and two entries of two lines"); assert_eq!(rows, 1 + 4, "header and two entries of two lines");
// An inverted range is refused by the journal too (review R1). // An inverted range is refused by the journal too (review R1).
let err = mcp.call_err( let err = mcp.call_err(
@@ -2836,7 +2820,7 @@ fn one_default_period_everywhere_defect_16() {
]; ];
for report in ["tax_summary", "members_equity", "general_ledger"] { for report in ["tax_summary", "members_equity", "general_ledger"] {
let out = mcp.call("books.export", json!({"report": report, "format": "json"})); let out = mcp.call("books.export", json!({"report": report, "format": "json"}));
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
got.push(pair(&v)); got.push(pair(&v));
} }
let after = today(); let after = today();
@@ -2886,10 +2870,8 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
&books, &books,
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", "[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
); );
mcp.call( let up10 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n");
"books.import_chart_csv", mcp.call("books.import_chart_csv", json!({"upload_id": up10}));
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Cash,asset,,\n3000,Capital,equity,,\n4000,Sales,revenue,,gross_receipts\n6000,Software,expense,,other_deductions\n"}),
);
let entries = [ let entries = [
("2025-03-15", "1000", "3000", "500.00"), ("2025-03-15", "1000", "3000", "500.00"),
("2025-09-10", "1000", "4000", "120.00"), ("2025-09-10", "1000", "4000", "120.00"),
@@ -2906,7 +2888,7 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
json!({"report": "general_journal", "format": "json"}), json!({"report": "general_journal", "format": "json"}),
); );
let after = today(); let after = today();
let v: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); let v: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
let journal: Vec<u64> = v let journal: Vec<u64> = v
.as_array() .as_array()
.unwrap() .unwrap()
@@ -2939,7 +2921,7 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
let iif = mcp.call("books.export", json!({"report": report, "format": "iif"})); let iif = mcp.call("books.export", json!({"report": report, "format": "iif"}));
assert_eq!(iif["report"], report.to_lowercase()); assert_eq!(iif["report"], report.to_lowercase());
assert_eq!( assert_eq!(
iif_docnums(iif["content"].as_str().unwrap()), iif_docnums(&mcp.handed_text(&iif["file"])),
every, every,
"{report}" "{report}"
); );
@@ -2986,10 +2968,8 @@ fn rename_account_end_to_end_defect_15() {
&books, &books,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n", "[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
); );
mcp.call( let up11 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n");
"books.import_chart_csv", mcp.call("books.import_chart_csv", json!({"upload_id": up11}));
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n3000,Capital,equity,,\n6000,Operating,expense,,other_deductions\n6150,Software,expense,6000,other_deductions\n6160,Hosting,expense,6000,other_deductions\n"}),
);
post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00"); post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00");
post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00"); post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00");
let verified = mcp.call("books.verify", json!({"blobs": false})); let verified = mcp.call("books.verify", json!({"blobs": false}));
@@ -3045,14 +3025,14 @@ fn rename_account_end_to_end_defect_15() {
"books.export", "books.export",
json!({"report": "profit_and_loss", "format": "json", "start": "2025-11-01", "end": "2025-11-30"}), json!({"report": "profit_and_loss", "format": "json", "start": "2025-11-01", "end": "2025-11-30"}),
); );
let pl: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); let pl: Value = serde_json::from_str(&mcp.handed_text(&out["file"])).unwrap();
assert_eq!(pl["expense_accounts"][0]["name"], "Software subscriptions"); assert_eq!(pl["expense_accounts"][0]["name"], "Software subscriptions");
assert_eq!(pl["expense_accounts"][0]["amount"], "29.00"); assert_eq!(pl["expense_accounts"][0]["amount"], "29.00");
let iif = mcp.call( let iif = mcp.call(
"books.export", "books.export",
json!({"report": "journal", "format": "iif"}), json!({"report": "journal", "format": "iif"}),
); );
let iif = iif["content"].as_str().unwrap(); let iif = &mcp.handed_text(&iif["file"]);
assert!(iif.contains("Operating:Software subscriptions"), "{iif}"); assert!(iif.contains("Operating:Software subscriptions"), "{iif}");
// A renamed parent: its children's full names follow, and the note // A renamed parent: its children's full names follow, and the note
// says so. // says so.
@@ -3112,10 +3092,8 @@ fn rename_says_what_the_new_name_moves_defect_15() {
&books, &books,
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n", "[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n",
); );
mcp.call( let up12 = mcp.csv_upload("code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n");
"books.import_chart_csv", mcp.call("books.import_chart_csv", json!({"upload_id": up12}));
json!({"csv_text": "code,name,type,parent_code,tax_category\n1000,Business Checking,asset,,\n1010,Petty Fund,asset,,\n2100,Visa,liability,,\n2200,Credit Card,liability,,\n6000,Software,expense,,other_deductions\n"}),
);
mcp.call( mcp.call(
"books.set_convention", "books.set_convention",
json!({"id": "software-note", "kind": "note", "title": "Subscriptions", json!({"id": "software-note", "kind": "note", "title": "Subscriptions",
@@ -3258,3 +3236,177 @@ fn mcp_daily_schedule() {
); );
assert!(err.contains("daily or monthly_even"), "{err}"); assert!(err.contains("daily or monthly_even"), "{err}");
} }
/// D-46: files pass only by handle. The five tools that file a file take
/// an upload and no other argument (their schemas close, and none names
/// an inline field); an inline argument is refused as unknown before the
/// tool runs, pointing to books.begin_upload, storing nothing; and every
/// file a tool once gave inline (books.export in each format,
/// books.asset_labels, books.get_receipt view bytes) comes back handed
/// over, its download the very bytes, with the size and SHA-256 it
/// states, and no bytes in the answer.
#[test]
fn files_pass_only_by_handle_in_and_out() {
let books = Books::with(
"handles",
"USD",
Some("[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n"),
);
let mut mcp = Mcp::spawn(&books);
init(&mut mcp);
// The schemas: closed, an upload, nothing inline.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
let tool = |name: &str| {
tools
.iter()
.find(|t| t["name"] == name)
.unwrap_or_else(|| panic!("{name} listed"))
.clone()
};
for (name, upload) in [
("books.attach_receipt", "upload_id"),
("books.file_document", "upload_id"),
("books.set_w9", "w9_upload_id"),
("books.import_bank_csv", "upload_id"),
("books.import_chart_csv", "upload_id"),
] {
let input = &tool(name)["inputSchema"];
assert_eq!(input["additionalProperties"], json!(false), "{name}");
let props = input["properties"].as_object().unwrap();
assert!(props.contains_key(upload), "{name}");
for gone in [
"content_base64",
"sha256",
"csv_text",
"w9_content_base64",
"w9_sha256",
] {
assert!(!props.contains_key(gone), "{name} takes {gone}");
}
}
for name in ["books.export", "books.asset_labels", "books.get_receipt"] {
let out = tool(name)["outputSchema"].to_string();
assert!(!out.contains("content_base64"), "{name}: {out}");
assert!(!out.contains("content_encoding"), "{name}: {out}");
assert!(out.contains("\"file\""), "{name}: {out}");
}
let chart = mcp.csv_upload(
"code,name,type,parent_code\n1000,Cash,asset,\n1500,Equipment,asset,\n\
1510,Accumulated Depreciation,asset,\n3000,Capital,equity,\n\
6500,Depreciation,expense,\n",
);
mcp.call("books.import_chart_csv", json!({"upload_id": chart}));
// In: an inline file is refused as unknown, storing nothing.
let journal = books.journal();
for (name, args) in [
(
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": "JVBERi0xLjQ=",
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4")}),
),
(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n1001,Petty,asset,\n"}),
),
] {
let resp = mcp.request_raw("tools/call", json!({"name": name, "arguments": args}));
let why = resp["error"]["message"].as_str().unwrap_or_default();
assert!(
why.contains("unknown argument") && why.contains("books.begin_upload"),
"{name}: {resp}"
);
}
assert_eq!(books.journal(), journal, "an inline file stored something");
// A posted entry with its receipt, and an asset with its tag.
let receipt = b"%PDF-1.4 a receipt for the laptop";
let e = mcp.call(
"books.draft_entry",
json!({"date": "2026-01-02", "description": "Laptop"}),
);
let id = e["entry_id"].as_u64().unwrap();
for (account, side) in [("1500", "debit"), ("3000", "credit")] {
mcp.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": "1200.00"}),
);
}
let up = mcp.upload(receipt, "laptop.pdf", "application/pdf", "receipt");
let att = mcp.call(
"books.attach_receipt",
json!({"entry_id": id, "upload_id": up}),
)["attachment_id"]
.clone();
mcp.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "no_party_reason": "a member's contribution"}),
);
mcp.call(
"books.register_asset",
json!({"description": "Laptop", "in_service_date": "2026-01-02", "cost": "1200.00",
"useful_life_months": 36, "cost_account": "1500",
"accum_depr_account": "1510", "expense_account": "6500"}),
);
// Out: each file handed over, its download the bytes it states.
let handed = |mcp: &mut Mcp, out: &Value, mime: &str| -> Vec<u8> {
for inline in ["content", "content_base64", "content_encoding"] {
assert!(out.get(inline).is_none(), "{inline} in {out}");
}
let file = &out["file"];
assert_eq!(file["mime_type"], mime, "{out}");
assert!(
file["download"]
.as_str()
.unwrap()
.starts_with("wanderbooks download --book acme"),
"{file}"
);
let bytes = mcp.handed(file);
assert_eq!(
file["sha256"],
json!(ledger_store::journal::sha256_hex(&bytes))
);
assert_eq!(file["size_bytes"], json!(bytes.len()));
bytes
};
for (format, report, mime, starts) in [
("csv", "general_journal", "text/csv", &b"entry_id,"[..]),
("json", "trial_balance", "application/json", b"{"),
(
"xlsx",
"balance_sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
b"PK",
),
("pdf", "profit_and_loss", "application/pdf", b"%PDF"),
("iif", "general_journal", "text/plain", b"!"),
] {
let out = mcp.call("books.export", json!({"format": format, "report": report}));
let bytes = handed(&mut mcp, &out, mime);
assert!(
bytes.starts_with(starts),
"{format} {report}: {:?}",
String::from_utf8_lossy(&bytes[..bytes.len().min(40)])
);
assert!(
out["file"]["file_name"]
.as_str()
.unwrap()
.ends_with(&format!(".{format}")),
"{out}"
);
}
let labels = mcp.call("books.asset_labels", json!({}));
assert_eq!(labels["tags"].as_array().unwrap().len(), 1, "{labels}");
assert!(handed(&mut mcp, &labels, "application/pdf").starts_with(b"%PDF"));
let back = mcp.call(
"books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}),
);
assert_eq!(handed(&mut mcp, &back, "application/pdf"), receipt);
assert_eq!(back["file"]["sha256"], back["sha256"]);
assert_eq!(back["file"]["file_name"], "laptop.pdf");
let meta = mcp.call("books.get_receipt", json!({"attachment_id": att}));
assert!(meta.get("file").is_none(), "{meta}");
}
+56 -8
View File
@@ -301,13 +301,19 @@ struct Agent {
next: u64, next: u64,
/// Notifications received. /// Notifications received.
notes: Vec<Value>, notes: Vec<Value>,
/// How it runs the commands it is given (`wanderbooks upload`), as
/// [`Home::cmd`] runs them, and the folder its files are in: the
/// program, the data root, the home.
commands: Option<(PathBuf, PathBuf, PathBuf)>,
} }
impl Agent { impl Agent {
fn start(home: &Home, book: &str) -> Self { fn start(home: &Home, book: &str) -> Self {
let mut c = home.cmd("mcp"); let mut c = home.cmd("mcp");
c.args(["--book", book]); c.args(["--book", book]);
Self::from(c) let mut a = Self::from(c);
a.commands = Some((home.exe.clone(), home.root(), home.dir.path().to_path_buf()));
a
} }
fn from(c: Command) -> Self { fn from(c: Command) -> Self {
@@ -315,9 +321,50 @@ impl Agent {
run: Running::start(c), run: Running::start(c),
next: 1, next: 1,
notes: Vec::new(), notes: Vec::new(),
commands: None,
} }
} }
/// Send `bytes` as `name` as an agent does (D-46): books.begin_upload,
/// the file written in the home, and the command it returns run on
/// it. The upload_id.
fn upload(&mut self, bytes: &[u8], name: &str) -> Result<String, String> {
let (exe, root, home) = self.commands.clone().expect("an agent Agent::start made");
let begun = self.call(
"books.begin_upload",
json!({"sha256": ledger_store::journal::sha256_hex(bytes), "size": bytes.len(),
"mime_type": "text/plain", "file_name": name, "purpose": "receipt"}),
)?;
let command = begun["command"]
.as_str()
.ok_or_else(|| format!("no upload begun: {begun}"))?;
let words: Vec<&str> = command
.strip_suffix(" <the file's path>")
.ok_or_else(|| command.to_string())?
.split(' ')
.collect();
let path = home.join(format!("upload-{}", begun["upload_id"].as_str().unwrap()));
std::fs::write(&path, bytes).unwrap();
let out = Command::new(exe)
.arg(words[1])
.arg("--root")
.arg(root)
.args(["--test-knobs", KNOBS])
.args(&words[2..])
.arg(&path)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", &home)
.stdin(Stdio::null())
.output()
.unwrap();
let _ = std::fs::remove_file(&path);
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).to_string());
}
Ok(begun["upload_id"].as_str().unwrap().to_string())
}
fn send(&mut self, message: Value) { fn send(&mut self, message: Value) {
let stdin = self.run.child.stdin.as_mut().unwrap(); let stdin = self.run.child.stdin.as_mut().unwrap();
writeln!(stdin, "{message}").unwrap(); writeln!(stdin, "{message}").unwrap();
@@ -405,11 +452,12 @@ fn post_one(a: &mut Agent, n: u64) -> Result<u64, String> {
json!({"entry_id": id, "account": account, "side": side, "amount": "1.00"}), json!({"entry_id": id, "account": account, "side": side, "amount": "1.00"}),
)?; )?;
} }
// Bytes of each entry's own: the same bytes stored already would be
// named for linking, not uploaded again.
let upload = a.upload(format!("receipt {n}").as_bytes(), &format!("r{n}.txt"))?;
a.call( a.call(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": id, "file_name": format!("r{n}.txt"), json!({"entry_id": id, "upload_id": upload}),
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
)?; )?;
a.call( a.call(
"books.post_entry", "books.post_entry",
@@ -2257,15 +2305,15 @@ fn an_agent_sends_and_saves_files_with_the_commands_it_is_given() {
) )
.unwrap(); .unwrap();
let att = filed["attachment_id"].as_u64().unwrap(); let att = filed["attachment_id"].as_u64().unwrap();
// Too big to come back inline: the command saves a copy, beside any // Handed over (D-46): the command saves a copy, beside any file of
// file of its name, never over it. // its name, never over it.
let back = a let back = a
.call( .call(
"books.get_receipt", "books.get_receipt",
json!({"attachment_id": att, "view": "bytes"}), json!({"attachment_id": att, "view": "bytes"}),
) )
.unwrap(); .unwrap();
let download = back["download"].as_str().unwrap().to_string(); let download = back["file"]["download"].as_str().unwrap().to_string();
for n in 1..=2 { for n in 1..=2 {
let out = run_given( let out = run_given(
&home, &home,
@@ -2348,7 +2396,7 @@ fn the_commands_given_run_as_the_agents_own_token() {
json!({"attachment_id": filed["attachment_id"], "view": "bytes"}), json!({"attachment_id": filed["attachment_id"], "view": "bytes"}),
) )
.unwrap(); .unwrap();
let download = back["download"].as_str().unwrap().to_string(); let download = back["file"]["download"].as_str().unwrap().to_string();
assert!(download.contains(" --token bookkeeper "), "{download}"); assert!(download.contains(" --token bookkeeper "), "{download}");
let out = run_given( let out = run_given(
&home, &home,
+137 -161
View File
@@ -12,7 +12,6 @@
//! folder of a data root of the test's own; then the store is closed and //! folder of a data root of the test's own; then the store is closed and
//! the server started on that root. //! the server started on that root.
use base64::Engine as _;
use common::{Place, Wire}; use common::{Place, Wire};
use ledger_core::{ use ledger_core::{
AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital, AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital,
@@ -148,25 +147,21 @@ impl Mcp {
} }
} }
/// `books.export`, returning the content (base64-decoded when the /// `books.export`'s file: handed over (D-46), fetched by its
/// server says it is base64). /// download, never its bytes in the answer.
fn export(&mut self, args: Value) -> Vec<u8> { fn export(&mut self, args: Value) -> Vec<u8> {
let out = self.ok("books.export", args); let out = self.ok("books.export", args.clone());
let content = out["content"].as_str().unwrap(); assert!(out.get("content").is_none(), "{args} came back inline");
if out["content_encoding"] == "base64" { self.handed(&out["file"])
b64d(content)
} else {
content.as_bytes().to_vec()
}
} }
fn export_text(&mut self, args: Value) -> String { fn export_text(&mut self, args: Value) -> String {
let out = self.ok("books.export", args.clone()); String::from_utf8(self.export(args)).expect("a text export")
assert!( }
out.get("content_encoding").is_none_or(|e| e.is_null()),
"text export {args} came back encoded" /// Upload `bytes` as an agent does (D-46): the upload_id.
); fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
out["content"].as_str().unwrap().to_string() self.wire.upload(bytes, file_name, mime_type, purpose)
} }
/// The viewer command the last `books.show` wrote, in the books' own /// The viewer command the last `books.show` wrote, in the books' own
@@ -185,16 +180,6 @@ impl Mcp {
// ---------------------------------------------------------------- helpers // ---------------------------------------------------------------- helpers
fn b64e(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn b64d(s: &str) -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode(s)
.expect("valid base64")
}
fn sha256(bytes: &[u8]) -> String { fn sha256(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes) ledger_store::journal::sha256_hex(bytes)
} }
@@ -1440,7 +1425,8 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
"books.export", "books.export",
json!({"format": "iif", "report": "journal", "as_of": END}), json!({"format": "iif", "report": "journal", "as_of": END}),
); );
assert!(out.get("content_encoding").is_none(), "plain text: {out}"); assert!(out.get("content").is_none(), "handed over: {out}");
assert_eq!(out["file"]["mime_type"], "text/plain");
let lunch = ledger let lunch = ledger
.posted_entries() .posted_entries()
.find(|e| e.description().starts_with("Lunch")) .find(|e| e.description().starts_with("Lunch"))
@@ -1466,7 +1452,7 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
), ),
]; ];
assert_eq!(out["warnings"], json!(warnings)); assert_eq!(out["warnings"], json!(warnings));
let iif = out["content"].as_str().unwrap(); let iif = &String::from_utf8(m.handed(&out["file"])).unwrap();
assert!(iif.ends_with("\r\n") && !iif.replace("\r\n", "").contains(['\r', '\n'])); assert!(iif.ends_with("\r\n") && !iif.replace("\r\n", "").contains(['\r', '\n']));
let mut types: HashMap<String, String> = HashMap::new(); let mut types: HashMap<String, String> = HashMap::new();
@@ -2081,10 +2067,10 @@ fn post_contribution(m: &mut Mcp) -> u64 {
json!({"entry_id": e, "account": a, "side": side, "amount": "10000.00"}), json!({"entry_id": e, "account": a, "side": side, "amount": "10000.00"}),
); );
} }
let up = m.upload(b"contribution", "c.txt", "text/plain", "receipt");
m.ok( m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain", json!({"entry_id": e, "upload_id": up}),
"content_base64": b64e(b"contribution"), "sha256": sha256(b"contribution")}),
); );
m.ok( m.ok(
"books.post_entry", "books.post_entry",
@@ -2739,7 +2725,7 @@ fn resources_read_back_as_the_matching_tools() {
"books.get_receipt", "books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}), json!({"attachment_id": id, "view": "bytes"}),
); );
assert_eq!(b64d(full["content_base64"].as_str().unwrap()), bytes); assert_eq!(m.handed(&full["file"]), bytes);
} }
let r = m.rpc("resources/read", json!({"uri": "books://nope"})); let r = m.rpc("resources/read", json!({"uri": "books://nope"}));
let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}")); let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}"));
@@ -2764,29 +2750,38 @@ fn receipts_and_documents_round_trip() {
let e = post_contribution(&mut m); let e = post_contribution(&mut m);
let first: &[u8] = b"%PDF-1.4 first"; let first: &[u8] = b"%PDF-1.4 first";
let second: &[u8] = b"\x89PNG second \x00\xff"; let second: &[u8] = b"\x89PNG second \x00\xff";
let up = m.upload(first, "first.pdf", "application/pdf", "receipt");
let a = m.ok( let a = m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf", json!({"entry_id": e, "upload_id": up,
"content_base64": b64e(first), "sha256": sha256(first),
"description": "Receipt ID 1007; Paid from: Quillmere"}), "description": "Receipt ID 1007; Paid from: Quillmere"}),
); );
let up = m.upload(second, "oa.png", "image/png", "document");
let b = m.ok( let b = m.ok(
"books.file_document", "books.file_document",
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png", json!({"category": "legal", "upload_id": up, "description": "Operating agreement"}),
"content_base64": b64e(second), "sha256": sha256(second), );
"description": "Operating agreement"}), let up = m.upload(
b"EIN letter bytes",
"ein-letter.pdf",
"application/pdf",
"document",
); );
let c = m.ok( let c = m.ok(
"books.file_document", "books.file_document",
json!({"category": "tax", "file_name": "ein-letter.pdf", "mime_type": "application/pdf", json!({"category": "tax", "upload_id": up, "description": "IRS CP 575"}),
"content_base64": b64e(b"EIN letter bytes"), "sha256": sha256(b"EIN letter bytes"), );
"description": "IRS CP 575"}), // The name and type a call gives win over the upload's.
let up = m.upload(
b"a scanned receipt",
"scan.bin",
"application/octet-stream",
"receipt",
); );
let r = m.ok( let r = m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "renamed.png", "mime_type": "image/png", json!({"entry_id": e, "upload_id": up, "file_name": "renamed.png",
"content_base64": b64e(b"a scanned receipt"), "mime_type": "image/png", "description": "scan"}),
"sha256": sha256(b"a scanned receipt"), "description": "scan"}),
); );
// (result, bytes, file name, MIME type, entry, description) // (result, bytes, file name, MIME type, entry, description)
@@ -2830,23 +2825,20 @@ fn receipts_and_documents_round_trip() {
let id = out["attachment_id"].as_u64().unwrap(); let id = out["attachment_id"].as_u64().unwrap();
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes"); assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes");
let meta = m.ok("books.get_receipt", json!({"attachment_id": id})); let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
assert!( assert!(meta.get("file").is_none(), "{name}: meta, no file");
meta.get("content_base64").is_none(),
"{name}: meta, no bytes"
);
let got = m.ok( let got = m.ok(
"books.get_receipt", "books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}), json!({"attachment_id": id, "view": "bytes"}),
); );
assert_eq!(got["attachment_id"], json!(id)); assert_eq!(got["attachment_id"], json!(id));
assert_eq!( // Handed over (D-46): its download is the bytes, under its name.
b64d(got["content_base64"].as_str().unwrap()), assert!(got.get("content_base64").is_none(), "{name}: {got}");
bytes, assert_eq!(m.handed(&got["file"]), bytes, "{name}");
"{name}" assert_eq!(got["file"]["file_name"], name);
); assert_eq!(got["file"]["mime_type"], mime);
let mut without = got.clone(); let mut without = got.clone();
without.as_object_mut().unwrap().remove("content_base64"); without.as_object_mut().unwrap().remove("file");
assert_eq!(without, meta, "{name}: view bytes is meta and the bytes"); assert_eq!(without, meta, "{name}: view bytes is meta and the file");
assert_eq!(got["sha256"], sha256(bytes)); assert_eq!(got["sha256"], sha256(bytes));
assert_eq!(got["file_name"], name); assert_eq!(got["file_name"], name);
assert_eq!(got["mime_type"], mime); assert_eq!(got["mime_type"], mime);
@@ -2882,31 +2874,27 @@ fn receipts_and_documents_round_trip() {
} }
} }
/// 256 KiB is the most a file may be given inline (plan 2.1): exactly /// No file is given inline any more (D-46): one past the old inline limit
/// the limit is accepted by books.attach_receipt and books.file_document /// (256 KiB) is filed by upload through books.attach_receipt and
/// and stored whole, one byte more is refused by both, pointing at /// books.file_document and stored whole, and comes back whole by its
/// books.begin_upload, and nothing is stored. Kills: the limit comparison /// download; the inline arguments are refused by both as unknown,
/// in take_file. /// pointing at books.begin_upload, and nothing is stored.
#[test] #[test]
fn inline_files_up_to_256_kib_are_accepted_and_no_more() { fn files_past_the_old_inline_limit_go_by_upload_and_come_back_by_download() {
const LIMIT: usize = 256 * 1024; const OLD_LIMIT: usize = 256 * 1024;
let dir = test_dir("limits"); let dir = test_dir("limits");
let s = new_store(&dir); let s = new_store(&dir);
drop(s); drop(s);
let mut m = Mcp::start(&dir, "1000000.00"); let mut m = Mcp::start(&dir, "1000000.00");
let at = vec![b'a'; LIMIT]; let at = vec![b'a'; OLD_LIMIT + 1];
let out = m.ok( let up = m.upload(&at, "big.bin", "application/octet-stream", "receipt");
"books.attach_receipt", let out = m.ok("books.attach_receipt", json!({"upload_id": up}));
json!({"file_name": "big.bin", "mime_type": "application/octet-stream",
"content_base64": b64e(&at), "sha256": sha256(&at)}),
);
let at_id = out["attachment_id"].as_u64().unwrap(); let at_id = out["attachment_id"].as_u64().unwrap();
let doc = vec![b'b'; LIMIT]; let doc = vec![b'b'; OLD_LIMIT + 1];
let up = m.upload(&doc, "big.bin", "application/octet-stream", "document");
let out = m.ok( let out = m.ok(
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": "big.bin", json!({"category": "other", "upload_id": up}),
"mime_type": "application/octet-stream", "content_base64": b64e(&doc),
"sha256": sha256(&doc)}),
); );
let doc_id = out["attachment_id"].as_u64().unwrap(); let doc_id = out["attachment_id"].as_u64().unwrap();
let listed = m.ok("books.list_receipts", json!({})); let listed = m.ok("books.list_receipts", json!({}));
@@ -2918,32 +2906,31 @@ fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
.find(|r| r["attachment_id"] == json!(id)) .find(|r| r["attachment_id"] == json!(id))
.unwrap() .unwrap()
.clone(); .clone();
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole"); assert_eq!(row["size_bytes"], json!(OLD_LIMIT + 1), "stored whole");
// And it comes back inline, whole. // And it comes back by its download, whole.
let got = m.ok( let got = m.ok(
"books.get_receipt", "books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}), json!({"attachment_id": id, "view": "bytes"}),
); );
assert_eq!(&b64d(got["content_base64"].as_str().unwrap()), bytes); assert_eq!(&m.handed(&got["file"]), bytes);
} }
let journal = std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(); let journal = std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
let over = vec![b'c'; LIMIT + 1];
for (tool, args) in [ for (tool, args) in [
( (
"books.attach_receipt", "books.attach_receipt",
json!({"file_name": "over.bin", "mime_type": "application/octet-stream", json!({"file_name": "s.bin", "mime_type": "application/octet-stream",
"content_base64": b64e(&over), "sha256": sha256(&over)}), "content_base64": "YQ==", "sha256": sha256(b"a")}),
), ),
( (
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": "over.bin", json!({"category": "other", "file_name": "s.bin",
"mime_type": "application/octet-stream", "content_base64": b64e(&over), "mime_type": "application/octet-stream", "content_base64": "YQ==",
"sha256": sha256(&over)}), "sha256": sha256(b"a")}),
), ),
] { ] {
let why = m.refuse(tool, args); let why = m.refuse(tool, args);
assert!( assert!(
why.contains("books.begin_upload") && why.contains(&(LIMIT + 1).to_string()), why.contains("books.begin_upload") && why.contains("unknown argument"),
"{tool}: {why}" "{tool}: {why}"
); );
} }
@@ -2954,16 +2941,16 @@ fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
); );
} }
/// The bytes a filing tool takes are inline base64 with its own SHA-256, /// A filing tool takes its file only as an upload (D-46): with none it is
/// or an upload; never both, never neither. A sha256 missing, or not the /// refused, naming upload_id; an argument that once carried the bytes
/// bytes' own (a byte lost on the way), or base64 that isn't, is refused /// inline (content_base64, sha256, csv_text), alone or beside an upload,
/// (guard base64_corrupt); inline bytes need their file_name and /// is refused as unknown, pointing at books.begin_upload; an upload_id
/// mime_type. An upload_id that names no upload is refused, and /// that names no upload is refused. books.begin_upload names the
/// books.begin_upload names the attachment for bytes stored already, and /// attachment for bytes stored already (its sha256 in any case), and
/// begins an upload for new ones (journaling nothing). Every refusal /// begins an upload for new ones, journaling nothing. Every refusal
/// stores nothing. /// stores nothing.
#[test] #[test]
fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() { fn a_filing_tool_takes_its_file_only_as_an_upload() {
let dir = test_dir("inline"); let dir = test_dir("inline");
let s = new_store(&dir); let s = new_store(&dir);
drop(s); drop(s);
@@ -2972,43 +2959,36 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
let other: &[u8] = b"%PDF-1.4 a receipt!"; let other: &[u8] = b"%PDF-1.4 a receipt!";
let journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(); let journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
let before = journal(); let before = journal();
let up = m.upload(bytes, "r.pdf", "application/pdf", "receipt");
for (tool, base) in [ for (tool, base) in [
("books.attach_receipt", json!({})), ("books.attach_receipt", json!({})),
("books.file_document", json!({"category": "other"})), ("books.file_document", json!({"category": "other"})),
] { ] {
let with = |extra: Value| { let with = |extra: Value| {
let mut args = base.clone(); let mut args = base.clone();
for (k, v) in json!({"file_name": "r.pdf", "mime_type": "application/pdf"}) for (k, v) in extra.as_object().unwrap() {
.as_object()
.unwrap()
.iter()
.chain(extra.as_object().unwrap())
{
args[k] = v.clone(); args[k] = v.clone();
} }
args args
}; };
for (args, said) in [ for (args, said) in [
// Neither, both. (with(json!({})), "upload_id"),
(with(json!({})), "books.begin_upload"),
( (
with(json!({"upload_id": "u1", "content_base64": b64e(bytes), with(json!({"content_base64": "JVBERi0xLjQ=", "sha256": sha256(bytes)})),
"sha256": sha256(bytes)})), "books.begin_upload",
"not both",
),
// The sha256 missing, another file's, not hex; base64 that isn't.
(with(json!({"content_base64": b64e(bytes)})), "sha256"),
(
with(json!({"content_base64": b64e(bytes), "sha256": sha256(other)})),
"base64_corrupt",
), ),
( (
with(json!({"content_base64": b64e(bytes), "sha256": "not hex"})), with(json!({"content_base64": "JVBERi0xLjQ="})),
"base64_corrupt", "unknown argument",
),
(with(json!({"sha256": sha256(bytes)})), "unknown argument"),
(
with(json!({"upload_id": up, "content_base64": "JVBERi0xLjQ="})),
"unknown argument",
), ),
( (
with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})), with(json!({"upload_id": up, "sha256": sha256(bytes)})),
"base64_corrupt", "unknown argument",
), ),
// An upload there is none of. // An upload there is none of.
( (
@@ -3019,22 +2999,10 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
let why = m.refuse(tool, args.clone()); let why = m.refuse(tool, args.clone());
assert!(why.contains(said), "{tool} {args}: {why}"); assert!(why.contains(said), "{tool} {args}: {why}");
} }
// Inline bytes need their name and type.
for missing in ["file_name", "mime_type"] {
let mut args = with(json!({"content_base64": b64e(bytes), "sha256": sha256(bytes)}));
args.as_object_mut().unwrap().remove(missing);
let why = m.refuse(tool, args);
assert!(why.contains(missing), "{tool} without {missing}: {why}");
}
} }
// A sha256 given in capitals, or with spaces around it, is the same assert_eq!(journal(), before, "a refusal stored something");
// hash. // Every refusal left the upload: filed now.
let out = m.ok( let out = m.ok("books.attach_receipt", json!({"upload_id": up}));
"books.attach_receipt",
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
"content_base64": b64e(bytes),
"sha256": format!(" {} ", sha256(bytes).to_ascii_uppercase())}),
);
assert_eq!(out["sha256"], sha256(bytes)); assert_eq!(out["sha256"], sha256(bytes));
let id = out["attachment_id"].as_u64().unwrap(); let id = out["attachment_id"].as_u64().unwrap();
assert_ne!(journal(), before); assert_ne!(journal(), before);
@@ -3054,8 +3022,8 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
let begun = m.ok("books.begin_upload", up(sha256(other))); let begun = m.ok("books.begin_upload", up(sha256(other)));
assert!(begun["upload_id"].is_string(), "{begun}"); assert!(begun["upload_id"].is_string(), "{begun}");
assert_eq!(journal(), stored, "beginning an upload journals nothing"); assert_eq!(journal(), stored, "beginning an upload journals nothing");
// The CSV imports take csv_text or an upload, never both, never // The CSV imports take only an upload; the text inline is refused as
// neither; an upload there is none of is refused. // unknown, alone or beside one; an upload there is none of is refused.
for (tool, base) in [ for (tool, base) in [
( (
"books.import_bank_csv", "books.import_bank_csv",
@@ -3064,8 +3032,12 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
("books.import_chart_csv", json!({})), ("books.import_chart_csv", json!({})),
] { ] {
for (extra, said) in [ for (extra, said) in [
(json!({}), "csv_text"), (json!({}), "upload_id"),
(json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"), (json!({"csv_text": "a,b\n"}), "books.begin_upload"),
(
json!({"csv_text": "a,b\n", "upload_id": "u1"}),
"unknown argument",
),
(json!({"upload_id": "u1"}), "there is no upload \"u1\""), (json!({"upload_id": "u1"}), "there is no upload \"u1\""),
] { ] {
let mut args = base.clone(); let mut args = base.clone();
@@ -3079,17 +3051,17 @@ fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
assert_eq!(journal(), stored, "a refusal stored something"); assert_eq!(journal(), stored, "a refusal stored something");
} }
/// books.get_receipt with view "bytes" gives a file's bytes inline up to /// books.get_receipt with view "bytes" hands every file over, whatever its
/// 256 KiB; a larger one, its download (a stored file, fetched with /// size (D-46): its download, made for the caller's token, is those very
/// books:read by `GET /b/{book}/a/{id}`: those very bytes), never its /// bytes, as is the stored file's own route (`GET /b/{book}/a/{id}`, for
/// bytes inline. /// books:read); the answer never carries the bytes.
#[test] #[test]
fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() { fn get_receipt_hands_every_file_over_by_its_download() {
const LIMIT: usize = 256 * 1024; const OLD_LIMIT: usize = 256 * 1024;
let dir = test_dir("get-bytes"); let dir = test_dir("get-bytes");
let mut s = new_store(&dir); let mut s = new_store(&dir);
let at = vec![b'x'; LIMIT]; let at = vec![b'x'; OLD_LIMIT];
let over = vec![b'y'; LIMIT + 1]; let over = vec![b'y'; OLD_LIMIT + 1];
let (at_id, _, _) = s let (at_id, _, _) = s
.attach_receipt(None, "at.bin", "application/octet-stream", &at, None) .attach_receipt(None, "at.bin", "application/octet-stream", &at, None)
.unwrap(); .unwrap();
@@ -3098,27 +3070,29 @@ fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() {
.unwrap(); .unwrap();
drop(s); drop(s);
let mut m = Mcp::start(&dir, "1000000.00"); let mut m = Mcp::start(&dir, "1000000.00");
let got = m.ok( for (id, bytes, name) in [(at_id, &at, "at.bin"), (over_id, &over, "over.bin")] {
"books.get_receipt", let got = m.ok(
json!({"attachment_id": at_id, "view": "bytes"}), "books.get_receipt",
); json!({"attachment_id": id, "view": "bytes"}),
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at); );
let big = m.ok( assert!(got.get("content_base64").is_none(), "{got}");
"books.get_receipt", assert!(got.get("download").is_none(), "{got}");
json!({"attachment_id": over_id, "view": "bytes"}), let file = &got["file"];
); assert_eq!(file["file_name"], name);
assert!(big.get("content_base64").is_none(), "{big}"); assert_eq!(file["mime_type"], "application/octet-stream");
let download = big["download"].as_str().unwrap_or_else(|| panic!("{big}")); assert_eq!(file["size_bytes"], json!(bytes.len()));
assert!(download.contains(&format!(" {over_id} ")), "{download}"); assert_eq!(file["sha256"], sha256(bytes));
assert_eq!(big["size_bytes"], json!(LIMIT + 1)); let download = file["download"].as_str().unwrap_or_else(|| panic!("{got}"));
assert_eq!(big["sha256"], sha256(&over)); assert!(download.contains(" ex_"), "{download}");
let (status, bytes) = m.wire.get(&format!("a/{over_id}")); assert_eq!(&m.handed(file), bytes);
assert_eq!(status, 200); let (status, stored) = m.wire.get(&format!("a/{id}"));
assert_eq!(bytes, over); assert_eq!(status, 200);
let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id})); assert_eq!(&stored, bytes);
assert_eq!(meta["size_bytes"], json!(LIMIT + 1)); let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
assert_eq!(meta["sha256"], sha256(&over)); assert_eq!(meta["size_bytes"], json!(bytes.len()));
assert!(meta.get("content_base64").is_none(), "{meta}"); assert_eq!(meta["sha256"], sha256(bytes));
assert!(meta.get("file").is_none(), "{meta}");
}
} }
/// Bank CSV import: money in debits the bank, money out credits it; the /// Bank CSV import: money in debits the bank, money out credits it; the
@@ -3131,13 +3105,15 @@ fn bank_import_puts_money_in_and_out_on_the_right_sides() {
let s = new_store(&dir); let s = new_store(&dir);
drop(s); drop(s);
let mut m = Mcp::start(&dir, "1000000.00"); let mut m = Mcp::start(&dir, "1000000.00");
let csv = "Date,Description,Amount\n\
03/04/2025,Corner Coffee Shop,-4.50\n\
13/04/2025,Client payment,250.00\n\
20/04/2025,STARBUCKS COFFEE #12,-6.00\n";
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let out = m.ok( let out = m.ok(
"books.import_bank_csv", "books.import_bank_csv",
json!({ json!({
"csv_text": "Date,Description,Amount\n\ "upload_id": up,
03/04/2025,Corner Coffee Shop,-4.50\n\
13/04/2025,Client payment,250.00\n\
20/04/2025,STARBUCKS COFFEE #12,-6.00\n",
"bank_account_code": "1000", "bank_account_code": "1000",
"default_offset_code": "4000", "default_offset_code": "4000",
"rules": {"coffee": "6010", "starbucks coffee": "6020"}, "rules": {"coffee": "6010", "starbucks coffee": "6020"},
+2 -2
View File
@@ -202,7 +202,7 @@ fn the_tools_figures_are_the_engines_on_every_date() {
"books.export", "books.export",
json!({"report": "members_equity", "format": "json", "start": START, "end": end}), json!({"report": "members_equity", "format": "json", "start": START, "end": end}),
); );
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); let me: Value = serde_json::from_str(&mcp.wire.fetch_text(&me["file"])).unwrap();
for (col, engine) in me["members"] for (col, engine) in me["members"]
.as_array() .as_array()
.unwrap() .unwrap()
@@ -263,7 +263,7 @@ fn the_tools_figures_are_the_engines_on_every_date() {
"books.export", "books.export",
json!({"report": "general_journal", "format": "iif", "as_of": end}), json!({"report": "general_journal", "format": "iif", "as_of": end}),
); );
assert_eq!(iif["content"].as_str().unwrap(), x.iif, "{end}"); assert_eq!(mcp.wire.fetch_text(&iif["file"]), x.iif, "{end}");
} }
// Party totals for the year, on both bases. // Party totals for the year, on both bases.
for (name, a) in &parties { for (name, a) in &parties {
+18 -57
View File
@@ -282,12 +282,15 @@ impl Mcp {
} }
fn file_bill_of_sale(&mut self) -> u64 { fn file_bill_of_sale(&mut self) -> u64 {
let upload = self.wire.upload(
b"bill of sale",
"bill-of-sale.txt",
"text/plain",
"document",
);
self.ok( self.ok(
"books.file_document", "books.file_document",
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain", json!({"category": "legal", "upload_id": upload, "description": "Bill of sale"}),
"content_base64": b64(b"bill of sale"),
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
"description": "Bill of sale"}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap() .unwrap()
@@ -313,49 +316,6 @@ fn books(name: &str) -> Mcp {
m m
} }
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
fn unb64(s: &str) -> Vec<u8> {
let val = |c: u8| -> u32 {
match c {
b'A'..=b'Z' => u32::from(c - b'A'),
b'a'..=b'z' => u32::from(c - b'a') + 26,
b'0'..=b'9' => u32::from(c - b'0') + 52,
b'+' => 62,
b'/' => 63,
_ => panic!("not base64: {c}"),
}
};
let clean: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
let mut out = Vec::new();
for chunk in clean.chunks(4) {
let pad = chunk.iter().filter(|&&b| b == b'=').count();
let mut n = 0u32;
for (i, &b) in chunk.iter().enumerate() {
n |= if b == b'=' { 0 } else { val(b) } << (18 - 6 * i);
}
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
out.extend_from_slice(&bytes[..3 - pad]);
}
out
}
/// Decimal strings compared by value. /// Decimal strings compared by value.
fn canon(s: &str) -> String { fn canon(s: &str) -> String {
let (neg, s) = match s.strip_prefix('-') { let (neg, s) = match s.strip_prefix('-') {
@@ -677,9 +637,12 @@ fn asset_labels_prints_a_pdf_of_the_tags_in_order() {
let (a3, t3) = m.register_ok(json!({"tag": "DESK/7"})); let (a3, t3) = m.register_ok(json!({"tag": "DESK/7"}));
let r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]})); let r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]}));
assert_eq!(r["tags"], json!([t3, t1]), "the tags printed, in order"); assert_eq!(r["tags"], json!([t3, t1]), "the tags printed, in order");
let pdf = unb64(r["content_base64"].as_str().unwrap()); // Handed over (D-46): its bytes come by its download, never here.
assert!(r.get("content_base64").is_none(), "{r}");
let pdf = m.wire.fetch(&r["file"]);
assert!(pdf.starts_with(b"%PDF"), "a PDF"); assert!(pdf.starts_with(b"%PDF"), "a PDF");
assert!(!r["file_name"].as_str().unwrap().is_empty()); assert_eq!(r["file"]["mime_type"], "application/pdf");
assert!(!r["file"]["file_name"].as_str().unwrap().is_empty());
// By id too. // By id too.
let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]})); let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]}));
assert_eq!(r["tags"], json!(["DESK/7"])); assert_eq!(r["tags"], json!(["DESK/7"]));
@@ -697,7 +660,7 @@ fn asset_labels_prints_a_pdf_of_the_tags_in_order() {
assert_eq!(tags, want); assert_eq!(tags, want);
// skip: 0 to 29. // skip: 0 to 29.
let r = m.read_only("books.asset_labels", json!({"assets": [t1], "skip": 29})); let r = m.read_only("books.asset_labels", json!({"assets": [t1], "skip": 29}));
assert!(unb64(r["content_base64"].as_str().unwrap()).starts_with(b"%PDF")); assert!(m.wire.fetch(&r["file"]).starts_with(b"%PDF"));
m.refuse("books.asset_labels", json!({"assets": [t2], "skip": 30})); m.refuse("books.asset_labels", json!({"assets": [t2], "skip": 30}));
// Unknown asset. // Unknown asset.
m.refuse("books.asset_labels", json!({"assets": ["NOPE-1"]})); m.refuse("books.asset_labels", json!({"assets": ["NOPE-1"]}));
@@ -795,7 +758,7 @@ fn export_fixed_asset_register_reconciles_to_the_books() {
); );
assert_eq!(r["report"], "fixed_asset_register"); assert_eq!(r["report"], "fixed_asset_register");
assert_eq!(r["format"], format); assert_eq!(r["format"], format);
r["content"].as_str().unwrap().to_string() m.wire.fetch_text(&r["file"])
}; };
// Agreeing: depreciation posted through February. // Agreeing: depreciation posted through February.
@@ -839,7 +802,7 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
"books.export", "books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}), json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
); );
let text = r["content"].as_str().unwrap(); let text = &m.wire.fetch_text(&r["file"]);
assert!(text.contains("EARLY-1")); assert!(text.contains("EARLY-1"));
assert!(!text.contains("LATE-1"), "{text}"); assert!(!text.contains("LATE-1"), "{text}");
} }
@@ -1385,13 +1348,11 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
"confirmed": true, "receipt_attachment_ids": [doc]}), "confirmed": true, "receipt_attachment_ids": [doc]}),
); );
let export = |m: &mut Mcp, format: &str| -> String { let export = |m: &mut Mcp, format: &str| -> String {
m.read_only( let r = m.read_only(
"books.export", "books.export",
json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}), json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}),
)["content"] );
.as_str() m.wire.fetch_text(&r["file"])
.unwrap()
.to_string()
}; };
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap(); let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
let rows = j["assets"].as_array().unwrap(); let rows = j["assets"].as_array().unwrap();
+36 -34
View File
@@ -274,16 +274,6 @@ impl Mcp {
} }
} }
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
const CHART: &[(&str, &str, &str)] = &[ const CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"), ("1000", "Bank", "asset"),
("1300", "Prepaid Expenses", "asset"), ("1300", "Prepaid Expenses", "asset"),
@@ -314,13 +304,22 @@ fn books(name: &str) -> Mcp {
} }
impl Mcp { impl Mcp {
/// Upload `bytes` as an agent does (D-46): the upload_id.
fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
self.wire.upload(bytes, file_name, mime_type, purpose)
}
/// File a document; its id. /// File a document; its id.
fn doc(&mut self, category: &str, bytes: &[u8], link_to: Value) -> u64 { fn doc(&mut self, category: &str, bytes: &[u8], link_to: Value) -> u64 {
let upload = self.upload(
bytes,
&format!("{category}.pdf"),
"application/pdf",
"document",
);
self.ok( self.ok(
"books.file_document", "books.file_document",
json!({"category": category, "file_name": format!("{category}.pdf"), json!({"category": category, "upload_id": upload, "link_to": link_to}),
"mime_type": "application/pdf", "content_base64": b64(bytes), "sha256": sha(bytes),
"link_to": link_to}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap() .unwrap()
@@ -328,8 +327,8 @@ impl Mcp {
/// Attach a receipt (to `entry`, or unlinked); its id. /// Attach a receipt (to `entry`, or unlinked); its id.
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 { fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf", let upload = self.upload(bytes, "r.pdf", "application/pdf", "receipt");
"content_base64": b64(bytes), "sha256": sha(bytes)}); let mut args = json!({"upload_id": upload});
if let Some(e) = entry { if let Some(e) = entry {
args["entry_id"] = json!(e); args["entry_id"] = json!(e);
} }
@@ -691,6 +690,8 @@ fn filing_links_in_the_same_call_all_or_nothing() {
.len() .len()
}; };
let n = count(&mut m); let n = count(&mut m);
// One upload, left for the next call by each refused one.
let r = m.upload(b"r", "r.pdf", "application/pdf", "receipt");
for link_to in [ for link_to in [
json!([{"kind": "party", "id": "Acme Design"}, {"kind": "entry", "id": 999}]), json!([{"kind": "party", "id": "Acme Design"}, {"kind": "entry", "id": 999}]),
json!([{"kind": "company"}, {"kind": "company"}]), json!([{"kind": "company"}, {"kind": "company"}]),
@@ -699,26 +700,23 @@ fn filing_links_in_the_same_call_all_or_nothing() {
] { ] {
m.refuse( m.refuse(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf", json!({"entry_id": e, "upload_id": r, "link_to": link_to}),
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": link_to}),
); );
} }
let c = m.upload(b"c", "c.pdf", "application/pdf", "document");
m.refuse( m.refuse(
"books.file_document", "books.file_document",
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf", json!({"category": "contract", "upload_id": c, "link_to": [{"kind": "asset", "id": "NOPE"}]}),
"content_base64": b64(b"c"), "sha256": sha(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
); );
// An unknown entry_id is refused before anything is filed. // An unknown entry_id is refused before anything is filed.
m.refuse( m.refuse(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf", json!({"entry_id": 999, "upload_id": r, "link_to": [{"kind": "company"}]}),
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": [{"kind": "company"}]}),
); );
assert_eq!(count(&mut m), n, "nothing filed"); assert_eq!(count(&mut m), n, "nothing filed");
let out = m.ok( let out = m.ok(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf", json!({"entry_id": e, "upload_id": r,
"content_base64": b64(b"r"), "sha256": sha(b"r"),
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}), "link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
); );
assert_eq!( assert_eq!(
@@ -729,10 +727,10 @@ fn filing_links_in_the_same_call_all_or_nothing() {
assert_eq!(out["links"][0]["id"], json!(e)); assert_eq!(out["links"][0]["id"], json!(e));
assert_eq!(m.count("receipt.attached"), 1); assert_eq!(m.count("receipt.attached"), 1);
assert_eq!(m.count("attachment.linked"), 2); assert_eq!(m.count("attachment.linked"), 2);
let cert = m.upload(b"certificate", "cert.pdf", "application/pdf", "document");
let doc = m.ok( let doc = m.ok(
"books.file_document", "books.file_document",
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf", json!({"category": "formation", "upload_id": cert, "link_to": [{"kind": "company"}]}),
"content_base64": b64(b"certificate"), "sha256": sha(b"certificate"), "link_to": [{"kind": "company"}]}),
); );
assert_eq!(kinds(&doc), ["company"]); assert_eq!(kinds(&doc), ["company"]);
assert_eq!(m.count("document.filed"), 1); assert_eq!(m.count("document.filed"), 1);
@@ -759,10 +757,10 @@ fn documents_file_under_the_attachment_kinds() {
c c
); );
} }
let x = m.upload(b"x", "x", "text/plain", "document");
m.refuse( m.refuse(
"books.file_document", "books.file_document",
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain", json!({"category": "receipt-ish", "upload_id": x}),
"content_base64": b64(b"x"), "sha256": sha(b"x")}),
); );
} }
@@ -1249,13 +1247,15 @@ fn a_bank_import_links_its_source_to_the_account_not_the_drafts() {
"books.withdraw_attachment", "books.withdraw_attachment",
json!({"attachment_id": gone, "reason": "superseded"}), json!({"attachment_id": gone, "reason": "superseded"}),
); );
let args = |source: u64| { let args = |upload: &str, source: u64| {
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999", json!({"upload_id": upload, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}) "source_attachment_id": source})
}; };
m.refuse("books.import_bank_csv", args(999)); // One upload, left for the next call by each refused one.
m.refuse("books.import_bank_csv", args(gone)); let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let out = m.ok("books.import_bank_csv", args(src)); m.refuse("books.import_bank_csv", args(&up, 999));
m.refuse("books.import_bank_csv", args(&up, gone));
let out = m.ok("books.import_bank_csv", args(&up, src));
assert_eq!(out["count"], 2); assert_eq!(out["count"], 2);
assert_eq!(out["source_attachment_id"], json!(src)); assert_eq!(out["source_attachment_id"], json!(src));
assert_eq!(out["source_linked"], true); assert_eq!(out["source_linked"], true);
@@ -1271,13 +1271,15 @@ fn a_bank_import_links_its_source_to_the_account_not_the_drafts() {
); );
} }
// Linked already: imported again, not linked twice. // Linked already: imported again, not linked twice.
let again = m.ok("books.import_bank_csv", args(src)); let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let again = m.ok("books.import_bank_csv", args(&up, src));
assert_eq!(again["source_linked"], false); assert_eq!(again["source_linked"], false);
assert_eq!(m.count("attachment.linked"), 1); assert_eq!(m.count("attachment.linked"), 1);
// Without a source, as before. // Without a source, as before.
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
let plain = m.ok( let plain = m.ok(
"books.import_bank_csv", "books.import_bank_csv",
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999"}), json!({"upload_id": up, "bank_account_code": "1000", "default_offset_code": "6999"}),
); );
assert!(plain["source_attachment_id"].is_null()); assert!(plain["source_attachment_id"].is_null());
assert_eq!(plain["source_linked"], false); assert_eq!(plain["source_linked"], false);
+65 -67
View File
@@ -426,13 +426,14 @@ impl Mcp {
id id
} }
/// Upload `bytes` as an agent does (D-46): the upload_id.
fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
self.wire.upload(bytes, file_name, mime_type, purpose)
}
fn attach(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 { fn attach(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({ let upload = self.upload(bytes, "receipt.txt", "text/plain", "receipt");
"file_name": "receipt.txt", let mut args = json!({"upload_id": upload});
"mime_type": "text/plain",
"content_base64": b64(bytes),
"sha256": sha(bytes),
});
if let Some(e) = entry { if let Some(e) = entry {
args["entry_id"] = json!(e); args["entry_id"] = json!(e);
} }
@@ -544,29 +545,6 @@ fn canon(s: &str) -> String {
} }
} }
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
/// A chart used by several tests. /// A chart used by several tests.
const BASE_CHART: &[(&str, &str, &str)] = &[ const BASE_CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"), ("1000", "Bank", "asset"),
@@ -627,10 +605,13 @@ fn happy_path(name: &str) -> Mcp {
("6010", "Platform Fees", "expense"), ("6010", "Platform Fees", "expense"),
("6500", "Depreciation Expense", "expense"), ("6500", "Depreciation Expense", "expense"),
]); ]);
m.ok( let chart = m.upload(
"books.import_chart_csv", b"code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n",
json!({"csv_text": "code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n"}), "chart.csv",
"text/csv",
"import",
); );
m.ok("books.import_chart_csv", json!({"upload_id": chart}));
m.ok( m.ok(
"books.renumber_account", "books.renumber_account",
json!({"account": "7000", "code": "7100"}), json!({"account": "7000", "code": "7100"}),
@@ -717,11 +698,11 @@ fn happy_path(name: &str) -> Mcp {
"tin_last4": "4321", "kept_at": "CPA portal"}), "tin_last4": "4321", "kept_at": "CPA portal"}),
); );
let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321"; let w9_pdf = b"%PDF-1.4 W-9 EIN 79-9714321";
let w9 = m.upload(w9_pdf, "w9.pdf", "application/pdf", "w9");
m.ok( m.ok(
"books.set_w9", "books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)", json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "79-9714321", "tin_type": "ein", "tin": "79-9714321", "tin_type": "ein", "w9_upload_id": w9}),
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
); );
m.ok("books.get_w9", json!({"party": "Landlord"})); m.ok("books.get_w9", json!({"party": "Landlord"}));
m.ok("books.parties", json!({"role": "landlord", "year": 2026})); m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
@@ -800,19 +781,22 @@ fn happy_path(name: &str) -> Mcp {
"books.get_receipt", "books.get_receipt",
json!({"attachment_id": r_old, "view": "bytes"}), json!({"attachment_id": r_old, "view": "bytes"}),
); );
let oa = m.upload(
b"operating agreement",
"agreement.txt",
"text/plain",
"document",
);
let doc = m.ok( let doc = m.ok(
"books.file_document", "books.file_document",
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain", json!({"category": "legal", "upload_id": oa, "description": "OA"}),
"content_base64": b64(b"operating agreement"),
"sha256": sha(b"operating agreement"), "description": "OA"}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
let lease = m.upload(b"lease", "lease.txt", "text/plain", "document");
let filed = m.ok( let filed = m.ok(
"books.file_document", "books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain", json!({"category": "contract", "upload_id": lease, "link_to": [{"kind": "company"}]}),
"content_base64": b64(b"lease"),
"sha256": sha(b"lease"), "link_to": [{"kind": "company"}]}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -833,7 +817,7 @@ fn happy_path(name: &str) -> Mcp {
let local = m.attach(None, b"a local receipt"); let local = m.attach(None, b"a local receipt");
let up = m.ok( let up = m.ok(
"books.begin_upload", "books.begin_upload",
json!({"sha256": sha(b"a local receipt"), "size": 15, "mime_type": "text/plain", json!({"sha256": ledger_store::journal::sha256_hex(b"a local receipt"), "size": 15, "mime_type": "text/plain",
"file_name": "local-receipt.txt", "purpose": "receipt"}), "file_name": "local-receipt.txt", "purpose": "receipt"}),
); );
assert_eq!(up["existing_attachment_id"], json!(local), "{up}"); assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
@@ -983,17 +967,17 @@ fn happy_path(name: &str) -> Mcp {
// Bank import and reconciliation. // Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n"; let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "document");
let source = m.ok( let source = m.ok(
"books.file_document", "books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv", json!({"category": "import_source", "upload_id": up}),
"content_base64": b64(csv.as_bytes()),
"sha256": sha(csv.as_bytes())}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
m.ok( m.ok(
"books.import_bank_csv", "books.import_bank_csv",
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999", json!({"upload_id": up, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}), "source_attachment_id": source}),
); );
m.ok( m.ok(
@@ -1005,11 +989,15 @@ fn happy_path(name: &str) -> Mcp {
"books.mark_cleared", "books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}), json!({"entry_id": e1, "line_index": 0, "cleared": true}),
); );
let up = m.upload(
b"%PDF statement",
"2026-01.pdf",
"application/pdf",
"document",
);
let stmt = m.ok( let stmt = m.ok(
"books.file_document", "books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf", json!({"category": "statement", "upload_id": up}),
"content_base64": b64(b"%PDF statement"),
"sha256": sha(b"%PDF statement")}),
)["attachment_id"] )["attachment_id"]
.as_u64() .as_u64()
.unwrap(); .unwrap();
@@ -1324,9 +1312,10 @@ enum Op {
date: String, date: String,
confirmed: Option<bool>, confirmed: Option<bool>,
}, },
/// A receipt: these bytes, or (None) bytes given inline.
Attach { Attach {
entry: Option<Pick>, entry: Option<Pick>,
content: String, content: Option<Vec<u8>>,
}, },
Link { Link {
att: Pick, att: Pick,
@@ -1424,8 +1413,8 @@ fn op_strategy() -> impl Strategy<Value = Op> {
2 => (pick(), date_strategy(), confirmed()) 2 => (pick(), date_strategy(), confirmed())
.prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }), .prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }),
3 => (prop::option::of(pick()), prop_oneof![ 3 => (prop::option::of(pick()), prop_oneof![
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(|b| b64(&b)), 4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(Some),
1 => Just("!!not base64!!".to_string()), 1 => Just(None),
]) ])
.prop_map(|(entry, content)| Op::Attach { entry, content }), .prop_map(|(entry, content)| Op::Attach { entry, content }),
1 => (pick(), pick()).prop_map(|(att, entry)| Op::Link { att, entry }), 1 => (pick(), pick()).prop_map(|(att, entry)| Op::Link { att, entry }),
@@ -1487,11 +1476,10 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
} }
if *receipt { if *receipt {
let bytes = format!("receipt for entry {e}"); let bytes = format!("receipt for entry {e}");
let up = m.upload(bytes.as_bytes(), "r.txt", "text/plain", "receipt");
let out = m.tool( let out = m.tool(
"books.attach_receipt", "books.attach_receipt",
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain", json!({"entry_id": e, "upload_id": up}),
"content_base64": b64(bytes.as_bytes()),
"sha256": sha(bytes.as_bytes())}),
); );
k.attachments.extend(new_id(&out, "attachment_id")); k.attachments.extend(new_id(&out, "attachment_id"));
} }
@@ -1521,19 +1509,30 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
"confirmed": opt(*confirmed)}), "confirmed": opt(*confirmed)}),
), ),
Op::Attach { entry, content } => { Op::Attach { entry, content } => {
// The bytes' own SHA-256 (content that is no base64 has none). let entry = entry.map(|p| p.resolve(&k.entries));
let sha256 = { // New bytes go by their upload; bytes stored already (begin_upload
use base64::Engine as _; // names their attachment) are attached by an upload there is
base64::engine::general_purpose::STANDARD // none of; none at all, by the inline argument D-46 removed.
.decode(content) let given = match content {
.map_or_else(|_| "0".repeat(64), |b| sha(&b)) Some(bytes) => {
let begun = m.wire.begin_and_send(
bytes,
"r.bin",
"application/octet-stream",
"receipt",
);
let up = begun["upload_id"]
.as_str()
.map_or_else(|| format!("up_{}", "a".repeat(32)), str::to_string);
json!({"upload_id": up})
}
None => json!({"content_base64": "cmVjZWlwdA=="}),
}; };
( let mut args = json!({"entry_id": entry});
"books.attach_receipt", for (key, v) in given.as_object().unwrap() {
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin", args[key] = v.clone();
"mime_type": "application/octet-stream", "content_base64": content, }
"sha256": sha256}), ("books.attach_receipt", args)
)
} }
Op::Link { att, entry } => ( Op::Link { att, entry } => (
"books.link_receipt", "books.link_receipt",
@@ -2298,7 +2297,6 @@ fn helpers_self_check() {
assert_eq!(canon("007.50"), "7.5"); assert_eq!(canon("007.50"), "7.5");
assert_eq!(canon("5.00"), "5"); assert_eq!(canon("5.00"), "5");
assert_eq!(canon("-0.00"), "0"); assert_eq!(canon("-0.00"), "0");
assert_eq!(b64(b"abcd"), "YWJjZA==");
let mut bad = Vec::new(); let mut bad = Vec::new();
money_walk( money_walk(
&json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}), &json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}),
+1 -1
View File
@@ -480,7 +480,7 @@ fn defect14_party_totals_take_a_basis() {
"books.export", "books.export",
json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}), json!({"report": "members_equity", "format": "json", "start": "2026-01-01", "end": "2026-12-31"}),
); );
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap(); let me: Value = serde_json::from_str(&m.wire.fetch_text(&me["file"])).unwrap();
for who in ["Tobin Harlow", "Morgan Ellis"] { for who in ["Tobin Harlow", "Morgan Ellis"] {
let col = me["members"] let col = me["members"]
.as_array() .as_array()
+5 -12
View File
@@ -271,15 +271,6 @@ fn books_in(name: &str, p2: bool) -> Mcp {
m m
} }
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
impl Mcp { impl Mcp {
/// Draft with lines (account, side, amount, class), post it; the id. /// Draft with lines (account, side, amount, class), post it; the id.
fn tagged( fn tagged(
@@ -308,12 +299,14 @@ impl Mcp {
id id
} }
/// File `bytes` as a contract, uploaded as an agent uploads them.
fn doc(&mut self, bytes: &[u8], link_to: Value) -> Out { fn doc(&mut self, bytes: &[u8], link_to: Value) -> Out {
let upload = self
.wire
.upload(bytes, "spec.pdf", "application/pdf", "document");
self.tool( self.tool(
"books.file_document", "books.file_document",
json!({"category": "contract", "file_name": "spec.pdf", json!({"category": "contract", "upload_id": upload, "link_to": link_to}),
"mime_type": "application/pdf", "content_base64": b64(bytes),
"sha256": sha(bytes), "link_to": link_to}),
) )
} }
} }
+14 -14
View File
@@ -60,6 +60,12 @@ impl Mcp {
} }
/// Call a tool; panic on a refusal. Returns structuredContent. /// Call a tool; panic on a refusal. Returns structuredContent.
/// Upload a CSV for an import as an agent does (D-46): the upload_id.
fn csv_upload(&mut self, csv: &str) -> String {
self.wire
.upload(csv.as_bytes(), "chart.csv", "text/csv", "import")
}
fn call(&mut self, tool: &str, args: Value) -> Value { fn call(&mut self, tool: &str, args: Value) -> Value {
let result = self.result(tool, args); let result = self.result(tool, args);
assert_ne!( assert_ne!(
@@ -243,10 +249,8 @@ fn require_tax_category_refuses_defect_8() {
); );
// The import: refused whole, saying why. // The import: refused whole, saying why.
let before = m.events(); let before = m.events();
let err = m.call_err( let up = m.csv_upload("code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n");
"books.import_chart_csv", let err = m.call_err("books.import_chart_csv", json!({"upload_id": up}));
json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}),
);
assert!( assert!(
err.contains("6210") && err.contains("fifth column") && err.contains("never a header"), err.contains("6210") && err.contains("fifth column") && err.contains("never a header"),
"{err}" "{err}"
@@ -296,10 +300,8 @@ fn require_tax_category_refuses_defect_8() {
json!({"code": "6210", "name": "Misc", "account_type": "expense"}), json!({"code": "6210", "name": "Misc", "account_type": "expense"}),
); );
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}"); assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
let out = m.call( let up = m.csv_upload("code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n");
"books.import_chart_csv", let out = m.call("books.import_chart_csv", json!({"upload_id": up}));
json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}),
);
assert_eq!(out["count"], 3); assert_eq!(out["count"], 3);
assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}"); assert_eq!(out["warnings"].as_array().unwrap().len(), 2, "{out}");
} }
@@ -340,10 +342,8 @@ fn a_new_account_named_like_a_sibling_is_refused() {
let err = m.call_err("books.add_account", args.clone()); let err = m.call_err("books.add_account", args.clone());
assert!(err.contains(says), "{args}: {err}"); assert!(err.contains(says), "{args}: {err}");
} }
let err = m.call_err( let up = m.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n");
"books.import_chart_csv", let err = m.call_err("books.import_chart_csv", json!({"upload_id": up}));
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}),
);
assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}"); assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}");
assert_eq!(m.events(), before); assert_eq!(m.events(), before);
// Top level: no sibling of the child. // Top level: no sibling of the child.
@@ -389,7 +389,7 @@ fn a_gain_on_business_property_is_exported_apart() {
} }
let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"}); let range = |format: &str| json!({"report": "tax_summary", "format": format, "start": "2026-01-01", "end": "2026-12-31"});
let out = m.call("books.export", range("json")); let out = m.call("books.export", range("json"));
let j: Value = serde_json::from_str(out["content"].as_str().unwrap()).unwrap(); let j: Value = serde_json::from_str(&m.wire.fetch_text(&out["file"])).unwrap();
assert_eq!( assert_eq!(
j["form_4797"], j["form_4797"],
json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)", json!([{"category": "form_4797_gain_loss", "label": "Gain or loss on business property (Form 4797)",
@@ -400,7 +400,7 @@ fn a_gain_on_business_property_is_exported_apart() {
assert_eq!(j["book_net"], "10.00"); assert_eq!(j["book_net"], "10.00");
assert_eq!(j["tax_net"], "10.00"); assert_eq!(j["tax_net"], "10.00");
let out = m.call("books.export", range("csv")); let out = m.call("books.export", range("csv"));
let csv = out["content"].as_str().unwrap(); let csv = &m.wire.fetch_text(&out["file"]);
assert!( assert!(
csv.lines().any(|l| l csv.lines().any(|l| l
== "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"), == "form_4797,form_4797_gain_loss,Gain or loss on business property (Form 4797),4700 Gain on disposal,7.50,"),
+54 -61
View File
@@ -60,17 +60,6 @@ struct Mcp {
books_dir: Option<TestDir>, books_dir: Option<TestDir>,
} }
/// `bytes` as a tool takes them inline: base64.
fn b64(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// The SHA-256 (hex) a file given inline comes with.
fn sha(bytes: &[u8]) -> String {
ledger_store::journal::sha256_hex(bytes)
}
/// A test's place (common.rs): its home, its server, its books. /// A test's place (common.rs): its home, its server, its books.
type TestDir = Place; type TestDir = Place;
@@ -466,6 +455,24 @@ impl Mcp {
} }
self.tool("books.set_w9", args) self.tool("books.set_w9", args)
} }
/// Upload `bytes` as an agent does (D-46): the upload_id.
fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String {
self.wire.upload(bytes, file_name, mime_type, purpose)
}
/// Upload a W-9 form, named and typed for what its first bytes say
/// (w9.pdf, w9.png, w9.jpg; else w9.bin): the upload_id.
fn w9_upload(&mut self, bytes: &[u8]) -> String {
let (name, mime) = if bytes.starts_with(b"%PDF-") {
("w9.pdf", "application/pdf")
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
("w9.png", "image/png")
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
("w9.jpg", "image/jpeg")
} else {
("w9.bin", "application/octet-stream")
};
self.upload(bytes, name, mime, "w9")
}
/// A W-9 file (a PDF); it shows the TIN, like the real one. /// A W-9 file (a PDF); it shows the TIN, like the real one.
fn w9_file(&self, tin: &str) -> Vec<u8> { fn w9_file(&self, tin: &str) -> Vec<u8> {
format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes() format!("%PDF-1.4 Form W-9 TIN {tin}").into_bytes()
@@ -608,7 +615,6 @@ fn set_w9_refusals_write_nothing_anywhere() {
m.add_party("IRS", &["tax_agency"]); m.add_party("IRS", &["tax_agency"]);
let pdf = m.w9_file(SSN); let pdf = m.w9_file(SSN);
let txt = b"W-9"; let txt = b"W-9";
let big = [b"%PDF-1.4 ".as_slice(), &vec![b' '; 256 * 1024]].concat();
for (party, extra) in [ for (party, extra) in [
("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits ("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits
( (
@@ -652,41 +658,28 @@ fn set_w9_refusals_write_nothing_anywhere() {
out.err_text() out.err_text()
); );
} }
// The W-9 file, each refused for its own reason. // The W-9 file, each refused for its own reason. Every upload is
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4); // its own: a refused call leaves it unused.
let up_txt = m.upload(txt, "w9.txt", "text/plain", "w9");
let up_pdf = m.upload(&pdf, "w9.pdf", "application/pdf", "w9");
let up_receipt = m.upload(&pdf, "w9.pdf", "application/pdf", "receipt");
for (extra, why) in [ for (extra, why) in [
( (json!({"w9_upload_id": up_txt}), "a PDF, PNG, or JPEG"),
json!({"w9_content_base64": b64(txt), "w9_sha256": sha(txt)}),
"a PDF, PNG, or JPEG",
),
(
json!({"w9_content_base64": b64(&pdf)}),
"comes with its sha256",
),
(
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(txt)}),
"lost on the way",
),
(
json!({"w9_content_base64": "!!not base64", "w9_sha256": sha(&pdf)}),
"not valid base64",
),
(
json!({"w9_content_base64": b64(&big), "w9_sha256": sha(&big)}),
"upload it with books.begin_upload",
),
(
json!({"w9_content_base64": long, "w9_sha256": sha(&big)}),
"inline is at most",
),
(json!({"w9_upload_id": "u1"}), "there is no upload \"u1\""), (json!({"w9_upload_id": "u1"}), "there is no upload \"u1\""),
(json!({"w9_upload_id": up_receipt}), "begun for receipt"),
( (
json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}), json!({"w9_upload_id": up_pdf, "w9_file_name": "../w9.pdf"}),
"not both", "no folder",
),
// The inline form D-46 removed: an unknown argument, pointing to
// the upload.
(
json!({"w9_content_base64": "JVBERi0xLjQ=", "w9_sha256": "00"}),
"books.begin_upload",
), ),
( (
json!({"w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf), "w9_file_name": "../w9.pdf"}), json!({"w9_upload_id": up_pdf, "w9_sha256": "00"}),
"no folder", "unknown argument",
), ),
] { ] {
let mut args = json!({"tin": SSN, "tin_type": "ssn"}); let mut args = json!({"tin": SSN, "tin_type": "ssn"});
@@ -757,9 +750,10 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
let mut m = books("w9-file"); let mut m = books("w9-file");
let acme = m.party_id("Acme Design"); let acme = m.party_id("Acme Design");
let src = m.w9_file(SSN); let src = m.w9_file(SSN);
let up1 = m.w9_upload(&src);
let Out::Ok(p) = m.w9( let Out::Ok(p) = m.w9(
"Acme Design", "Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}), json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up1}),
) else { ) else {
panic!("set_w9 with a file") panic!("set_w9 with a file")
}; };
@@ -790,10 +784,8 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
// A W-9 file alone (the TIN typed in later) records the vault too. // A W-9 file alone (the TIN typed in later) records the vault too.
m.add_party("Solo", &["contractor"]); m.add_party("Solo", &["contractor"]);
let solo = m.w9_file("000"); let solo = m.w9_file("000");
let Out::Ok(p) = m.w9( let up2 = m.w9_upload(&solo);
"Solo", let Out::Ok(p) = m.w9("Solo", json!({"tin_last4": "0000", "w9_upload_id": up2})) else {
json!({"tin_last4": "0000", "w9_content_base64": b64(&solo), "w9_sha256": sha(&solo)}),
) else {
panic!("a W-9 file alone") panic!("a W-9 file alone")
}; };
assert_eq!(p["w9"]["in_vault"], true); assert_eq!(p["w9"]["in_vault"], true);
@@ -816,10 +808,8 @@ fn every_w9_file_type_is_taken() {
{ {
let party = format!("Payee {n}"); let party = format!("Payee {n}");
m.add_party(&party, &["contractor"]); m.add_party(&party, &["contractor"]);
let Out::Ok(p) = m.w9( let up3 = m.w9_upload(bytes);
&party, let Out::Ok(p) = m.w9(&party, json!({"tin_last4": "0000", "w9_upload_id": up3})) else {
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
) else {
panic!("{name} refused") panic!("{name} refused")
}; };
assert_eq!(p["w9"]["in_vault"], true, "{name}"); assert_eq!(p["w9"]["in_vault"], true, "{name}");
@@ -830,9 +820,10 @@ fn every_w9_file_type_is_taken() {
// Its own name, when given; given alone, a name is refused. // Its own name, when given; given alone, a name is refused.
m.add_party("Payee N", &["contractor"]); m.add_party("Payee N", &["contractor"]);
let bytes = b"%PDF-1.4 Form W-9 named"; let bytes = b"%PDF-1.4 Form W-9 named";
let up4 = m.w9_upload(bytes);
let Out::Ok(_) = m.w9( let Out::Ok(_) = m.w9(
"Payee N", "Payee N",
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes), json!({"tin_last4": "0000", "w9_upload_id": up4,
"w9_file_name": "payee-n-w9.pdf"}), "w9_file_name": "payee-n-w9.pdf"}),
) else { ) else {
panic!("named W-9 refused") panic!("named W-9 refused")
@@ -855,11 +846,9 @@ fn every_w9_file_type_is_taken() {
b"\xff\xd8 short".as_slice(), b"\xff\xd8 short".as_slice(),
b"GIF89a".as_slice(), b"GIF89a".as_slice(),
] { ] {
let up5 = m.w9_upload(bytes);
let why = m let why = m
.w9( .w9("Payee X", json!({"tin_last4": "0000", "w9_upload_id": up5}))
"Payee X",
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
)
.err_text(); .err_text();
assert!(why.contains("PDF, PNG, or JPEG"), "{why}"); assert!(why.contains("PDF, PNG, or JPEG"), "{why}");
} }
@@ -870,9 +859,10 @@ fn every_w9_file_type_is_taken() {
fn a_locked_vault_says_so_and_never_guesses() { fn a_locked_vault_says_so_and_never_guesses() {
let mut m = books("locked"); let mut m = books("locked");
let src = m.w9_file(SSN); let src = m.w9_file(SSN);
let up6 = m.w9_upload(&src);
m.w9( m.w9(
"Acme Design", "Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}), json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up6}),
); );
m.pay("Acme Design", "700.00"); m.pay("Acme Design", "700.00");
let key = std::fs::read(m.key()).unwrap(); let key = std::fs::read(m.key()).unwrap();
@@ -1006,9 +996,10 @@ fn purge_deletes_the_vault_records_and_journals_why() {
let mut m = books("purge"); let mut m = books("purge");
let acme = m.party_id("Acme Design"); let acme = m.party_id("Acme Design");
let src = m.w9_file(SSN); let src = m.w9_file(SSN);
let up7 = m.w9_upload(&src);
m.w9( m.w9(
"Acme Design", "Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}), json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up7}),
); );
// Clearing while the vault holds it: refused. // Clearing while the vault holds it: refused.
let why = m let why = m
@@ -1089,9 +1080,10 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
"[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n", "[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
); );
let src = m.w9_file(SSN); let src = m.w9_file(SSN);
let up8 = m.w9_upload(&src);
m.w9( m.w9(
"Acme Design", "Acme Design",
json!({"tin": SSN, "tin_type": "ssn", "w9_content_base64": b64(&src), "w9_sha256": sha(&src)}), json!({"tin": SSN, "tin_type": "ssn", "w9_upload_id": up8}),
); );
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
m.ok("books.add_vendor", json!({"name": "Rent Co"})); m.ok("books.add_vendor", json!({"name": "Rent Co"}));
@@ -1245,8 +1237,9 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
} }
assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}"); assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}");
// The handed-over files were scanned: two packages (without TINs, // The handed-over files were scanned: two packages (without TINs,
// with them), the W-9 file, the worksheet CSV. // with them), the W-9 file, the worksheet CSV, and the journal's two
assert_eq!(scanned.len(), 4, "{scanned:?}"); // exports (D-46: handed over too).
assert_eq!(scanned.len(), 6, "{scanned:?}");
assert!(m.transcript.len() > 40, "the session used the books"); assert!(m.transcript.len() > 40, "the session used the books");
// The server's log. // The server's log.
let log = m.server_log(); let log = m.server_log();
+121 -51
View File
@@ -10,8 +10,8 @@
//! INVARIANTS (oracle): //! INVARIANTS (oracle):
//! 1. Every file the server read or wrote with full TINs is refused by //! 1. Every file the server read or wrote with full TINs is refused by
//! every way a file enters the books (books.attach_receipt and //! every way a file enters the books (books.attach_receipt and
//! books.file_document, its bytes given inline), from any session and //! books.file_document, its bytes uploaded as an agent uploads them:
//! after the server restarts. //! D-46), from any session and after the server restarts.
//! 2. Text holding a full TIN is refused, whoever made it: one the vault //! 2. Text holding a full TIN is refused, whoever made it: one the vault
//! holds, in every common rendering (nine digits; 3-2-4 joined by //! holds, in every common rendering (nine digits; 3-2-4 joined by
//! hyphens, spaces or dots; 2-7 joined by a hyphen or a space), and, //! hyphens, spaces or dots; 2-7 joined by a hyphen or a space), and,
@@ -55,6 +55,10 @@ struct Books {
dir: PathBuf, dir: PathBuf,
inbox: PathBuf, inbox: PathBuf,
out: PathBuf, out: PathBuf,
/// Each upload a refusal left unused, by its bytes' SHA-256: named
/// again by the next call (files.rs INVARIANT 1), so the refusals
/// never pile up past a token's MAX_LIVE.
left: std::sync::Mutex<std::collections::HashMap<String, String>>,
} }
fn books(tag: &str) -> Books { fn books(tag: &str) -> Books {
@@ -83,6 +87,38 @@ fn books(tag: &str) -> Books {
served, served,
inbox, inbox,
out, out,
left: Default::default(),
}
}
impl Books {
/// An upload of `bytes` named `name` on `c` (the books' token's
/// session), as an agent uploads them: one a refusal left unused if
/// there is one, else a new one (purpose `purpose`). Its id.
async fn upload(&self, c: &Client, bytes: &[u8], name: &str, purpose: &str) -> String {
let sha = ledger_store::journal::sha256_hex(bytes);
let key = format!("{purpose} {sha}");
if let Some(id) = self.left.lock().unwrap().get(&key) {
return id.clone();
}
let token = self.served.home.token(common::BOOK);
let id = common::upload_on(
c,
&self.served.home.run(),
common::BOOK,
&token,
bytes,
(name, "application/octet-stream", purpose),
)
.await;
self.left.lock().unwrap().insert(key, id.clone());
id
}
/// The upload of `bytes` for `purpose` was filed: used up.
fn spent(&self, bytes: &[u8], purpose: &str) {
let key = format!("{purpose} {}", ledger_store::journal::sha256_hex(bytes));
self.left.lock().unwrap().remove(&key);
} }
} }
@@ -97,15 +133,6 @@ async fn ok(c: &Client, tool: &str, args: Value) -> Value {
.unwrap_or_else(|e| panic!("{tool}: {e}")) .unwrap_or_else(|e| panic!("{tool}: {e}"))
} }
/// `bytes` as a tool takes them inline: base64, and their SHA-256 (hex).
fn inline(bytes: &[u8]) -> (String, String) {
use base64::Engine;
(
base64::engine::general_purpose::STANDARD.encode(bytes),
ledger_store::journal::sha256_hex(bytes),
)
}
/// The bytes of a file a tool handed over (a `HandedFile`): fetched by its /// The bytes of a file a tool handed over (a `HandedFile`): fetched by its
/// download, matching the size and SHA-256 it states. /// download, matching the size and SHA-256 it states.
async fn handed(b: &Books, file: &Value) -> Vec<u8> { async fn handed(b: &Books, file: &Value) -> Vec<u8> {
@@ -123,20 +150,19 @@ async fn refused_everywhere(
why: &str, why: &str,
) -> Vec<String> { ) -> Vec<String> {
let bytes = std::fs::read(path).unwrap(); let bytes = std::fs::read(path).unwrap();
let (content, sha) = inline(&bytes); // One upload, refused by each tool and so left for the next.
let upload = b.upload(c, &bytes, name, "receipt").await;
let journal = b.dir.join(ledger_store::JOURNAL_FILE); let journal = b.dir.join(ledger_store::JOURNAL_FILE);
let before = std::fs::read(&journal).unwrap(); let before = std::fs::read(&journal).unwrap();
let mut refusals = Vec::new(); let mut refusals = Vec::new();
for (tool, args) in [ for (tool, args) in [
( (
"books.attach_receipt", "books.attach_receipt",
json!({"file_name": name, "mime_type": "application/octet-stream", json!({"upload_id": upload, "mime_type": "application/octet-stream"}),
"content_base64": content, "sha256": sha}),
), ),
( (
"books.file_document", "books.file_document",
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf", json!({"category": "tax", "upload_id": upload, "mime_type": "application/pdf"}),
"content_base64": content, "sha256": sha}),
), ),
] { ] {
let err = call(c, tool, args).await.expect_err(tool); let err = call(c, tool, args).await.expect_err(tool);
@@ -155,7 +181,7 @@ async fn refused_everywhere(
/// books.get_w9 copy of it, a 1099 worksheet and a CPA package with TINs, /// books.get_w9 copy of it, a 1099 worksheet and a CPA package with TINs,
/// and the K-1 worksheet extracted from that package are each refused, /// and the K-1 worksheet extracted from that package are each refused,
/// from another session and after a restart; INVARIANT 2: a CSV holding a /// from another session and after a restart; INVARIANT 2: a CSV holding a
/// TIN that the server never saw is refused, sent as base64. /// TIN that the server never saw is refused, sent by upload.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() { async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() {
let b = books("sessions"); let b = books("sessions");
@@ -166,8 +192,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n", b"%PDF-1.4\n\xff\xfe\x00 scanned W-9 \x01\x02\x03\n%%EOF\n",
) )
.unwrap(); .unwrap();
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
let one = connect(&b).await; let one = connect(&b).await;
let w9_upload = b
.upload(&one, &std::fs::read(&w9).unwrap(), "w9.pdf", "w9")
.await;
for (code, name, ty) in [ for (code, name, ty) in [
("1000", "Cash", "asset"), ("1000", "Cash", "asset"),
("3000", "Capital", "equity"), ("3000", "Capital", "equity"),
@@ -196,9 +224,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
&one, &one,
"books.set_w9", "books.set_w9",
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual", json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_content_base64": w9_content, "w9_sha256": w9_sha}), "tin": SSN, "tin_type": "ssn", "w9_upload_id": w9_upload}),
) )
.await; .await;
b.spent(&std::fs::read(&w9).unwrap(), "w9");
ok( ok(
&one, &one,
"books.set_tin", "books.set_tin",
@@ -207,7 +236,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
.await; .await;
// A bank statement filed in the books: the package carries it, and it // A bank statement filed in the books: the package carries it, and it
// is no TIN file (its routing numbers are not TINs). // is no TIN file (its routing numbers are not TINs).
accepted_everywhere(&one, "statement.csv", BANK_CSV).await; accepted_everywhere(&one, &b, "statement.csv", BANK_CSV).await;
// What the server hands over with TINs, from the first session, saved // What the server hands over with TINs, from the first session, saved
// outside the books as the human would save it. // outside the books as the human would save it.
let copy = b.out.join("w9-copy.pdf"); let copy = b.out.join("w9-copy.pdf");
@@ -259,12 +288,18 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap(); std::fs::write(&fresh, format!("name,ssn\nDana,{}\n", "187-05-1907")).unwrap();
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await; refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
// An ordinary file still goes in, every way. // An ordinary file still goes in, every way.
let (fine, fine_sha) = inline(b"%PDF-1.4\n\xff lunch receipt, order 12-34\n"); let fine = b
.upload(
&two,
b"%PDF-1.4\n\xff lunch receipt, order 12-34\n",
"lunch.pdf",
"receipt",
)
.await;
ok( ok(
&two, &two,
"books.attach_receipt", "books.attach_receipt",
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf", json!({"upload_id": fine, "mime_type": "application/pdf"}),
"content_base64": fine, "sha256": fine_sha}),
) )
.await; .await;
// The books' own files the package carried are not marked: the // The books' own files the package carried are not marked: the
@@ -279,7 +314,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
"the package carries the statement" "the package carries the statement"
); );
} }
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await; accepted_everywhere(&two, &b, "statement-again.csv", BANK_CSV).await;
drop((one, two)); drop((one, two));
b.served.restart(); b.served.restart();
@@ -329,49 +364,60 @@ async fn a_refused_set_w9_never_marks_its_file() {
) )
.await; .await;
} }
let w9_args = |party: &str, path: &Path| { let w9_args = |party: &str, upload: &str| {
let (content, sha) = inline(&std::fs::read(path).unwrap());
json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual", json!({"party": party, "received_on": "2026-01-03", "entity_type": "Individual",
"tin": SSN, "tin_type": "ssn", "w9_content_base64": content, "w9_sha256": sha}) "tin": SSN, "tin_type": "ssn", "w9_upload_id": upload})
}; };
for (party, path, why) in [ for (party, path, why) in [
("Nobody", &w9, "unknown party"), ("Nobody", &w9, "unknown party"),
("Mo", &w9, "1099"), ("Mo", &w9, "1099"),
("Acme", &notes, "PDF, PNG, or JPEG"), ("Acme", &notes, "PDF, PNG, or JPEG"),
] { ] {
let err = call(&c, "books.set_w9", w9_args(party, path)) let bytes = std::fs::read(path).unwrap();
let upload = b.upload(&c, &bytes, "w9.pdf", "w9").await;
let err = call(&c, "books.set_w9", w9_args(party, &upload))
.await .await
.expect_err("refused"); .expect_err("refused");
assert!(err.contains(why), "{party}: {err}"); assert!(err.contains(why), "{party}: {err}");
} }
// Neither file was marked: each is filed as any document is. // Neither file was marked: each is filed as any document is.
let mut filed = Vec::new();
for (name, bytes) in [ for (name, bytes) in [
("w9-scan.pdf", w9_bytes.clone()), ("w9-scan.pdf", w9_bytes.clone()),
("notes.txt", std::fs::read(&notes).unwrap()), ("notes.txt", std::fs::read(&notes).unwrap()),
] { ] {
let (content, sha) = inline(&bytes); let upload = b.upload(&c, &bytes, name, "document").await;
let out = ok( let out = ok(
&c, &c,
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": name, "mime_type": "application/pdf", json!({"category": "other", "upload_id": upload, "mime_type": "application/pdf"}),
"content_base64": content, "sha256": sha}),
) )
.await; .await;
b.spent(&bytes, "document");
assert!(out["attachment_id"].is_u64(), "{name}: {out}"); assert!(out["attachment_id"].is_u64(), "{name}: {out}");
filed.push(out["attachment_id"].clone());
} }
// A set_w9 that succeeds marks it. // A set_w9 that succeeds marks it.
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await; let upload = b.upload(&c, &w9_bytes, "w9.pdf", "w9").await;
let (content, sha) = inline(&w9_bytes); ok(&c, "books.set_w9", w9_args("Acme", &upload)).await;
b.spent(&w9_bytes, "w9");
// The scan filed before is withdrawn, so its bytes are no attachment
// begin_upload names: they are uploaded, and refused.
ok(
&c,
"books.withdraw_attachment",
json!({"attachment_id": filed[0], "reason": "a W-9: it belongs in the vault"}),
)
.await;
let upload = b.upload(&c, &w9_bytes, "again.pdf", "document").await;
for (tool, args) in [ for (tool, args) in [
( (
"books.file_document", "books.file_document",
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf", json!({"category": "other", "upload_id": upload, "mime_type": "application/pdf"}),
"content_base64": content, "sha256": sha}),
), ),
( (
"books.attach_receipt", "books.attach_receipt",
json!({"file_name": "again.pdf", "mime_type": "application/pdf", json!({"upload_id": upload, "mime_type": "application/pdf"}),
"content_base64": content, "sha256": sha}),
), ),
] { ] {
let err = call(&c, tool, args).await.expect_err("marked"); let err = call(&c, tool, args).await.expect_err("marked");
@@ -380,20 +426,42 @@ async fn a_refused_set_w9_never_marks_its_file() {
} }
/// Every way a file enters the books takes the text `name` holding /// Every way a file enters the books takes the text `name` holding
/// `text`, and the journal shows it. /// `text`, and the journal shows it. The same bytes filed already are
async fn accepted_everywhere(c: &Client, name: &str, text: &str) { /// withdrawn first (books.begin_upload would name them instead of
let (content, sha) = inline(text.as_bytes()); /// taking them again), so each tool takes them itself.
for (tool, args) in [ async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) {
( let token = b.served.home.token(common::BOOK);
"books.attach_receipt", for (tool, mut args) in [
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}), ("books.attach_receipt", json!({"mime_type": "text/csv"})),
),
( (
"books.file_document", "books.file_document",
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv", json!({"category": "import_source", "mime_type": "text/csv"}),
"content_base64": content, "sha256": sha}),
), ),
] { ] {
let begun = ok(
c,
"books.begin_upload",
common::upload_args(text.as_bytes(), name, "text/csv", "document"),
)
.await;
if let Some(stored) = begun["existing_attachment_id"].as_u64() {
ok(
c,
"books.withdraw_attachment",
json!({"attachment_id": stored, "reason": "filed again another way"}),
)
.await;
}
let upload = common::upload_on(
c,
&b.served.home.run(),
common::BOOK,
&token,
text.as_bytes(),
(name, "text/csv", "document"),
)
.await;
args["upload_id"] = json!(upload);
let out = ok(c, tool, args).await; let out = ok(c, tool, args).await;
assert!(out["attachment_id"].is_u64(), "{tool}: {out}"); assert!(out["attachment_id"].is_u64(), "{tool}: {out}");
} }
@@ -453,7 +521,7 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap(); std::fs::write(&path, format!("payee,tin\n{printed}\n")).unwrap();
refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await; refused_everywhere(&c, &b, &path, &format!("payees-{n}.csv"), TIN_PRINTED).await;
} }
accepted_everywhere(&c, "bank.csv", BANK_CSV).await; accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
drop(c); drop(c);
// The vault locked (its key gone): the printed shapes are still // The vault locked (its key gone): the printed shapes are still
@@ -469,11 +537,12 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await; refused_everywhere(&c, &b, &path, "members-1.csv", TIN_PRINTED).await;
accepted_everywhere( accepted_everywhere(
&c, &c,
&b,
"bank-2.csv", "bank-2.csv",
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"), &format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
) )
.await; .await;
accepted_everywhere(&c, "members-0.csv", "member,tin\nKat,830835531\n").await; accepted_everywhere(&c, &b, "members-0.csv", "member,tin\nKat,830835531\n").await;
} }
/// INVARIANT 3b through the tools: the company's own EIN, as its /// INVARIANT 3b through the tools: the company's own EIN, as its
@@ -494,6 +563,7 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
.unwrap(); .unwrap();
accepted_everywhere( accepted_everywhere(
&c, &c,
&b,
"plan.md", "plan.md",
"# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n", "# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n",
) )
@@ -617,8 +687,8 @@ async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
}; };
let open = probes(&c).await; let open = probes(&c).await;
refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).await; refused_everywhere(&c, &b, &tin, "tin.csv", TIN_HELD).await;
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await; accepted_everywhere(&c, &b, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
accepted_everywhere(&c, "bank.csv", BANK_CSV).await; accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
drop(c); drop(c);
// The vault locked: the same refusals, word for word. // The vault locked: the same refusals, word for word.
+2 -2
View File
@@ -66,8 +66,8 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Notify, mpsc, watch}; use tokio::sync::{Notify, mpsc, watch};
use tokio::time::Instant; use tokio::time::Instant;
/// The longest message the agent may send (a file bigger than a call /// The longest message the agent may send (a file never comes in one: it
/// takes inline goes by an upload). /// goes by an upload, D-46).
const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY; const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY;
/// The bridge's own request ids upstream start so. /// The bridge's own request ids upstream start so.
const OWN_ID: &str = "wanderbooks-bridge-"; const OWN_ID: &str = "wanderbooks-bridge-";
+4 -5
View File
@@ -1,8 +1,7 @@
# Files in and out # Files in and out
- A file comes in one of two ways, named in the tool that files it (books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv, books.import_chart_csv): - A file never passes through you, in either direction: no tool takes a file's bytes or text, and none gives them back.
- an upload, for any file up to 25 MiB: books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it. - A file comes in by upload alone, named by its upload_id in the tool that files it (books.attach_receipt, books.file_document, books.set_w9 w9_upload_id, books.import_bank_csv, books.import_chart_csv): books.begin_upload with the file's sha256 (`sha256sum`), size (up to 25 MiB), type, name and purpose; run the command it returns (`wanderbooks upload`) with the file's path; then give the upload_id. An upload lasts 15 minutes, is used once, and only by the token that began it.
- inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught.
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy. - If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself. - A file goes out by download alone. Files the books make (books.export in every format, books.asset_labels, books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name. - Receipts and documents come back out through books.get_receipt view bytes, handed over the same way: run its download command to save a copy in a folder you name.
@@ -10,6 +10,6 @@
- Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both. - Name whose each card, loan and advance is: books.update_party creditor_of, the issuer for its card, the lender for its loan, each member for what the members advanced. Then a fee on the card, interest on the loan, or a distribution credited to what the members are owed never reads as a payment to them; it counts when paid. A distribution declared to distributions payable counts when it is paid, too. Record a reimbursement and a distribution as two entries, and a member's cash either as a loan (through the members' financing, tagged with the payee it paid) or as capital, not both.
- Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it. - Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it.
- Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. - Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line.
- Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN). - Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9; never books.file_document or books.attach_receipt: the form shows the TIN).
- A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. - A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed.
- books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history. - books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.
+2
View File
@@ -96,6 +96,8 @@ pub(crate) const INSUFFICIENT_SCOPE: &str = "insufficient_scope";
pub(crate) const CLOSING: &str = "closing"; pub(crate) const CLOSING: &str = "closing";
/// The server had stopped. /// The server had stopped.
pub(crate) const STOPPED: &str = "stopped"; pub(crate) const STOPPED: &str = "stopped";
/// A file's bytes or text came as an argument (D-46).
pub(crate) const INLINE_FILE: &str = "inline_file";
/// What a refused call is told when its record couldn't be kept, or a /// What a refused call is told when its record couldn't be kept, or a
/// call that ran is told instead of its answer (INVARIANT 3). /// call that ran is told instead of its answer (INVARIANT 3).
+3 -3
View File
@@ -1,9 +1,9 @@
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way //! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]), //! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
//! every book of the data root on the root's local sockets, which agents //! every book of the data root on the root's local sockets, which agents
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as //! reach through the bridge `wanderbooks mcp --book ID`. Files come in only
//! tool input (inline, at most 256 KiB) or as uploads, and the files the //! as uploads and go out only as downloads: never through an agent's
//! books make go out as downloads. //! context (D-46).
pub mod access; pub mod access;
pub mod admin; pub mod admin;
+2 -2
View File
@@ -86,8 +86,8 @@ pub const SERVER_LOCK: &str = "server.lock";
/// What the server prints on stdout when it is ready (INVARIANT 5). /// What the server prints on stdout when it is ready (INVARIANT 5).
pub const READY: &str = "ready"; pub const READY: &str = "ready";
/// How large an MCP request may be (plan 3.2): files of any size come /// How large an MCP request may be (plan 3.2): files come only through
/// through uploads (`/b/{book}/u/{upload}`); inline, at most 256 KiB. /// uploads (`/b/{book}/u/{upload}`), never in a request (D-46).
pub const MAX_BODY: usize = 1024 * 1024; pub const MAX_BODY: usize = 1024 * 1024;
/// How long the admin socket waits for a request to arrive. /// How long the admin socket waits for a request to arrive.
+94 -96
View File
@@ -317,12 +317,8 @@ fn named(
mime_type: Option<String>, mime_type: Option<String>,
file: &files::TakenFile, file: &files::TakenFile,
) -> Result<(String, String), String> { ) -> Result<(String, String), String> {
let name = file_name let name = file_name.unwrap_or_else(|| file.file_name.clone());
.or_else(|| file.file_name.clone()) let mime = mime_type.unwrap_or_else(|| file.mime.clone());
.ok_or("file_name is needed")?;
let mime = mime_type
.or_else(|| file.mime.clone())
.ok_or("mime_type is needed")?;
if let Some(why) = ledger_store::uploads::file_name_problem(&name) if let Some(why) = ledger_store::uploads::file_name_problem(&name)
.or_else(|| ledger_store::uploads::mime_problem(&mime)) .or_else(|| ledger_store::uploads::mime_problem(&mime))
{ {
@@ -939,35 +935,30 @@ impl BooksServer {
.collect() .collect()
} }
/// The signed W-9 a call gives (an upload, or inline), for the vault: /// The signed W-9 a call gives (its upload, `upload_id`), for the vault:
/// a PDF, PNG, or JPEG by its own first bytes, whatever it is called. /// a PDF, PNG, or JPEG by its own first bytes, whatever it is called.
fn w9_file( fn w9_file(
&self, &self,
p: &SetW9Params, p: &SetW9Params,
upload_id: &str,
) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> { ) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> {
let file = self.take_file(files::Given { let file = self.take_file(files::Given {
upload_id: p.w9_upload_id.as_deref(), upload_id,
content_base64: p.w9_content_base64.as_deref(),
sha256: p.w9_sha256.as_deref(),
purposes: files::W9, purposes: files::W9,
})?; })?;
// Not recorded here: set_w9 records it once the call can only // Not recorded here: set_w9 records it once the call can only
// succeed, just before the vault write (tin_files.rs INVARIANT 1), // succeed, just before the vault write (tin_files.rs INVARIANT 1),
// so a refused call never marks an ordinary file. // so a refused call never marks an ordinary file.
let (mime_type, ext) = if file.bytes.starts_with(b"%PDF-") { let mime_type = if file.bytes.starts_with(b"%PDF-") {
("application/pdf", "pdf") "application/pdf"
} else if file.bytes.starts_with(b"\x89PNG\r\n\x1a\n") { } else if file.bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
("image/png", "png") "image/png"
} else if file.bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { } else if file.bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
("image/jpeg", "jpg") "image/jpeg"
} else { } else {
return Err("the W-9 is a PDF, PNG, or JPEG".into()); return Err("the W-9 is a PDF, PNG, or JPEG".into());
}; };
let file_name = p let file_name = p.w9_file_name.clone().unwrap_or(file.file_name);
.w9_file_name
.clone()
.or(file.file_name)
.unwrap_or_else(|| format!("w9.{ext}"));
if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) { if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) {
return Err(why); return Err(why);
} }
@@ -981,29 +972,19 @@ impl BooksServer {
)) ))
} }
/// A CSV's text, given inline or as an upload (books.begin_upload), /// A CSV's text, from its upload (books.begin_upload purpose import),
/// and the upload's hold. /// and the upload's hold.
fn csv_given( fn csv_given(
&self, &self,
text: Option<&str>, upload_id: &str,
upload_id: Option<&str>,
) -> Result<(String, Option<ledger_store::uploads::Claim>), String> { ) -> Result<(String, Option<ledger_store::uploads::Claim>), String> {
match (text, upload_id) { let file = self.take_file(files::Given {
(Some(_), Some(_)) => Err("give csv_text or upload_id, not both".into()), upload_id,
(Some(t), None) => Ok((t.to_string(), None)), purposes: files::IMPORTED,
(None, Some(id)) => { })?;
let file = self.take_file(files::Given { let text =
upload_id: Some(id), String::from_utf8(file.bytes).map_err(|_| "the CSV isn't UTF-8 text".to_string())?;
content_base64: None, Ok((text, file.claim))
sha256: None,
purposes: files::IMPORTED,
})?;
let text = String::from_utf8(file.bytes)
.map_err(|_| "the CSV isn't UTF-8 text".to_string())?;
Ok((text, file.claim))
}
(None, None) => Err("give the CSV: csv_text, or an upload_id".into()),
}
} }
/// Record a file read or written with full TINs (tin_files.rs /// Record a file read or written with full TINs (tin_files.rs
@@ -3517,7 +3498,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.attach_receipt", name = "books.attach_receipt",
title = "Attach a receipt", title = "Attach a receipt",
description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -3538,9 +3519,7 @@ impl BooksServer {
let link_to = check_link_to(&store, p.entry_id, &p.link_to)?; let link_to = check_link_to(&store, p.entry_id, &p.link_to)?;
// Taken once everything else is known good. // Taken once everything else is known good.
let file = self.take_file(files::Given { let file = self.take_file(files::Given {
upload_id: p.upload_id.as_deref(), upload_id: &p.upload_id,
content_base64: p.content_base64.as_deref(),
sha256: p.sha256.as_deref(),
purposes: files::FILED, purposes: files::FILED,
})?; })?;
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?; let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
@@ -3572,7 +3551,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.file_document", name = "books.file_document",
title = "File a document", title = "File a document",
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.", description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id, from books.begin_upload and `wanderbooks upload` (a file never passes through you). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -3597,9 +3576,7 @@ impl BooksServer {
let mut store = self.lock()?; let mut store = self.lock()?;
let link_to = check_link_to(&store, None, &p.link_to)?; let link_to = check_link_to(&store, None, &p.link_to)?;
let file = self.take_file(files::Given { let file = self.take_file(files::Given {
upload_id: p.upload_id.as_deref(), upload_id: &p.upload_id,
content_base64: p.content_base64.as_deref(),
sha256: p.sha256.as_deref(),
purposes: files::FILED, purposes: files::FILED,
})?; })?;
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?; let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
@@ -3890,7 +3867,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.get_receipt", name = "books.get_receipt",
title = "Get a receipt", title = "Get a receipt",
description = "A receipt or document: what it is and everything it is linked to. view bytes adds its bytes, base64 and hash-verified, for a file of at most 256 KiB; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name.", description = "A receipt or document: what it is and everything it is linked to. view bytes adds the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.",
annotations(read_only_hint = true, open_world_hint = false) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn get_receipt( async fn get_receipt(
@@ -3910,33 +3887,17 @@ impl BooksServer {
}; };
let store = self.lock()?; let store = self.lock()?;
let meta = find_attachment(&store, p.attachment_id)?; let meta = find_attachment(&store, p.attachment_id)?;
let book = store // Handed over, read hash-verified (D-46): never its bytes here.
.data_dir() let file = if bytes_wanted {
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let fits = meta.size_bytes as usize <= files::INLINE_MAX;
let content_base64 = if bytes_wanted && fits {
let bytes = store let bytes = store
.attachment_bytes(p.attachment_id) .attachment_bytes(p.attachment_id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Some(base64::Engine::encode( Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?)
&base64::engine::general_purpose::STANDARD,
&bytes,
))
} else { } else {
None None
}; };
// Too large to come back here: the command that saves a copy.
let download = (bytes_wanted && !fits).then(|| {
format!(
"wanderbooks download --book {book}{} {} --to <a folder>",
files::token_flag(),
meta.id
)
});
Ok(Json(GetReceiptOut { Ok(Json(GetReceiptOut {
download, file,
links: links_out(&store, meta.id)?, links: links_out(&store, meta.id)?,
description: meta.description.clone(), description: meta.description.clone(),
attachment_id: meta.id, attachment_id: meta.id,
@@ -3946,7 +3907,6 @@ impl BooksServer {
file_name: meta.file_name, file_name: meta.file_name,
mime_type: meta.mime_type, mime_type: meta.mime_type,
size_bytes: meta.size_bytes, size_bytes: meta.size_bytes,
content_base64,
})) }))
} }
@@ -4512,7 +4472,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.import_bank_csv", name = "books.import_bank_csv",
title = "Import a bank CSV as drafts", title = "Import a bank CSV as drafts",
description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as csv_text, or upload it (books.begin_upload purpose import) and give upload_id. Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).", description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -4534,7 +4494,7 @@ impl BooksServer {
)); ));
} }
}; };
let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?; let (csv, claim) = self.csv_given(&p.upload_id)?;
let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?; let txns = ledger_import::parse_bank_csv_with(&csv, order).map_err(|e| e.to_string())?;
let mut store = self.lock()?; let mut store = self.lock()?;
let bank = store let bank = store
@@ -5683,14 +5643,13 @@ impl BooksServer {
#[tool( #[tool(
name = "books.asset_labels", name = "books.asset_labels",
title = "Asset label sheet", title = "Asset label sheet",
description = "A printable sheet of asset labels (PDF, base64): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print at 100% scale.", description = "A printable sheet of asset labels (a PDF, handed over: run its download to save it): each label shows the company, the tag, a QR code of the tag, the description, and the serial number. Avery 5160/8160 (US letter, 30 labels of 1\" x 2-5/8\"); skip reuses a partly used sheet. Omit assets for every active asset with a tag. Save the file and tell the human to print it at 100% scale.",
annotations(read_only_hint = true, open_world_hint = false) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn asset_labels( async fn asset_labels(
&self, &self,
params: Parameters<AssetLabelsParams>, params: Parameters<AssetLabelsParams>,
) -> Result<Json<AssetLabelsOut>, String> { ) -> Result<Json<AssetLabelsOut>, String> {
use base64::Engine as _;
let p = params.0; let p = params.0;
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); let ledger = store.ledger();
@@ -5729,9 +5688,14 @@ impl BooksServer {
p.skip.unwrap_or(0) as usize, p.skip.unwrap_or(0) as usize,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let file = self.hand_over(
&book_name(&store),
&format!("asset-labels-{}.pdf", today()),
"application/pdf",
pdf,
)?;
Ok(Json(AssetLabelsOut { Ok(Json(AssetLabelsOut {
file_name: format!("asset-labels-{}.pdf", today()), file,
content_base64: base64::engine::general_purpose::STANDARD.encode(pdf),
tags: labels.into_iter().map(|l| l.tag).collect(), tags: labels.into_iter().map(|l| l.tag).collect(),
})) }))
} }
@@ -5866,7 +5830,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.import_chart_csv", name = "books.import_chart_csv",
title = "Import a chart of accounts", title = "Import a chart of accounts",
description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional): csv_text, or upload_id (books.begin_upload purpose import). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.", description = "Import a chart of accounts from CSV (header: code,name,type,parent_code,tax_category; the last is optional), as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Types: asset, liability, equity, revenue, expense. Parents must come before children. Fails on the first bad row; nothing is half-imported. A revenue or expense row without a tax_category is listed in warnings, or, under policy.require_tax_category = refuse, refuses the file.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = false, destructive_hint = false,
@@ -5878,7 +5842,7 @@ impl BooksServer {
params: Parameters<ImportChartCsvParams>, params: Parameters<ImportChartCsvParams>,
) -> Result<Json<ImportChartCsvOut>, String> { ) -> Result<Json<ImportChartCsvOut>, String> {
let p = params.0; let p = params.0;
let (csv, claim) = self.csv_given(p.csv_text.as_deref(), p.upload_id.as_deref())?; let (csv, claim) = self.csv_given(&p.upload_id)?;
let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?; let rows = ledger_import::parse_chart_csv(&csv).map_err(|e| e.to_string())?;
let mut store = self.lock()?; let mut store = self.lock()?;
let parse_type = |t: &str| match t { let parse_type = |t: &str| match t {
@@ -5996,7 +5960,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.export", name = "books.export",
title = "Export a report", title = "Export a report",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).", description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register and iif (omitted: today).",
annotations(read_only_hint = true, open_world_hint = false) annotations(read_only_hint = true, open_world_hint = false)
)] )]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> { async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -6025,11 +5989,11 @@ impl BooksServer {
let as_of = self.as_of(p.as_of.as_deref())?; let as_of = self.as_of(p.as_of.as_deref())?;
let iif = self.export_iif(as_of, &p.cash_accounts)?; let iif = self.export_iif(as_of, &p.cash_accounts)?;
let warnings = iif.warnings(); let warnings = iif.warnings();
let file = self.hand_over_export(&report, &format, iif.text.into_bytes())?;
return Ok(Json(ExportOut { return Ok(Json(ExportOut {
format, format,
report, report,
content: iif.text, file,
content_encoding: None,
warnings, warnings,
})); }));
} }
@@ -6039,11 +6003,11 @@ impl BooksServer {
} else { } else {
self.export_pdf(&report, &p)? self.export_pdf(&report, &p)?
}; };
let file = self.hand_over_export(&report, &format, bytes)?;
return Ok(Json(ExportOut { return Ok(Json(ExportOut {
format, format,
report, report,
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes), file,
content_encoding: Some("base64".to_string()),
warnings: Vec::new(), warnings: Vec::new(),
})); }));
} }
@@ -6066,15 +6030,40 @@ impl BooksServer {
)); ));
} }
}; };
let file = self.hand_over_export(&report, &format, content.into_bytes())?;
Ok(Json(ExportOut { Ok(Json(ExportOut {
format, format,
report, report,
content, file,
content_encoding: None,
warnings: Vec::new(), warnings: Vec::new(),
})) }))
} }
/// An export's file, handed over (files.rs INVARIANT 6, D-46): named
/// for its report and today, typed by its format.
fn hand_over_export(
&self,
report: &str,
format: &str,
bytes: Vec<u8>,
) -> Result<HandedFile, String> {
let mime = match format {
"csv" => "text/csv",
"json" => "application/json",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pdf" => "application/pdf",
// IIF is tab-separated ASCII text.
_ => "text/plain",
};
let book = book_name(&*self.lock()?);
self.hand_over(
&book,
&format!("{report}-{}.{format}", today()),
mime,
bytes,
)
}
/// Lay out a financial statement for rendering, from export params. /// Lay out a financial statement for rendering, from export params.
fn layout_statement( fn layout_statement(
&self, &self,
@@ -7555,7 +7544,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.set_w9", name = "books.set_w9",
title = "Record W-9 facts", title = "Record W-9 facts",
description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload), or w9_content_base64 with w9_sha256 when at most 256 KiB, and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = true, destructive_hint = true,
@@ -7564,12 +7553,9 @@ impl BooksServer {
)] )]
async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> { async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> {
let p = params.0; let p = params.0;
let w9_given = p.w9_upload_id.is_some() || p.w9_content_base64.is_some(); let w9_given = p.w9_upload_id.is_some();
if p.w9_file_name.is_some() && !w9_given { if p.w9_file_name.is_some() && !w9_given {
return Err( return Err("w9_file_name names the W-9 given with it (w9_upload_id)".into());
"w9_file_name names the W-9 given with it (w9_upload_id or w9_content_base64)"
.into(),
);
} }
let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given; let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given;
let tin = match (&p.tin, &p.tin_type) { let tin = match (&p.tin, &p.tin_type) {
@@ -7624,8 +7610,8 @@ impl BooksServer {
vault_record: None, vault_record: None,
}) })
}; };
let (w9_file, mut w9_claim) = if w9_given { let (w9_file, mut w9_claim) = if let Some(upload_id) = &p.w9_upload_id {
let (file, claim) = self.w9_file(&p)?; let (file, claim) = self.w9_file(&p, upload_id)?;
(Some(file), claim) (Some(file), claim)
} else { } else {
(None, None) (None, None)
@@ -9879,7 +9865,14 @@ impl ServerHandler for BooksServer {
.join(", ") .join(", ")
))), ))),
)), )),
_ => None, // A file's bytes or text given as an argument: refused as an
// unknown argument, before it runs (files.rs INVARIANT 5).
_ => files::inline_refusal(&request.name, request.arguments.as_ref()).map(|why| {
(
access::INLINE_FILE,
Err(rmcp::ErrorData::invalid_params(why, None)),
)
}),
}; };
if let Some((code, answer)) = refusal { if let Some((code, answer)) = refusal {
call.digested(refused_digest(request.arguments.clone()).await); call.digested(refused_digest(request.arguments.clone()).await);
@@ -10271,11 +10264,11 @@ mod tests {
) )
} }
/// An upload's limit, 25 MiB exactly; inline, 256 KiB (plan 2.1, 4.7). /// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline
/// (D-46).
#[test] #[test]
fn the_file_limits_are_the_plans() { fn the_file_limits_are_the_plans() {
assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400); assert_eq!(ledger_store::uploads::MAX_UPLOAD, 26_214_400);
assert_eq!(files::INLINE_MAX, 262_144);
// Plan 4.7: an upload lasts 15 minutes; 16 under way per token. // Plan 4.7: an upload lasts 15 minutes; 16 under way per token.
assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900); assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900);
assert_eq!(ledger_store::uploads::MAX_LIVE, 16); assert_eq!(ledger_store::uploads::MAX_LIVE, 16);
@@ -10920,7 +10913,7 @@ mod tests {
} }
/// No tool takes a path on the server's computer (plan 4.7, D-28, /// No tool takes a path on the server's computer (plan 4.7, D-28,
/// D-29): files come in by upload or inline, and go out as exports. /// D-29, D-46): files come in by upload, and go out as exports.
#[test] #[test]
fn no_tool_takes_a_path() { fn no_tool_takes_a_path() {
fn names(v: &serde_json::Value, out: &mut Vec<String>) { fn names(v: &serde_json::Value, out: &mut Vec<String>) {
@@ -11001,7 +10994,7 @@ mod tests {
"read the file", "read the file",
"open the file", "open the file",
"cat ", "cat ",
// Gone in P2 slice 5: files come by upload or inline. // Gone in P2 slice 5: files come by upload.
"import_local_file", "import_local_file",
"w9_file_path", "w9_file_path",
"import folder", "import folder",
@@ -11009,6 +11002,11 @@ mod tests {
"put_url", "put_url",
"human_url", "human_url",
"fl-0001", "fl-0001",
// Gone with D-46: a file never passes through the agent.
"content_base64",
"csv_text",
"base64",
"256 kib",
]; ];
// Whole words only: "digit" and "committee" are fine. // Whole words only: "digit" and "committee" are fine.
const NEVER_WORDS: &[&str] = &["git", "commit"]; const NEVER_WORDS: &[&str] = &["git", "commit"];
+110 -82
View File
@@ -1,19 +1,20 @@
//! Files in (plan 4.7, P2 slice 5, D-28): `books.begin_upload`, and the //! Files in and out (plan 4.7, P2 slice 5, D-28, D-29, D-46):
//! bytes a tool is given, as an upload or inline. //! `books.begin_upload`, the upload a tool is given, and the files the
//! books hand over.
//! //!
//! ```text //! ```text
//! GOAL: an agent files a document of any size up to the limit without //! GOAL: an agent files a document of any size up to the limit, and takes
//! its bytes passing through its context, and a small one inline, //! away any file the books make, without the file's bytes passing
//! both exactly as the agent declared them. //! through its context, in either direction (D-46).
//! SOURCE: plan 4.7 (begin_upload; "content_base64 (≤256 KiB + required //! SOURCE: plan 4.7 (begin_upload), D-28 (the agent does it all), D-29
//! sha256)"; guard base64_corrupt), 2.1 ("256 KiB with a mandatory //! (files the books make are handed over by `wanderbooks
//! matching sha256"), D-28 (the agent does it all). //! download`), D-46 ("files never pass through an agent's context,
//! in either direction": uploads only, no inline bytes).
//! INVARIANTS: //! INVARIANTS:
//! 1. A tool's bytes are an upload made by the same token, or inline //! 1. A tool's bytes are an upload made by the same token, named by its
//! base64 of at most INLINE_MAX bytes whose SHA-256 is the one given; //! upload_id; nothing else. An upload is used up only by the call that
//! never both, never neither. An upload is used up only by the call //! files it (TakenFile::filed); a call that fails leaves it for the
//! that files it (TakenFile::filed); a call that fails leaves it for //! next.
//! the next.
//! 2. begin_upload makes nothing for a receipt or document whose bytes //! 2. begin_upload makes nothing for a receipt or document whose bytes
//! are stored already: it names the attachment (not withdrawn) that //! are stored already: it names the attachment (not withdrawn) that
//! holds them. //! holds them.
@@ -23,8 +24,9 @@
//! 4. An upload is filed only by a tool of the purpose it was begun for //! 4. An upload is filed only by a tool of the purpose it was begun for
//! (a receipt or document, a CSV import, a W-9); another tool's //! (a receipt or document, a CSV import, a W-9); another tool's
//! refusal leaves it for the right one. //! refusal leaves it for the right one.
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused //! 5. A call that gives a file's bytes or text as an argument (one of
//! before it is decoded. //! INLINE_ARGS) is refused before it runs, as an unknown argument
//! whose words name books.begin_upload (D-46).
//! 6. A file the books make for an agent (D-29) is handed over as an //! 6. A file the books make for an agent (D-29) is handed over as an
//! export the caller's token fetches with `wanderbooks download`; //! export the caller's token fetches with `wanderbooks download`;
//! never inline, never by a path. //! never inline, never by a path.
@@ -34,20 +36,41 @@ use super::*;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// The most bytes given inline (plan 2.1: 256 KiB). /// The arguments that once carried a file's bytes or text inline, before
pub(crate) const INLINE_MAX: usize = 256 * 1024; /// D-46 (INVARIANT 5): refused whatever tool they come to.
pub(crate) const INLINE_ARGS: [&str; 5] = [
"content_base64",
"sha256",
"csv_text",
"w9_content_base64",
"w9_sha256",
];
/// Why `args` can't be taken by `tool` (INVARIANT 5): an argument that
/// carries a file's bytes or text. books.begin_upload's own sha256 is the
/// file's hash, not its bytes.
pub(crate) fn inline_refusal(
tool: &str,
args: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<String> {
let given = args?.keys().find(|k| INLINE_ARGS.contains(&k.as_str()))?;
if tool == "books.begin_upload" && given == "sha256" {
return None;
}
Some(format!(
"unknown argument {given:?}: a file never passes through you (D-46). Send it with books.begin_upload and the command it returns (`wanderbooks upload`), then name the upload_id here"
))
}
/// Why a call with no token begins, takes or is handed no file /// Why a call with no token begins, takes or is handed no file
/// (INVARIANT 3). /// (INVARIANT 3).
const NO_CALLER: &str = const NO_CALLER: &str =
"this call came without a token for these books: a file is kept only for the token that asked"; "this call came without a token for these books: a file is kept only for the token that asked";
/// What the bytes of a file a tool files came as (INVARIANT 1), and the /// The upload a tool files (INVARIANT 1), and the upload purposes the
/// upload purposes the tool takes (INVARIANT 4). /// tool takes (INVARIANT 4).
pub(crate) struct Given<'a> { pub(crate) struct Given<'a> {
pub upload_id: Option<&'a str>, pub upload_id: &'a str,
pub content_base64: Option<&'a str>,
pub sha256: Option<&'a str>,
pub purposes: &'a [&'a str], pub purposes: &'a [&'a str],
} }
@@ -61,8 +84,8 @@ pub(crate) const W9: &[&str] = &["w9"];
/// The bytes, and the name and type an upload declared. /// The bytes, and the name and type an upload declared.
pub(crate) struct TakenFile { pub(crate) struct TakenFile {
pub bytes: Vec<u8>, pub bytes: Vec<u8>,
pub file_name: Option<String>, pub file_name: String,
pub mime: Option<String>, pub mime: String,
/// The upload's hold, if it came as one (INVARIANT 1). /// The upload's hold, if it came as one (INVARIANT 1).
pub claim: Option<ledger_store::uploads::Claim>, pub claim: Option<ledger_store::uploads::Claim>,
} }
@@ -196,65 +219,32 @@ impl BooksServer {
CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone())) CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone()))
} }
/// The bytes a tool was given (INVARIANT 1). /// The bytes of the upload a tool was given (INVARIANT 1).
pub(crate) fn take_file(&self, given: Given<'_>) -> Result<TakenFile, String> { pub(crate) fn take_file(&self, given: Given<'_>) -> Result<TakenFile, String> {
match (given.upload_id, given.content_base64) { // INVARIANT 3.
(Some(_), Some(_)) => Err("give upload_id or content_base64, not both".into()), let token = self.caller_token().ok_or(NO_CALLER)?;
(None, None) => Err( let got = self
"the file's bytes are missing: upload it (books.begin_upload), or give content_base64 with its sha256 (at most 256 KiB)" .uploads
.into(), .take(
), given.upload_id.trim(),
(Some(id), None) => { &token,
// INVARIANT 3. chrono::Utc::now().timestamp(),
let token = self.caller_token().ok_or(NO_CALLER)?; )
let got = self .map_err(|e| e.to_string())?;
.uploads // INVARIANT 4: dropped, the claim gives it back.
.take(id.trim(), &token, chrono::Utc::now().timestamp()) if !given.purposes.contains(&got.grant.purpose.as_str()) {
.map_err(|e| e.to_string())?; return Err(format!(
// INVARIANT 4: dropped, the claim gives it back. "that upload was begun for {}: this tool takes one begun for {}",
if !given.purposes.contains(&got.grant.purpose.as_str()) { got.grant.purpose,
return Err(format!( given.purposes.join(" or ")
"that upload was begun for {}: this tool takes one begun for {}", ));
got.grant.purpose,
given.purposes.join(" or ")
));
}
Ok(TakenFile {
bytes: got.bytes,
file_name: Some(got.grant.file_name),
mime: Some(got.grant.mime),
claim: Some(got.claim),
})
}
(None, Some(b64)) => {
let Some(sha) = given.sha256 else {
return Err("content_base64 comes with its sha256 (the file's SHA-256, hex), so a byte lost on the way is caught".into());
};
// INVARIANT 5.
if b64.len() > INLINE_MAX.div_ceil(3) * 4 {
return Err(format!(
"inline is at most {INLINE_MAX} bytes: upload it with books.begin_upload"
));
}
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
.map_err(|_| "content_base64 is not valid base64 (guard base64_corrupt): send the file with books.begin_upload".to_string())?;
if bytes.len() > INLINE_MAX {
return Err(format!(
"the file is {} bytes; inline is at most {INLINE_MAX}: upload it with books.begin_upload",
bytes.len()
));
}
if ledger_store::journal::sha256_hex(&bytes) != sha.trim().to_ascii_lowercase() {
return Err("content_base64 doesn't have the sha256 given: some of it was lost on the way (guard base64_corrupt). Send the file with books.begin_upload instead".into());
}
Ok(TakenFile {
bytes,
file_name: None,
mime: None,
claim: None,
})
}
} }
Ok(TakenFile {
bytes: got.bytes,
file_name: got.grant.file_name,
mime: got.grant.mime,
claim: Some(got.claim),
})
} }
} }
@@ -342,7 +332,45 @@ impl BooksServer {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{CALLER, plain_file_name, token_flag}; use super::{CALLER, INLINE_ARGS, inline_refusal, plain_file_name, token_flag};
/// INVARIANT 5: every argument that once carried a file inline is
/// refused, on any tool, naming itself and books.begin_upload; but
/// books.begin_upload's own sha256 (the file's hash), and the
/// arguments that name an upload.
#[test]
fn an_inline_file_argument_is_refused_on_every_tool() {
let args = |key: &str| {
let mut m = serde_json::Map::new();
m.insert(key.into(), serde_json::json!("JVBERi0xLjQ="));
m
};
for tool in [
"books.attach_receipt",
"books.file_document",
"books.set_w9",
"books.import_bank_csv",
"books.import_chart_csv",
"books.draft_entry",
] {
for key in INLINE_ARGS {
let why = inline_refusal(tool, Some(&args(key)))
.unwrap_or_else(|| panic!("{tool} took {key}"));
assert!(why.contains(&format!("{key:?}")), "{why}");
assert!(why.contains("books.begin_upload"), "{why}");
assert!(why.starts_with("unknown argument"), "{why}");
}
for key in ["upload_id", "w9_upload_id", "file_name", "mime_type"] {
assert_eq!(inline_refusal(tool, Some(&args(key))), None, "{tool} {key}");
}
}
assert_eq!(
inline_refusal("books.begin_upload", Some(&args("sha256"))),
None
);
assert!(inline_refusal("books.begin_upload", Some(&args("content_base64"))).is_some());
assert_eq!(inline_refusal("books.attach_receipt", None), None);
}
/// A name kept before names were checked is handed over cleaned, never /// A name kept before names were checked is handed over cleaned, never
/// refused (a W-9 sealed earlier, say). /// refused (a W-9 sealed earlier, say).
+50 -73
View File
@@ -1271,16 +1271,16 @@ pub struct CashFlowOut {
pub warnings: Vec<CashFlowWarningOut>, pub warnings: Vec<CashFlowWarningOut>,
} }
/// Parameters for `books.import_bank_csv`. /// Parameters for `books.import_bank_csv`. No other argument is taken: a
/// file's text never comes as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportBankCsvParams { pub struct ImportBankCsvParams {
/// The CSV text (QBO bank upload layout: Date,Description,Amount, or /// The CSV (QBO bank upload layout: Date,Description,Amount, or
/// Date,Description, money out, money in)… /// Date,Description, money out, money in): its upload, made with
#[serde(default)] /// books.begin_upload (purpose import) and `wanderbooks upload`. A
pub csv_text: Option<String>, /// file never passes through the agent (D-46).
/// …or an upload of it made with books.begin_upload (purpose import). pub upload_id: String,
#[serde(default)]
pub upload_id: Option<String>,
/// Account code for the bank account (the offset side of each entry). /// Account code for the bank account (the offset side of each entry).
pub bank_account_code: String, pub bank_account_code: String,
/// Account code for the default offset (e.g. an expense account). /// Account code for the default offset (e.g. an expense account).
@@ -1331,15 +1331,15 @@ pub struct ImportBankCsvOut {
pub source_linked: bool, pub source_linked: bool,
} }
/// Parameters for `books.import_chart_csv`. /// Parameters for `books.import_chart_csv`. No other argument is taken: a
/// file's text never comes as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ImportChartCsvParams { pub struct ImportChartCsvParams {
/// The CSV text: code,name,type,parent_code with a header row… /// The CSV (code,name,type,parent_code,tax_category with a header
#[serde(default)] /// row): its upload, made with books.begin_upload (purpose import) and
pub csv_text: Option<String>, /// `wanderbooks upload`. A file never passes through the agent (D-46).
/// …or an upload of it made with books.begin_upload (purpose import). pub upload_id: String,
#[serde(default)]
pub upload_id: Option<String>,
} }
/// Result of `books.import_chart_csv`. /// Result of `books.import_chart_csv`.
@@ -1679,8 +1679,10 @@ pub struct ReverseEntryOut {
// Receipts and documents // Receipts and documents
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Parameters for `books.attach_receipt`. /// Parameters for `books.attach_receipt`. No other argument is taken: a
/// file's bytes never come as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AttachReceiptParams { pub struct AttachReceiptParams {
/// Entry id to attach to. Omit to file unlinked, then link later with /// Entry id to attach to. Omit to file unlinked, then link later with
/// `books.link_receipt`. /// `books.link_receipt`.
@@ -1692,16 +1694,9 @@ pub struct AttachReceiptParams {
/// MIME type, e.g. "image/png" (an upload's own, if not given). /// MIME type, e.g. "image/png" (an upload's own, if not given).
#[serde(default)] #[serde(default)]
pub mime_type: Option<String>, pub mime_type: Option<String>,
/// The file's bytes: an upload made with books.begin_upload (its id)… /// The file: its upload, made with books.begin_upload and `wanderbooks
#[serde(default)] /// upload` (its id). A file never passes through the agent (D-46).
pub upload_id: Option<String>, pub upload_id: String,
/// …or, for a file of at most 256 KiB, its bytes base64-encoded, with
/// sha256.
#[serde(default)]
pub content_base64: Option<String>,
/// The SHA-256 of the bytes in content_base64, hex.
#[serde(default)]
pub sha256: Option<String>,
/// Optional description. /// Optional description.
pub description: Option<String>, pub description: Option<String>,
/// More subjects to link it to in the same call (each journaled as its /// More subjects to link it to in the same call (each journaled as its
@@ -1724,8 +1719,10 @@ pub struct AttachReceiptOut {
pub links: Vec<SubjectOut>, pub links: Vec<SubjectOut>,
} }
/// Parameters for `books.file_document`. /// Parameters for `books.file_document`. No other argument is taken: a
/// file's bytes never come as one (D-46).
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FileDocumentParams { pub struct FileDocumentParams {
/// One of: formation, legal, tax, accounting, statement, contract, /// One of: formation, legal, tax, accounting, statement, contract,
/// photo, warranty, import_source, other. /// photo, warranty, import_source, other.
@@ -1736,16 +1733,9 @@ pub struct FileDocumentParams {
/// MIME type (an upload's own, if not given). /// MIME type (an upload's own, if not given).
#[serde(default)] #[serde(default)]
pub mime_type: Option<String>, pub mime_type: Option<String>,
/// The file's bytes: an upload made with books.begin_upload (its id)… /// The file: its upload, made with books.begin_upload and `wanderbooks
#[serde(default)] /// upload` (its id). A file never passes through the agent (D-46).
pub upload_id: Option<String>, pub upload_id: String,
/// …or, for a file of at most 256 KiB, its bytes base64-encoded, with
/// sha256.
#[serde(default)]
pub content_base64: Option<String>,
/// The SHA-256 of the bytes in content_base64, hex.
#[serde(default)]
pub sha256: Option<String>,
/// Optional description. /// Optional description.
pub description: Option<String>, pub description: Option<String>,
/// Subjects to link it to in the same call (each journaled as its own /// Subjects to link it to in the same call (each journaled as its own
@@ -1860,15 +1850,14 @@ pub struct GetReceiptParams {
/// Attachment id. /// Attachment id.
pub attachment_id: u64, pub attachment_id: u64,
/// What to return: "meta" (default: what it is and what it's linked /// What to return: "meta" (default: what it is and what it's linked
/// to), or "bytes" (also its bytes, base64, for a file of at most /// to), or "bytes" (also the file, handed over: the command that
/// 256 KiB; a larger one comes with `wanderbooks download`). /// saves a copy, never its bytes; D-46).
#[serde(default)] #[serde(default)]
pub view: Option<String>, pub view: Option<String>,
} }
/// Result of `books.get_receipt`: what it is and what it's linked to, and /// Result of `books.get_receipt`: what it is and what it's linked to, and
/// with view `bytes`, its bytes (hash-verified on read) or the command that /// with view `bytes`, the file handed over (read hash-verified).
/// saves a copy.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct GetReceiptOut { pub struct GetReceiptOut {
/// Attachment id. /// Attachment id.
@@ -1889,14 +1878,10 @@ pub struct GetReceiptOut {
pub description: Option<String>, pub description: Option<String>,
/// Everything it is linked to (the entry it is filed on first). /// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>, pub links: Vec<SubjectOut>,
/// Its bytes, base64-encoded: with view `bytes`, for a file of at most /// With view `bytes`: the file, handed over: run its `download` to
/// 256 KiB. /// save a copy in a folder you name.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub content_base64: Option<String>, pub file: Option<HandedFile>,
/// With view `bytes`, for a file too large to come back here: the
/// command that saves a copy in a folder you name.
#[serde(skip_serializing_if = "Option::is_none")]
pub download: Option<String>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2140,8 +2125,8 @@ pub struct StaleRule {
/// Parameters for `books.export`. /// Parameters for `books.export`.
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
pub struct ExportParams { pub struct ExportParams {
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are /// "csv", "json", "xlsx", "pdf", or "iif": the file is handed over,
/// base64-encoded; PDF covers every report but general_ledger and /// never its bytes (D-46). PDF covers every report but general_ledger and
/// general_journal; IIF (plain ASCII text) is only valid with report /// general_journal; IIF (plain ASCII text) is only valid with report
/// "general_journal" (alias "journal"). /// "general_journal" (alias "journal").
pub format: String, pub format: String,
@@ -2203,12 +2188,9 @@ pub struct ExportOut {
pub format: String, pub format: String,
/// The requested report. /// The requested report.
pub report: String, pub report: String,
/// The exported content. Base64-encoded when `content_encoding` is /// The exported file, handed over: run its `download` to save it in a
/// "base64" (XLSX, PDF); otherwise raw text. /// folder you name. Never its bytes (D-46).
pub content: String, pub file: HandedFile,
/// "base64" for XLSX and PDF, otherwise absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub content_encoding: Option<String>,
/// IIF: what the file writes differently from the books to fit /// IIF: what the file writes differently from the books to fit
/// QuickBooks Desktop (plain ASCII; account names at most 31 /// QuickBooks Desktop (plain ASCII; account names at most 31
/// characters a level, numbers 7, memos 4,095; no two names or numbers /// characters a level, numbers 7, memos 4,095; no two names or numbers
@@ -2546,11 +2528,10 @@ pub struct AssetLabelsParams {
/// Result of `books.asset_labels`. /// Result of `books.asset_labels`.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct AssetLabelsOut { pub struct AssetLabelsOut {
/// A file name for the sheet. /// The label sheet, a PDF, handed over: run its `download` to save it.
pub file_name: String, /// Avery 5160/8160 (US letter, 30 labels of 1" x 2-5/8"); print at
/// The label sheet as PDF, base64. Avery 5160/8160 (US letter, 30 /// 100% scale, not "fit to page".
/// labels of 1" x 2-5/8"); print at 100% scale, not "fit to page". pub file: HandedFile,
pub content_base64: String,
/// The tags printed, in order. /// The tags printed, in order.
pub tags: Vec<String>, pub tags: Vec<String>,
} }
@@ -3752,8 +3733,10 @@ pub struct SetPartyRolesParams {
} }
/// Parameters for `books.set_w9`: what a W-9 said, never the number /// Parameters for `books.set_w9`: what a W-9 said, never the number
/// itself. Either `clear: true` alone, or every required fact. /// itself. Either `clear: true` alone, or every required fact. No other
/// argument is taken: the form's bytes never come as one (D-46).
#[derive(Debug, Clone, Deserialize, JsonSchema)] #[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetW9Params { pub struct SetW9Params {
/// The party: id or name. Needs a 1099 role (contractor or landlord). /// The party: id or name. Needs a 1099 role (contractor or landlord).
pub party: String, pub party: String,
@@ -3784,19 +3767,13 @@ pub struct SetW9Params {
/// The TIN's type: ssn, ein, or itin. Required with tin. /// The TIN's type: ssn, ein, or itin. Required with tin.
pub tin_type: Option<String>, pub tin_type: Option<String>,
/// The signed W-9 itself (a PDF, PNG, or JPEG): an upload made with /// The signed W-9 itself (a PDF, PNG, or JPEG): an upload made with
/// books.begin_upload. Stored only in the vault (it shows the full /// books.begin_upload purpose w9 and `wanderbooks upload` (its id; a
/// TIN), never with the receipts and documents. /// file never passes through the agent, D-46). Stored only in the
/// vault (it shows the full TIN), never with the receipts and
/// documents.
#[serde(default)] #[serde(default)]
pub w9_upload_id: Option<String>, pub w9_upload_id: Option<String>,
/// …or, for one of at most 256 KiB, its bytes base64-encoded, with /// The W-9 file's name (default: the upload's).
/// w9_sha256.
#[serde(default)]
pub w9_content_base64: Option<String>,
/// The SHA-256 of the bytes in w9_content_base64, hex.
#[serde(default)]
pub w9_sha256: Option<String>,
/// The W-9 file's name (default: the upload's, else w9.pdf, w9.png
/// or w9.jpg by its type).
#[serde(default)] #[serde(default)]
pub w9_file_name: Option<String>, pub w9_file_name: Option<String>,
} }
+15 -25
View File
@@ -122,18 +122,14 @@ pub fn asserted(text: &str) -> String {
/// books show is found among 10^5 guesses (review of slice 4). Masked: /// books show is found among 10^5 guesses (review of slice 4). Masked:
/// any value under a key naming a TIN; any key or text holding one; any /// any value under a key naming a TIN; any key or text holding one; any
/// number of eight digits or more (whatever its sign or fraction: a TIN /// number of eight digits or more (whatever its sign or fraction: a TIN
/// may start with a zero). A file's base64 bytes are hashed as they are: /// may start with a zero). No argument is a file's bytes (D-46: files
/// base64 never spells a TIN in the clear, and scanning megabytes of it /// come by upload), so every text is scanned.
/// costs more than it could protect.
pub fn digest(args: &Value) -> String { pub fn digest(args: &Value) -> String {
sha256_hex(canonical_json(&masked(args, false)).as_bytes()) sha256_hex(canonical_json(&masked(args)).as_bytes())
} }
/// Keys whose values are a file's bytes, base64 (see [`digest`]). /// `v` with every TIN masked (see [`digest`]).
const FILE_BYTES: [&str; 2] = ["content_base64", "w9_content_base64"]; fn masked(v: &Value) -> Value {
/// `v` with every TIN masked (see [`digest`]); `bytes`: a file's base64.
fn masked(v: &Value, bytes: bool) -> Value {
const MASK: &str = "[TIN]"; const MASK: &str = "[TIN]";
match v { match v {
Value::Object(map) => Value::Object( Value::Object(map) => Value::Object(
@@ -148,14 +144,13 @@ fn masked(v: &Value, bytes: bool) -> Value {
let v = if names_a_tin && !v.is_null() { let v = if names_a_tin && !v.is_null() {
Value::from(MASK) Value::from(MASK)
} else { } else {
masked(v, FILE_BYTES.contains(&k_lower.as_str())) masked(v)
}; };
(ledger_core::parties::redact_full_tins(k).into_owned(), v) (ledger_core::parties::redact_full_tins(k).into_owned(), v)
}) })
.collect(), .collect(),
), ),
Value::Array(items) => Value::Array(items.iter().map(|v| masked(v, false)).collect()), Value::Array(items) => Value::Array(items.iter().map(masked).collect()),
Value::String(_) if bytes => v.clone(),
Value::String(s) => Value::from(ledger_core::parties::redact_full_tins(s).into_owned()), Value::String(s) => Value::from(ledger_core::parties::redact_full_tins(s).into_owned()),
Value::Number(n) if n.as_f64().is_none_or(|f| f.abs() >= 1e7) => Value::from(MASK), Value::Number(n) if n.as_f64().is_none_or(|f| f.abs() >= 1e7) => Value::from(MASK),
other => other.clone(), other => other.clone(),
@@ -397,10 +392,10 @@ mod tests {
digest(&serde_json::json!({"entry_id": 1234567})), digest(&serde_json::json!({"entry_id": 1234567})),
digest(&serde_json::json!({"entry_id": 1234568})) digest(&serde_json::json!({"entry_id": 1234568}))
); );
// A file's bytes count as they are. // Text with no TIN in it counts as it is.
assert_ne!( assert_ne!(
digest(&serde_json::json!({"content_base64": "MTIzNDU2Nzg5"})), digest(&serde_json::json!({"upload_id": "up_MTIzNDU2Nzg5"})),
digest(&serde_json::json!({"content_base64": "OTg3NjU0MzIx"})) digest(&serde_json::json!({"upload_id": "up_OTg3NjU0MzIx"}))
); );
} }
@@ -442,21 +437,16 @@ mod tests {
} }
#[test] #[test]
fn a_files_bytes_are_hashed_as_they_are_even_where_they_spell_digits() { fn digits_inside_any_text_are_masked() {
// Base64 can hold a run of nine digits; it is still a file's bytes, // No argument is a file's bytes any more (D-46): a run of nine
// not a TIN, and two files differing there differ. // digits is masked wherever it is, under any key.
for key in FILE_BYTES { for key in ["memo", "description", "upload_id"] {
assert_ne!( assert_eq!(
digest(&serde_json::json!({ key: "QUJD123456789QUJD" })), digest(&serde_json::json!({ key: "QUJD123456789QUJD" })),
digest(&serde_json::json!({ key: "QUJD987814321QUJD" })), digest(&serde_json::json!({ key: "QUJD987814321QUJD" })),
"{key}" "{key}"
); );
} }
// The same text anywhere else is masked.
assert_eq!(
digest(&serde_json::json!({"memo": "QUJD123456789QUJD"})),
digest(&serde_json::json!({"memo": "QUJD987814321QUJD"}))
);
} }
#[test] #[test]
+10 -7
View File
@@ -1,7 +1,7 @@
//! Files on their way out (plan 4.7, D-29): a file the server makes for an //! Files on their way out (plan 4.7, D-29, D-46): a file the server hands
//! agent (the CPA package, the 1099 worksheet, a W-9) is kept here a //! an agent (the CPA package, the 1099 worksheet, a W-9, a report export,
//! while, for the token that asked for it, until `wanderbooks download` //! a label sheet, a receipt's copy) is kept here a while, for the token
//! fetches it. //! that asked for it, until `wanderbooks download` fetches it.
//! //!
//! ```text //! ```text
//! GOAL: an agent hands the human a file of any size the books make, //! GOAL: an agent hands the human a file of any size the books make,
@@ -9,7 +9,8 @@
//! path on the server. //! path on the server.
//! SOURCE: plan 4.7 (`download_link`: "valid 10 minutes, access-logged"); //! SOURCE: plan 4.7 (`download_link`: "valid 10 minutes, access-logged");
//! D-29 ("their files are made by the server and handed over by a //! D-29 ("their files are made by the server and handed over by a
//! command, `wanderbooks download`"). //! command, `wanderbooks download`"); D-46 ("files never pass
//! through an agent's context, in either direction").
//! INVARIANTS: //! INVARIANTS:
//! 1. An export is kept for one token, under an id of no digits (the //! 1. An export is kept for one token, under an id of no digits (the
//! access log masks what could be a TIN), and lasts EXPORT_TTL; only //! access log masks what could be a TIN), and lasts EXPORT_TTL; only
@@ -32,8 +33,10 @@ use std::path::{Path, PathBuf};
pub const EXPORTS_DIR: &str = "exports"; pub const EXPORTS_DIR: &str = "exports";
/// How long an export is kept, seconds. /// How long an export is kept, seconds.
pub const EXPORT_TTL: i64 = 15 * 60; pub const EXPORT_TTL: i64 = 15 * 60;
/// The most exports one token may have kept at once (INVARIANT 3). /// The most exports one token may have kept at once (INVARIANT 3): every
pub const MAX_LIVE: usize = 16; /// file an agent takes away is one since D-46 (each report it exports,
/// each receipt it reads back), so a month-end's worth fits.
pub const MAX_LIVE: usize = 64;
/// What an export is (INVARIANT 1): its bytes are beside it. /// What an export is (INVARIANT 1): its bytes are beside it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+1 -1
View File
@@ -22,7 +22,7 @@ Statuses: `covered` (built and tested), `partial` (built, known gaps listed),
| 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered | | 8 | Document filing by category (01 Formation / 02 Legal / 03 Tax / 04 Accounting) | `books.file_document` | covered |
| 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered | | 9 | Ad-hoc questions ("what did we spend on X?") | `search_entries` + reports; the AI itself is the query layer | covered |
| 10 | Human review of what the assistant recorded | `books.digest` | covered | | 10 | Human review of what the assistant recorded | `books.digest` | covered |
| 11 | CPA-ready export of the books | `books.export` (csv/json/xlsx/iif; xlsx is base64) | covered | | 11 | CPA-ready export of the books | `books.export` (csv/json/xlsx/pdf/iif, each handed over as a download: D-46) | covered |
| 12 | Company email filing (every company email → Drive folder) | out of books scope; the existing mail-filing automation keeps running unchanged | — | | 12 | Company email filing (every company email → Drive folder) | out of books scope; the existing mail-filing automation keeps running unchanged | — |
## Notes ## Notes
+57 -53
View File
@@ -247,14 +247,15 @@ reach through the bridge.
action `download` with the attachment's id, a refusal at the door action `download` with the attachment's id, a refusal at the door
too. too.
- `GET /b/{book}/x/{export}`: a file the books made for an agent (the - `GET /b/{book}/x/{export}`: a file the books made for an agent (the
CPA package, a 1099 worksheet, a W-9: a `HandedFile`, "Tools — the CPA package, a 1099 worksheet, a W-9, a report export, a label sheet,
TIN vault"), as `/a/` (the same door, the same headers, a receipt's copy: a `HandedFile`, "Tools — the TIN vault"), as `/a/` (the same door, the same headers,
`Repr-Digest` included, GET only), but only for the token it was made `Repr-Digest` included, GET only), but only for the token it was made
for (403 `not_yours`), with no scope of its own to check (the tool for (403 `not_yours`), with no scope of its own to check (the tool
that made it checked its scope); 404 `no_such_export` (unknown or that made it checked its scope); 404 `no_such_export` (unknown or
lapsed), 503 `stopped`. An export's id is `ex_` and 32 letters lapsed), 503 `stopped`. An export's id is `ex_` and 32 letters
`a`-`p`; it is kept 15 minutes, fetched as often as wanted until `a`-`p`; it is kept 15 minutes, fetched as often as wanted until
then, at most 16 per token, and swept like uploads. Logged as action then, at most 64 per token (every file an agent takes away is one,
D-46), and swept like uploads. Logged as action
`export` with the export's id. `export` with the export's id.
- `wanderbooks download --book B [--token NAME] ID --to DIR` takes an - `wanderbooks download --book B [--token NAME] ID --to DIR` takes an
attachment's id (a number, `/a/`) or an export's (`ex_…`, `/x/`), attachment's id (a number, `/a/`) or an export's (`ex_…`, `/x/`),
@@ -625,9 +626,9 @@ life's last day is 9999-12-31 at the latest.
debiting the cost account, not reversed). Refused with nothing to debiting the cost account, not reversed). Refused with nothing to
change, or with both `serial` and `clear_serial`. `asset` is the id or current tag. Journaled as `asset.tagged`, change, or with both `serial` and `clear_serial`. `asset` is the id or current tag. Journaled as `asset.tagged`,
`asset.serial_set`. `asset.serial_set`.
- `books.asset_labels { assets?, skip? }` → `{ file_name, content_base64, - `books.asset_labels { assets?, skip? }` → `{ file, tags }`: an Avery
tags }`: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of 5160/8160 label sheet (PDF), handed over (`file` a `HandedFile`, D-46):
the tag, description, serial. Default: every active tagged asset; `skip` company, tag, QR code of the tag, description, serial. Default: every active tagged asset; `skip`
reuses a partly used sheet. reuses a partly used sheet.
- `books.asset_schedule { asset_id | tag, through? }` - `books.asset_schedule { asset_id | tag, through? }`
→ `{ asset_id, description, cost, salvage_value, useful_life_months, → `{ asset_id, description, cost, salvage_value, useful_life_months,
@@ -1192,14 +1193,13 @@ texts are at most 500 characters, no control characters.
employee with W-4 facts, is refused: clear the facts first. employee with W-4 facts, is refused: clear the facts first.
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?, - `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_upload_id?, fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_upload_id?,
w9_content_base64?, w9_sha256?, w9_file_name? }` or `{ party, clear: w9_file_name? }` or `{ party, clear: true }` → the party. Journaled as `party.w9_set`. A party with a
true }` → the party. Journaled as `party.w9_set`. A party with a
vendor record also gets the record's `tin_on_file` set to match vendor record also gets the record's `tin_on_file` set to match
(journaled as `vendor.w9_flag_set`, its 1099 decision and who made it (journaled as `vendor.w9_flag_set`, its 1099 decision and who made it
kept), so `books.vendor_1099`'s `missing_w9` follows. kept), so `books.vendor_1099`'s `missing_w9` follows.
`tin` and `tin_type` put the full TIN in the TIN vault, and the signed `tin` and `tin_type` put the full TIN in the TIN vault, and the signed
form (`w9_upload_id`, or `w9_content_base64` with `w9_sha256`, as in form (`w9_upload_id`, as in "Tools — receipts & documents") goes there
"Tools — receipts & documents") goes there too (below); with a TIN, too (below); with a TIN,
`tin_last4` comes from it (a given one must agree); with a TIN or the `tin_last4` comes from it (a given one must agree); with a TIN or the
form, `kept_at` defaults to `"wanderbooks vault"`. form, `kept_at` defaults to `"wanderbooks vault"`.
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear: - `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
@@ -1398,8 +1398,8 @@ and the CPA package carries them.
facts name another last four is refused (a payee's new TIN comes with facts name another last four is refused (a payee's new TIN comes with
its W-9: `books.set_w9`); one whose W-9 facts agree gets their its W-9: `books.set_w9`); one whose W-9 facts agree gets their
`vault_record` updated too (`party.w9_set`). `vault_record` updated too (`party.w9_set`).
- `books.set_w9 { ..., tin, tin_type, w9_upload_id? | w9_content_base64? + - `books.set_w9 { ..., tin, tin_type, w9_upload_id?, w9_file_name? }`
w9_sha256?, w9_file_name? }` (above): validates the TIN against its type (nine digits, (above): validates the TIN against its type (nine digits,
hyphens allowed; an ITIN starts with 9 and has an IRS group in digits hyphens allowed; an ITIN starts with 9 and has an IRS group in digits
4-5; an SSN or EIN doesn't start with 9), checks the facts on a copy of 4-5; an SSN or EIN doesn't start with 9), checks the facts on a copy of
the ledger, then writes the vault and journals the facts with the ledger, then writes the vault and journals the facts with
@@ -1408,26 +1408,28 @@ and the CPA package carries them.
(`party.tin_set`, as `books.set_tin`); a TIN with a new last four clears (`party.tin_set`, as `books.set_tin`); a TIN with a new last four clears
the old TIN facts first (`party.tin_set` null), so the two never the old TIN facts first (`party.tin_set` null), so the two never
disagree. The form (a PDF, PNG or JPEG, judged by its own first bytes, disagree. The form (a PDF, PNG or JPEG, judged by its own first bytes,
whatever it is called; an upload of purpose `w9`, at most 25 MiB, or whatever it is called; an upload of purpose `w9`, at most 25 MiB) goes
inline at most 256 KiB) goes to the vault only, never the attachment to the vault only, never the attachment store. Its file name is
store. Its file name is `w9_file_name` if given, else the upload's, `w9_file_name` if given, else the upload's, checked as any filed name; `w9_file_name` without a W-9 is refused. Facts given later without a TIN
else `w9.pdf`, `w9.png` or `w9.jpg` by its type, checked as any filed
name; `w9_file_name` without a W-9 is refused. Facts given later without a TIN
keep the vault record when their last four agree with the vault's TIN, keep the vault record when their last four agree with the vault's TIN,
and are refused when they don't (pass the new TIN, or purge). Clearing and are refused when they don't (pass the new TIN, or purge). Clearing
the facts (`clear: true`) is refused while the vault holds the party's the facts (`clear: true`) is refused while the vault holds the party's
TIN or form: purge first. TIN or form: purge first.
- `books.get_w9 { party }` → `{ party_id, file }`: the party's form from - `books.get_w9 { party }` → `{ party_id, file }`: the party's form from
the vault, handed over. Journals nothing. the vault, handed over. Journals nothing.
- **Handed files** (D-29): no tool takes a path. A file the books make - **Handed files** (D-29, D-46): no tool takes a path, and no tool gives
for an agent (`books.get_w9`'s form, `books.vendor_1099`'s `csv`, a file's bytes back. Every file an agent takes away (`books.get_w9`'s
`books.export_cpa_package`'s zip) comes back as a `HandedFile { form, `books.vendor_1099`'s `csv`, `books.export_cpa_package`'s zip,
`books.export` in every format, `books.asset_labels`' sheet,
`books.get_receipt` view `bytes`) comes back as a `HandedFile {
file_name, mime_type, size_bytes, sha256, download, expires_at }`: an file_name, mime_type, size_bytes, sha256, download, expires_at }`: an
export, kept 15 minutes for the token that asked (at most 16 per export, kept 15 minutes for the token that asked (at most 64 per
token, swept like uploads), and `download` is the command that saves token, swept like uploads), and `download` is the command that saves
it in a folder the human names: `wanderbooks download --book B it in a folder the human names: `wanderbooks download --book B
[--token NAME] ex_… --to <a folder>` (`GET /b/{book}/x/{export}`, [--token NAME] ex_… --to <a folder>` (`GET /b/{book}/x/{export}`,
"Transports"). Its bytes never come back inline (D-44). "Transports"). Its bytes never come back in the answer (D-44, D-46).
A resource read is text only (JSON, Markdown): `books://receipts/{id}`
is the receipt's meta, never its bytes.
A file with full TINs never goes into the books: the server keeps, beside the vault, the SHA-256 of every A file with full TINs never goes into the books: the server keeps, beside the vault, the SHA-256 of every
file it read or wrote with full TINs (the W-9 `books.set_w9` reads, file it read or wrote with full TINs (the W-9 `books.set_w9` reads,
recorded once the call has passed every check, just before the vault recorded once the call has passed every check, just before the vault
@@ -1497,16 +1499,18 @@ the receipt policy counts it.
## Tools — receipts & documents ## Tools — receipts & documents
A file comes in one of two ways, named in the tool that files it A file comes in one way only (D-46: a file never passes through an
agent's context, in either direction), named in the tool that files it
(`books.attach_receipt`, `books.file_document`, `books.set_w9`, (`books.attach_receipt`, `books.file_document`, `books.set_w9`,
`books.import_bank_csv`, `books.import_chart_csv`): `upload_id`, an `books.import_bank_csv`, `books.import_chart_csv`): `upload_id` (for the
upload made with `books.begin_upload` (any file up to 25 MiB; its bytes W-9, `w9_upload_id`), an upload made with `books.begin_upload` (any file
never pass through the agent), or `content_base64` with its `sha256` up to 25 MiB) and sent by `wanderbooks upload`. These tools take no other
(at most 256 KiB decoded; longer text is refused before it is decoded). argument (their schemas say so): an argument that once carried a file's
Never both, never neither. Inline bytes that aren't base64, or whose bytes or text inline (`content_base64`, `sha256`, `csv_text`,
SHA-256 isn't the one given, are refused (guard `base64_corrupt`): send `w9_content_base64`, `w9_sha256`) is refused as unknown by every tool
the file as an upload. An upload is kept for the token that began it. An before it runs (a protocol error, recorded as refused `inline_file`),
MCP request is at most 1 MiB. A file's name and type are checked when its words naming `books.begin_upload`. An upload is kept for the token
that began it. An MCP request is at most 1 MiB. A file's name and type are checked when
it is filed (every filing tool, `books.begin_upload`, and the W-9's it is filed (every filing tool, `books.begin_upload`, and the W-9's
name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`, name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`,
and no control or invisible (Unicode format) characters; a MIME type and no control or invisible (Unicode format) characters; a MIME type
@@ -1538,11 +1542,10 @@ and no control or invisible (Unicode format) characters; a MIME type
names it (link it, `books.link_attachment`, instead of filing a copy); names it (link it, `books.link_attachment`, instead of filing a copy);
for `import` and `w9` a grant is always made. Refusals never name a for `import` and `w9` a grant is always made. Refusals never name a
path. path.
- `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id? | - `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id,
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id, description?, link_to? }` → `{ attachment_id, sha256, deduplicated,
sha256, deduplicated, links }`. `entry_id` is optional: a receipt can be links }`. `entry_id` is optional: a receipt can be filed before its
filed before its entry exists. `file_name` and `mime_type` default to entry exists. `file_name` and `mime_type` default to the upload's. The upload is taken only after
the upload's; inline they are required. The upload is taken only after
the entry and every `link_to` subject check out. `link_to` as in "Tools the entry and every `link_to` subject check out. `link_to` as in "Tools
— attachments". — attachments".
- `books.link_receipt { attachment_id, entry_id }` → `{ ok }`. Links a previously filed - `books.link_receipt { attachment_id, entry_id }` → `{ ok }`. Links a previously filed
@@ -1570,17 +1573,16 @@ and no control or invisible (Unicode format) characters; a MIME type
files filed on the entry and linked to it. files filed on the entry and linked to it.
- `books.get_receipt { attachment_id, view?: "meta" | "bytes" }` → `{ - `books.get_receipt { attachment_id, view?: "meta" | "bytes" }` → `{
attachment_id, sha256, entry_id?, category, file_name, mime_type, attachment_id, sha256, entry_id?, category, file_name, mime_type,
size_bytes, description?, links, content_base64?, download? }`. `meta` size_bytes, description?, links, file? }`. `meta` (the default) returns
(the default) returns no bytes. `bytes` adds `content_base64` no file. `bytes` adds `file`, the file handed over (a `HandedFile`, read
(hash-verified) for a file of at most 256 KiB; a larger one gives hash-verified, whatever its size: D-46), whose `download` saves a copy
`download`, the command `wanderbooks download --book B <id> --to <a as a new file in a folder the human names. The stored file is also
folder>` (with `--token NAME` when the caller's token isn't the fetched by `wanderbooks download --book B <id> --to <a folder>` (`GET
default `agent`; `GET /b/{book}/a/{attachment}`, "Transports"), which /b/{book}/a/{attachment}`, books:read, "Transports").
saves a copy as a new file in that folder.
- `books.file_document { category: "formation" | "legal" | "tax" | - `books.file_document { category: "formation" | "legal" | "tax" |
"accounting" | "statement" | "contract" | "photo" | "warranty" | "accounting" | "statement" | "contract" | "photo" | "warranty" |
"import_source" | "other", file_name?, mime_type?, upload_id? | "import_source" | "other", file_name?, mime_type?, upload_id,
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id, description?, link_to? }` → `{ attachment_id,
sha256, deduplicated, links }`. The bytes, name and type as in sha256, deduplicated, links }`. The bytes, name and type as in
`books.attach_receipt`. The Drive-folder replacement (today's 01 `books.attach_receipt`. The Drive-folder replacement (today's 01
Formation / 02 Legal / 03 Tax / 04 Accounting). Never a W-9: it shows Formation / 02 Legal / 03 Tax / 04 Accounting). Never a W-9: it shows
@@ -1635,8 +1637,10 @@ posting.
## Tools — export ## Tools — export
- `books.export { format, report, start?, end?, as_of?, cash_accounts?, - `books.export { format, report, start?, end?, as_of?, cash_accounts?,
financing_accounts?, investing_accounts? }` → `{ content, financing_accounts?, investing_accounts? }` → `{ format, report, file,
content_encoding?, warnings? }`. warnings? }`: `file` the export handed over (a `HandedFile`, D-46),
named `<report>-<today>.<format>`, every format alike; never its bytes
in the answer.
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (direct - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (direct
method; needs `cash_accounts`; json is `books.cash_flow`'s answer, csv method; needs `cash_accounts`; json is `books.cash_flow`'s answer, csv
is `section,kind,class,entry,date,label,amount,on_behalf` (`on_behalf` is `section,kind,class,entry,date,label,amount,on_behalf` (`on_behalf`
@@ -1656,7 +1660,7 @@ posting.
every posted entry from the first through `as_of`, never a period), every posted entry from the first through `as_of`, never a period),
`tax_summary` (see "Tools — tax categories"), `fixed_asset_register` `tax_summary` (see "Tools — tax categories"), `fixed_asset_register`
(as of a day; see "Tools — fixed assets"). (as of a day; see "Tools — fixed assets").
- Formats: `pdf` and `xlsx` (base64) for `profit_and_loss`, - Formats: `pdf` and `xlsx` for `profit_and_loss`,
`balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`, `balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`,
`tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for `tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for
every report (`general_ledger` and `general_journal` have no pdf); every report (`general_ledger` and `general_journal` have no pdf);
@@ -1668,7 +1672,7 @@ posting.
refused); `balance_sheet`, `trial_balance`, `fixed_asset_register` refused); `balance_sheet`, `trial_balance`, `fixed_asset_register`
and `iif` take `as_of` (omitted: today, the company's time zone). and `iif` take `as_of` (omitted: today, the company's time zone).
- IIF (`docs/spec/rules.md` B-34; plan D-36): plain ASCII text, every - IIF (`docs/spec/rules.md` B-34; plan D-36): plain ASCII text, every
line ending CR LF, inline (no `content_encoding`): every posted entry line ending CR LF (`text/plain`, handed over like every export): every posted entry
from the first through `as_of`, period-closing entries left out from the first through `as_of`, period-closing entries left out
(QuickBooks keeps none; imported, a close would zero its period's (QuickBooks keeps none; imported, a close would zero its period's
profit), each transaction's DOCNUM its entry id; its BANK accounts are profit), each transaction's DOCNUM its entry id; its BANK accounts are
@@ -1797,7 +1801,7 @@ posting.
## Tools — import ## Tools — import
- `books.import_bank_csv { csv_text? | upload_id?, bank_account_code, - `books.import_bank_csv { upload_id, bank_account_code,
default_offset_code, rules?, date_order?, source_attachment_id? }` → default_offset_code, rules?, date_order?, source_attachment_id? }` →
`{ imported: [{ entry_id, date, description, amount, offset_account }], `{ imported: [{ entry_id, date, description, amount, offset_account }],
count, source_attachment_id, source_linked }`: one draft per count, source_attachment_id, source_linked }`: one draft per
@@ -1809,12 +1813,12 @@ posting.
`import_source`) is checked before any draft and linked to the bank `import_source`) is checked before any draft and linked to the bank
account (`source_linked`; false when it was already), never to the account (`source_linked`; false when it was already), never to the
drafts: a bank export is not a receipt. drafts: a bank export is not a receipt.
- `books.import_chart_csv { csv_text? | upload_id? }` → `{ created, - `books.import_chart_csv { upload_id }` → `{ created,
count, warnings? }`: creates accounts from count, warnings? }`: creates accounts from
`code,name,type,parent_code[,tax_category]` CSV. All-or-nothing; the `code,name,type,parent_code[,tax_category]` CSV. All-or-nothing; the
name and tax category rules are `books.add_account`'s. name and tax category rules are `books.add_account`'s.
- Each takes the CSV as `csv_text` or as an upload (`books.begin_upload` - Each takes the CSV only as an upload (`books.begin_upload` purpose
purpose `import`; UTF-8), never both. The CSV itself is not filed: file `import`; UTF-8; D-46). The CSV itself is not filed: file
it with `books.file_document` (category `import_source`) for it with `books.file_document` (category `import_source`) for
`source_attachment_id`. `source_attachment_id`.
+2 -2
View File
@@ -58,8 +58,8 @@ B-27); the MCP server fails closed after an internal error, and no input
reaches one (dates are four-digit years, amounts at most a line's, B-28); reaches one (dates are four-digit years, amounts at most a line's, B-28);
local files local files
were read only from the import roots, never the books, the vault or its were read only from the import roots, never the books, the vault or its
key (P2 slice 5 removed local paths: files now come in as uploads or key (P2 slice 5 removed local paths: files now come in only as uploads,
inline bytes); no file with a full TIN is filed into the books (B-26); the agent is D-46); no file with a full TIN is filed into the books (B-26); the agent is
told the rules in force in a short connect text and a handbook, and no told the rules in force in a short connect text and a handbook, and no
agent text points at the books' files, git or the repo. agent text points at the books' files, git or the repo.
+1 -2
View File
@@ -313,8 +313,7 @@ damn CPA package."
TINs are missing (`tin_gaps`), and the package is still built. TINs are missing (`tin_gaps`), and the package is still built.
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"): - **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
`books.set_w9` takes `tin` + `tin_type` and the signed form as `books.set_w9` takes `tin` + `tin_type` and the signed form as
`w9_upload_id` (`books.begin_upload`) or `w9_content_base64` with `w9_upload_id` (`books.begin_upload`; never inline, D-46) (the form goes to the vault, never the attachment store) and journals the last
`w9_sha256` (the form goes to the vault, never the attachment store) and journals the last
four with `vault_record`; facts that disagree with the vault's TIN are four with `vault_record`; facts that disagree with the vault's TIN are
refused, and clearing facts with a vault record needs a purge first. refused, and clearing facts with a vault record needs a purge first.
With a TIN it also records the party's TIN facts. `books.set_tin With a TIN it also records the party's TIN facts. `books.set_tin
+2 -2
View File
@@ -93,8 +93,8 @@ a backup is then proven where it lands, by a drill (docs/mcp-interface.md,
- **Deduplicated.** Attaching the same receipt twice stores one blob and two - **Deduplicated.** Attaching the same receipt twice stores one blob and two
attachment rows. `attach_receipt` / `file_document` return attachment rows. `attach_receipt` / `file_document` return
`deduplicated: true` when the blob already existed. `deduplicated: true` when the blob already existed.
- **Limits.** 25 MiB per file as an upload (`books.begin_upload`), 256 - **Limits.** 25 MiB per file, as an upload (`books.begin_upload`): the
KiB given inline. The name and MIME type are the client's, checked only way a file comes in (D-46: nothing is given inline). The name and MIME type are the client's, checked
when the file is filed: a name of 1 to 255 bytes, no `/` or `\`, not when the file is filed: a name of 1 to 255 bytes, no `/` or `\`, not
`.` or `..`, no control or invisible characters; a `type/subtype` of `.` or `..`, no control or invisible characters; a `type/subtype` of
visible ASCII, at most 127 bytes (`docs/mcp-interface.md`, "Tools — visible ASCII, at most 127 bytes (`docs/mcp-interface.md`, "Tools —
+5 -2
View File
@@ -1491,7 +1491,9 @@ don't count toward the three: `sources/unicode-tin-text.md`, Unicode
worksheets carry every member's and payee's TIN by default) are never worksheets carry every member's and payee's TIN by default) are never
written to a path: no tool takes one (D-29, P2 slice 6). Each is handed written to a path: no tool takes one (D-29, P2 slice 6). Each is handed
over as an export the asking token fetches with `wanderbooks download` over as an export the asking token fetches with `wanderbooks download`
(kept 15 minutes), never inline (D-44). (Until slice 6 they were (kept 15 minutes), never inline (D-44; since D-46 no tool takes or
gives any file's bytes inline: files come in only by upload and go out
only by download). (Until slice 6 they were
written only outside the books dir: DECISION, P0 review, 2026-10-16, so written only outside the books dir: DECISION, P0 review, 2026-10-16, so
none could be filed back in as a receipt and read out again.) No file none could be filed back in as a receipt and read out again.) No file
holding a full TIN is filed into the books holding a full TIN is filed into the books
@@ -1503,7 +1505,8 @@ before the vault keeps it, so a refused call marks no file; each
a 1099 worksheet with TINs; a CPA package with TINs and each file in it a 1099 worksheet with TINs; a CPA package with TINs and each file in it
that holds one), shared by every session and every later server for the that holds one), shared by every session and every later server for the
books, and every way a file enters the books (`books.attach_receipt`, books, and every way a file enters the books (`books.attach_receipt`,
`books.file_document`, an upload or inline) refuses those exact files, `books.file_document`, by upload, the only way since D-46) refuses those
exact files,
and refuses any text file (by its type or name, or because it decodes as and refuses any text file (by its type or name, or because it decodes as
UTF-8 or UTF-16, with a byte-order mark or, NUL-interleaved, without one) UTF-8 or UTF-16, with a byte-order mark or, NUL-interleaved, without one)
holding (a) a TIN the vault holds for a party of the holding (a) a TIN the vault holds for a party of the
+84 -1
View File
@@ -2301,7 +2301,8 @@ data root of its own, reached as an agent reaches it.
lowered setting take effect on the next call lowered setting take effect on the next call
(`a_changed_setting_is_read_on_the_next_call`, (`a_changed_setting_is_read_on_the_next_call`,
`a_lowered_threshold_is_enforced`); `get_receipt` above 256 KiB gives `a_lowered_threshold_is_enforced`); `get_receipt` above 256 KiB gives
its download (`get_receipt_gives_bytes_up_to_256_kib_and_a_download_above`); its download (`get_receipt_gives_bytes_up_to_256_kib_and_a_download_above`,
since D-46 `get_receipt_hands_every_file_over_by_its_download`);
an unknown tool is a protocol error (`jsonrpc_unknown_tool_is_a_protocol_error`, an unknown tool is a protocol error (`jsonrpc_unknown_tool_is_a_protocol_error`,
now true of the server itself). now true of the server itself).
- **Deleted, for what they tested is gone:** startup's - **Deleted, for what they tested is gone:** startup's
@@ -2382,6 +2383,88 @@ data root of its own, reached as an agent reaches it.
tool suites run once, in `check`. Mutation of `ledger-mcp` runs the tool suites run once, in `check`. Mutation of `ledger-mcp` runs the
ledger-cli suites too, without the viewer (`just mutants-mcp`). ledger-cli suites too, without the viewer (`just mutants-mcp`).
## Files only by handle (D-46, 2026-10-23)
No file passes through an agent's context, in either direction: the
inline arguments are gone (`content_base64`, `sha256`, `csv_text`,
`w9_content_base64`, `w9_sha256`, the 256 KiB limit), and every file a
tool gave inline is handed over (`books.export` in every format,
`books.asset_labels`, `books.get_receipt` view `bytes`).
- **The harness** (`tests/common/mod.rs`, its INVARIANT 5): a test files
a file as an agent does. `Wire::begin_and_send` calls
`books.begin_upload` and PUTs the bytes to `/b/{book}/u/{upload}` with
the session's token, as `wanderbooks upload` sends them (nothing sent
when it names an `existing_attachment_id`); `Wire::upload` gives the
`upload_id`; `upload_on` does the same for an rmcp session;
`upload_args` and `answer_of` are its pieces; `Wire::fetch_text` reads a
handed file as text. `backups.rs` and `open_while_held.rs` keep their
own harnesses: `backups.rs`'s `Agent` uploads the same way, and
`open_while_held.rs`'s bridge agent runs the very `wanderbooks upload`
command books.begin_upload returns.
- **Migrated to uploads** (every assertion kept): every receipt, document,
W-9 and CSV given inline in `mcp_journeys.rs`, `spec_mcp.rs` (its
every-tool walk and the random walk, whose attach is now an upload, an
upload there is none of, or the removed inline argument),
`spec_vault.rs`, `spec_attachments.rs`, `spec_assets.rs`,
`spec_projects.rs`, `spec_tax_policy.rs`, `oracle_mcp.rs`,
`hostile_args.rs` (its fixture; the generator's file payloads now give
begin_upload's sha256), `tin_files.rs`, `local_server.rs`,
`backups.rs`, `http_concurrency.rs` and `open_while_held.rs`. A call
refused after taking an upload leaves it for the next, so a refusal
loop names one upload throughout (`tin_files.rs` keeps them by SHA-256,
so its refusals never pile up past a token's 16 uploads). Bytes stored
already are named by begin_upload, not uploaded again, so entries
posted in a loop get receipts of their own bytes (`receipt {n}`), and
`tin_files.rs`'s `accepted_everywhere` withdraws the copy filed the
other way before filing the same bytes again; the refused-W-9 test
withdraws the scan it filed before the W-9 marked it, then shows the
bytes refused.
- **Migrated to downloads:** every read of an export's `content` or
`content_encoding`, of the label sheet's `content_base64`, and of
`get_receipt`'s `content_base64` or attachment-route `download`, now
reads the handed file by its download (`Wire::fetch`, which checks its
size and SHA-256): `mcp_journeys.rs`, `oracle_mcp.rs` (`export`,
`export_text`), `spec_assets.rs`, `spec_tax_policy.rs`,
`spec_party_totals.rs`, `p3_seams.rs`, `spec_vault.rs` (whose TIN scan
now also scans the journal's two exports: six files),
`local_server.rs` and `open_while_held.rs` (the saved copy of a
receipt is the export's, under the receipt's name).
- **Rewritten, for what they tested changed:**
`oracle_mcp.rs` `inline_bytes_come_with_their_own_sha256_or_as_an_upload`
is `a_filing_tool_takes_its_file_only_as_an_upload` (no upload refused;
each inline argument, alone or beside an upload, refused as unknown,
pointing at books.begin_upload; begin_upload's naming of stored bytes
kept); `inline_files_up_to_256_kib_are_accepted_and_no_more` is
`files_past_the_old_inline_limit_go_by_upload_and_come_back_by_download`;
`get_receipt_gives_bytes_up_to_256_kib_and_a_download_above` is
`get_receipt_hands_every_file_over_by_its_download`;
`local_server.rs` `bytes_given_inline_are_small_and_carry_their_hash` is
`a_file_comes_only_by_its_upload_and_goes_back_by_its_download` (the
name and type checks kept, given beside an upload);
`spec_vault.rs`'s W-9 refusals keep the type, unknown upload and name
cases, add an upload of another purpose and the inline arguments.
- **Removed, for what they tested is gone:** the inline cases only
(sha256 missing or another file's, base64 that isn't, both given, the
256 KiB limit and the length check before decoding: guard
`base64_corrupt`), in the tests above; `ledger-mcp`'s
`the_file_limits_are_the_plans` lost its `INLINE_MAX` assertion;
`ledger-store`'s `a_files_bytes_are_hashed_as_they_are_even_where_they_spell_digits`
is `digits_inside_any_text_are_masked` (no argument is a file's bytes,
so the access log's digest masks a TIN in any text).
- **Added:** `tools/files.rs` `an_inline_file_argument_is_refused_on_every_tool`
(each inline name on any tool, naming itself and books.begin_upload;
begin_upload's own sha256 and the upload ids pass);
`mcp_journeys.rs` `files_pass_only_by_handle_in_and_out` (the five
filing tools' schemas closed, with an upload and no inline field; the
output schemas of export, asset_labels and get_receipt carry `file`;
an inline receipt and an inline CSV refused as unknown, storing
nothing; csv, json, xlsx, pdf and iif exports, the label sheet and a
receipt's bytes each handed over, its download the bytes, size and
SHA-256 stated, no bytes in the answer); the lint
`tool_text_lint` refuses `content_base64`, `csv_text`, `base64` and
`256 KiB` in every agent text.
## Benchmarks (baseline 2026-10-13) ## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work No budget, by decision (the owner, 2026-10-13: the books do little work
+21 -1
View File
@@ -194,7 +194,8 @@ books with no access log or backups, the settings file read and reloaded
(`Served`); an unknown tool is a protocol error. Every suite that drove (`Served`); an unknown tool is a protocol error. Every suite that drove
the stdio server moved to `crates/ledger-cli/tests/` and drives a real the stdio server moved to `crates/ledger-cli/tests/` and drives a real
`wanderbooks server` on a data root of its own (`tests/common`), files by `wanderbooks server` on a data root of its own (`tests/common`), files by
their download. Inline file input is unchanged. The owner then decided their download. Inline file input was unchanged then (D-46 removed it,
below). The owner then decided
(2026-10-23, the question tool): `books.import_settings` goes (118 (2026-10-23, the question tool): `books.import_settings` goes (118
tools; old books come in whole by `books adopt`; `books.chart_template` tools; old books come in whole by `books adopt`; `books.chart_template`
gives its settings as `books.set_setting` changes); the viewer's gives its settings as `books.set_setting` changes); the viewer's
@@ -203,6 +204,23 @@ loose-books-folder mode goes (`--data-dir`, `--books-toml`,
the data root; and `wanderbooks dev-stdio` goes with no stub, an unknown the data root; and `wanderbooks dev-stdio` goes with no stub, an unknown
command like any other. command like any other.
**Files only by handle (2026-10-23, D-46).** The owner, via the question
tool: "Uploads only"; and before it: "What we would DEFINITELY want to
avoid is passing a bunch of BLOBs through AI context windows. That's just
a pure waste." No file passes through an agent's context, either way. In:
`content_base64` and its `sha256` (`books.attach_receipt`,
`books.file_document`), `w9_content_base64` and `w9_sha256`
(`books.set_w9`) and `csv_text` (the two CSV imports) are gone with the
256 KiB inline limit; files come only by `books.begin_upload` and
`wanderbooks upload`; those tools take no other argument, and an inline
argument to any tool is refused before it runs as unknown, pointing to
`books.begin_upload`. Out: `books.export` (every format),
`books.asset_labels` and `books.get_receipt` view `bytes` hand the file
over as a download (`HandedFile`), as the CPA package and the W-9 did; a
token keeps up to 64 handed files (was 16). The tests file by upload
(`tests/common`: `Wire::upload`, `upload_on`) and read every file by its
download (`docs/testing.md`, "D-46").
**Still open after P3 (each for the phase named, or the lead):** **Still open after P3 (each for the phase named, or the lead):**
- Invoices and bills take a project now (above), behind the new journal - Invoices and bills take a project now (above), behind the new journal
feature `document_line_projects`: the first company's books declared feature `document_line_projects`: the first company's books declared
@@ -407,6 +425,8 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
## Decisions log ## Decisions log
- 2026-10-23: D-46, files only by handle: no inline file bytes in or out;
uploads in, downloads out (plan/served.md D-46).
- 2026-10-23: D-44, one way to serve books: the stdio server, its inline - 2026-10-23: D-44, one way to serve books: the stdio server, its inline
file outputs and its settings-file mode are gone; tests drive a real file outputs and its settings-file mode are gone; tests drive a real
books server (plan/served.md D-44). books server (plan/served.md D-44).
+11 -10
View File
@@ -94,7 +94,7 @@ Paths are relative to `/home/swaits/Code/wanderbooks/crates/` unless they are ab
| Extraction | In-process (A/B). | **Sandboxed subprocess** `ledger-extract` (C): rlimits, seccomp, no network, Landlock read-only on the input blob. | | Extraction | In-process (A/B). | **Sandboxed subprocess** `ledger-extract` (C): rlimits, seccomp, no network, Landlock read-only on the input blob. |
| Archive vs Landlock | A: server shells out to git, which contradicts its own Landlock rules. | **A separate archive process, `ledger-mcp archive`,** that the host starts beside the server, after the server holds every book's lock, and stops with it (3.9); each confines itself on its own. The archive process has read-only Landlock on `books/<id>/{journal.jsonl,blobs}`. Locally, Landlock ABI ≥ 4 limits its TCP connect to the target's port (443 or 22); it cannot filter by host, and an unprivileged process has no host-level egress filter, so that exists only under a system unit on self-host (`IPAddressAllow`). Locally the server listens on Unix sockets (3.9); if the owner turns TCP on, it binds 127.0.0.1 first. Either way Landlock ABI ≥ 4 then denies all further TCP bind and connect, so the local server has no egress. As built (slice 7, D-31): no archive process; the server runs git itself (3.7 as built), so slice 8's Landlock must let it run git and reach the backup folder and repository. On self-host, a system unit adds `IPAddressDeny=any` (loopback allowed); if the built-in authorization server fetches CIMD documents (D-7 a), connect is allowed to port 443 only and `IPAddressDeny` narrows to private and link-local ranges (loopback still allowed), matching the CIMD SSRF guard. This confines the server only; it does not restrict the agent. | | Archive vs Landlock | A: server shells out to git, which contradicts its own Landlock rules. | **A separate archive process, `ledger-mcp archive`,** that the host starts beside the server, after the server holds every book's lock, and stops with it (3.9); each confines itself on its own. The archive process has read-only Landlock on `books/<id>/{journal.jsonl,blobs}`. Locally, Landlock ABI ≥ 4 limits its TCP connect to the target's port (443 or 22); it cannot filter by host, and an unprivileged process has no host-level egress filter, so that exists only under a system unit on self-host (`IPAddressAllow`). Locally the server listens on Unix sockets (3.9); if the owner turns TCP on, it binds 127.0.0.1 first. Either way Landlock ABI ≥ 4 then denies all further TCP bind and connect, so the local server has no egress. As built (slice 7, D-31): no archive process; the server runs git itself (3.7 as built), so slice 8's Landlock must let it run git and reach the backup folder and repository. On self-host, a system unit adds `IPAddressDeny=any` (loopback allowed); if the built-in authorization server fetches CIMD documents (D-7 a), connect is allowed to port 443 only and `IPAddressDeny` narrows to private and link-local ranges (loopback still allowed), matching the CIMD SSRF guard. This confines the server only; it does not restrict the agent. |
| Attention engine location | A: `ledger-store/attention.rs`. B: wasm-clean `ledger-view`. | **New crate `ledger-view`** (depends only on `ledger-core`), holding `attention::find`, `next_steps`, the evidence gate view, and a `BooksView` trait implemented by Store and Snapshot. A pure-Rust evidence projection (dropping SQLite from the viewer) is deferred to the hosted service's web viewer (section 11). | | Attention engine location | A: `ledger-store/attention.rs`. B: wasm-clean `ledger-view`. | **New crate `ledger-view`** (depends only on `ledger-core`), holding `attention::find`, `next_steps`, the evidence gate view, and a `BooksView` trait implemented by Store and Snapshot. A pure-Rust evidence projection (dropping SQLite from the viewer) is deferred to the hosted service's web viewer (section 11). |
| Inline bytes | A: 256 KiB. B: 1 MiB without sha. C: 256 KiB plus sha. | **256 KiB with a mandatory matching sha256.** A mismatch is refused with guard `base64_corrupt`, whose remedy is `books.begin_upload`. `/mcp` body cap is 1 MiB. | | Inline bytes | A: 256 KiB. B: 1 MiB without sha. C: 256 KiB plus sha. | ~~**256 KiB with a mandatory matching sha256.** A mismatch is refused with guard `base64_corrupt`, whose remedy is `books.begin_upload`.~~ **Superseded by D-46 (2026-10-23): no inline bytes at all, in or out; files come only by upload and go only by download.** `/mcp` body cap is 1 MiB. |
| Same-user mode | C: an announced tier. B: refuse to start without an acknowledgement flag. | **The local default, with no gate flag** (owner decision 2026-10-16). `books.start`, Needs attention, `wanderbooks doctor` and the docs report `isolation: "same_user (advisory)"`. | | Same-user mode | C: an announced tier. B: refuse to start without an acknowledgement flag. | **The local default, with no gate flag** (owner decision 2026-10-16). `books.start`, Needs attention, `wanderbooks doctor` and the docs report `isolation: "same_user (advisory)"`. |
| Viewer channel | A: replica over a peer-credential socket. | **Direct read-only disk access locally.** This is the owner's rule: "the GUI has r/o access to ALL state on disk; it never mutates; it doesn't go through the MCP." `books.show` requests reach the GUI through `ops/show.jsonl` in the book's own data root, which the server writes and the GUI watches. Presence is a shared `flock` that the GUI holds on a read-only descriptor of `ops/viewer.lock`. Locally there is no socket, no peer-credential check and no executable check. The replica protocol exists only for viewing remote books (P9). | | Viewer channel | A: replica over a peer-credential socket. | **Direct read-only disk access locally.** This is the owner's rule: "the GUI has r/o access to ALL state on disk; it never mutates; it doesn't go through the MCP." `books.show` requests reach the GUI through `ops/show.jsonl` in the book's own data root, which the server writes and the GUI watches. Presence is a shared `flock` that the GUI holds on a read-only descriptor of `ops/viewer.lock`. Locally there is no socket, no peer-credential check and no executable check. The replica protocol exists only for viewing remote books (P9). |
| Local token | A: literal header in the Claude config. | **OS keyring, read by the bridge** (D-23): Claude Code and Codex run `wanderbooks mcp --book X` over stdio, and the bridge reads the token itself, so no client config holds it. `headersHelper`/`http_headers_helper` running `wanderbooks token print` remain for clients that choose HTTP. | | Local token | A: literal header in the Claude config. | **OS keyring, read by the bridge** (D-23): Claude Code and Codex run `wanderbooks mcp --book X` over stdio, and the bridge reads the token itself, so no client config holds it. `headersHelper`/`http_headers_helper` running `wanderbooks token print` remain for clients that choose HTTP. |
@@ -367,7 +367,7 @@ Hard cap 1,400 characters. The first 512 contain `books.start`. Composed from jo
``` ```
wanderbooks: the double-entry books of {company.name|"a new company"}. Call books.start first in every session: it returns the rules in force, this company's conventions, what needs attention, and your next steps. These tools are the only way to the books; never look for them in local files. Amounts are decimal strings ("123.45"). Entries: books.draft_entry, books.add_line, books.preview_entry, show the human the preview, then books.post_entry with its preview_token. Posted entries never change; books.reverse_entry makes a reversal draft. wanderbooks: the double-entry books of {company.name|"a new company"}. Call books.start first in every session: it returns the rules in force, this company's conventions, what needs attention, and your next steps. These tools are the only way to the books; never look for them in local files. Amounts are decimal strings ("123.45"). Entries: books.draft_entry, books.add_line, books.preview_entry, show the human the preview, then books.post_entry with its preview_token. Posted entries never change; books.reverse_entry makes a reversal draft.
{evidence_rule}. No receipt yet? books.set_draft_state awaiting_evidence keeps the draft visible until it arrives. Files go in with books.begin_upload (inline base64 only under 256 KiB, with its sha256) and come out of books.get_receipt as text, page images or download links. Every change returns next_steps, guards and the journal head: follow them and show the human the affected balances. Never ask for, accept or repeat a full TIN: books.request_tin_entry gives the human a secure link. Procedures are prompts (onboard, month_end_close, cpa_handoff, audit); the guide is books.handbook or books://handbook. {evidence_rule}. No receipt yet? books.set_draft_state awaiting_evidence keeps the draft visible until it arrives. Files go in with books.begin_upload (never inline: D-46) and come out of books.get_receipt as text, page images or download links. Every change returns next_steps, guards and the journal head: follow them and show the human the affected balances. Never ask for, accept or repeat a full TIN: books.request_tin_entry gives the human a secure link. Procedures are prompts (onboard, month_end_close, cpa_handoff, audit); the guide is books.handbook or books://handbook.
``` ```
`{evidence_rule}` comes from `evidence::rule(&settings)`, which replaces `ReceiptPolicy::rule()` (`ledger-config/src/receipt_policy.rs:147`) and now names the required and exempt accounts. The first company example: "Every entry needs a receipt attached or linked before posting; depreciation, amortization and closes are exempt; a reversal needs its own receipt or the original's linked". `{evidence_rule}` comes from `evidence::rule(&settings)`, which replaces `ReceiptPolicy::rule()` (`ledger-config/src/receipt_policy.rs:147`) and now names the required and exempt accounts. The first company example: "Every entry needs a receipt attached or linked before posting; depreciation, amortization and closes are exempt; a reversal needs its own receipt or the original's linked".
@@ -465,24 +465,24 @@ Report results also carry `as_of_status {drafts_open_on_or_before, unreconciled,
- `books.begin_upload {sha256, size, mime, file_name, purpose: receipt|document|import}` returns `{upload_id, put_url, curl, human_url, expires_at, max_bytes}`. If the bytes are already stored it returns `{existing_attachment_id}`. - `books.begin_upload {sha256, size, mime, file_name, purpose: receipt|document|import}` returns `{upload_id, put_url, curl, human_url, expires_at, max_bytes}`. If the bytes are already stored it returns `{existing_attachment_id}`.
- `PUT /b/{book}/u/{upload_id}` is single-use, valid 15 minutes, bound to the principal, sha and size, capped at 25 MiB by default, and re-hashed before acceptance. - `PUT /b/{book}/u/{upload_id}` is single-use, valid 15 minutes, bound to the principal, sha and size, capped at 25 MiB by default, and re-hashed before acceptance.
- `human_url` is a drop zone on `/h/inbox` for clients with no shell (claude.ai). - `human_url` is a drop zone on `/h/inbox` for clients with no shell (claude.ai).
- `attach_receipt`, `file_document`, `import_bank_csv`, `import_chart_csv` and `attach_email` take `upload_id | inbox_id | sha256 | content_base64 (≤256 KiB + required sha256)`. - `attach_receipt`, `file_document`, `import_bank_csv`, `import_chart_csv` and `attach_email` take `upload_id | inbox_id | sha256` (the planned `content_base64 (≤256 KiB + required sha256)` is superseded by D-46: no inline bytes).
- `books.inbox` / `books.discard_inbox_item` manage the drop zone. - `books.inbox` / `books.discard_inbox_item` manage the drop zone.
- `openai/fileParams` is declared on the attach tools (P9). An MCP App upload widget comes in P9. - `openai/fileParams` is declared on the attach tools (P9). An MCP App upload widget comes in P9.
**Out:** **Out:**
- `books.get_receipt {id, view: meta+text (default) | text | page_image{page, max_px:1568} | link | bytes (≤256 KiB, with sha)}`. - `books.get_receipt {id, view: meta+text (default) | text | page_image{page, max_px:1568} | link | bytes}` (bytes handed over as a download, never inline: D-46; it was planned inline up to 256 KiB).
- `books.download_link {attachment_id|export_id}` returns `{url, expires_at, sha256, size}`. Links are single-use, valid 10 minutes, access-logged, and served with `Content-Disposition: attachment` and `nosniff`. - `books.download_link {attachment_id|export_id}` returns `{url, expires_at, sha256, size}`. Links are single-use, valid 10 minutes, access-logged, and served with `Content-Disposition: attachment` and `nosniff`.
- `export`, `asset_labels` and `export_cpa_package` return a link, plus inline JSON/CSV when ≤ 50K characters. - `export`, `asset_labels` and `export_cpa_package` return a link, plus inline JSON/CSV when ≤ 50K characters.
- TIN-bearing outputs come only from `books.request_human_download`, which returns an `/h/` link. - TIN-bearing outputs come only from `books.request_human_download`, which returns an `/h/` link.
**Typical receipt flow (Claude Code, the daily case in 7.2), as planned; slice 5 built it with a command instead of `curl` (below):** `sha256sum` → `books.begin_upload` → `curl -T` the file to `put_url` → `attach_receipt {upload_id}` → `get_receipt` (text) → `draft_from_template` or `draft_entry` → `preview_entry`, shown to the human → on "yes", `post_entry` with the `preview_token` → `next_steps` and the head come back, and `books.show` moves the viewer to the entry. No step raises a client permission prompt beyond the client's normal rules. "Save it as a draft" is `set_draft_state awaiting_evidence`. On claude.ai, files ≤ 256 KiB go inline with their sha256; larger ones go through `human_url` and the `process_inbox` prompt. **Typical receipt flow (Claude Code, the daily case in 7.2), as planned; slice 5 built it with a command instead of `curl` (below):** `sha256sum` → `books.begin_upload` → `curl -T` the file to `put_url` → `attach_receipt {upload_id}` → `get_receipt` (text) → `draft_from_template` or `draft_entry` → `preview_entry`, shown to the human → on "yes", `post_entry` with the `preview_token` → `next_steps` and the head come back, and `books.show` moves the viewer to the entry. No step raises a client permission prompt beyond the client's normal rules. "Save it as a draft" is `set_draft_state awaiting_evidence`. On claude.ai, files go through `human_url` and the `process_inbox` prompt (the planned inline path for files ≤ 256 KiB is superseded by D-46).
**As built (P2 slice 5), and where it departs:** **As built (P2 slice 5), and where it departs:**
- **No path on the server's computer is read.** `books.import_local_file`, the import roots and `--import-root` are gone, and so is `set_w9`'s `w9_file_path`; a test (`no_tool_takes_a_path`) keeps any path-like parameter out, except the three outputs with full TINs (`export_cpa_package` and `get_w9` `save_to`, `vendor_1099` `save_csv_to`), which keep theirs, written only outside the books, until the viewer takes them over in slice 6 (D-28: the viewer reads and downloads; it never writes). - **No path on the server's computer is read.** `books.import_local_file`, the import roots and `--import-root` are gone, and so is `set_w9`'s `w9_file_path`; a test (`no_tool_takes_a_path`) keeps any path-like parameter out, except the three outputs with full TINs (`export_cpa_package` and `get_w9` `save_to`, `vendor_1099` `save_csv_to`), which keep theirs, written only outside the books, until the viewer takes them over in slice 6 (D-28: the viewer reads and downloads; it never writes).
- **An upload, for books served from the root:** `books.begin_upload {sha256, size, mime_type, file_name, purpose: receipt|document|import|w9}` returns `{upload_id, command, expires_at, max_bytes}`. No `put_url`, `curl` or `human_url`: the server listens on a Unix socket, so the agent runs `command`, `wanderbooks upload --book ID UPLOAD FILE`, which reads its token from the keyring (`--token NAME` is in the command when the caller's token isn't the default `agent`) and PUTs the file to `/b/{book}/u/{upload}` (D-28: the agent does it all; no `/h/inbox` drop zone, no `inbox_id`; the hosted server of P9 adds a URL). The bytes stream to `uploads/<id>.part` and are kept only if exactly the size and SHA-256 declared, by the token that began it, once (409 after); 25 MiB at most; 15 minutes; at most 16 under way per token; lapsed ones swept when the books open, whenever a grant is made, and every 10 seconds (owner, 2026-10-19: a W-9 or a file with a TIN "shouldn't just linger there forever... EVENTUALLY cleaned up is fine"). One lock per book orders every change to its uploads (grants, sweeps, a send's start and end, a take, a give-back), and a grant is written aside and renamed. Refusals carry their code (`no_such_upload`, `not_yours`, `already_sent`, `in_use`, `not_as_declared`, `stopped`), and a refused body is read and dropped (up to 25 MiB) so the client gets the answer. Every PUT to books the server holds open is in the access log (action `upload`, with the upload's id, refusals at the door included; a request for books it doesn't hold has no log to go in), written in the same blocking task as the bytes, so a client hanging up never leaves bytes kept without their record, and bytes whose record can't be written are thrown away. An upload's id is `up_` and 32 letters `a`-`p`: with no digits, the access log's TIN masking never hides one. - **An upload, for books served from the root:** `books.begin_upload {sha256, size, mime_type, file_name, purpose: receipt|document|import|w9}` returns `{upload_id, command, expires_at, max_bytes}`. No `put_url`, `curl` or `human_url`: the server listens on a Unix socket, so the agent runs `command`, `wanderbooks upload --book ID UPLOAD FILE`, which reads its token from the keyring (`--token NAME` is in the command when the caller's token isn't the default `agent`) and PUTs the file to `/b/{book}/u/{upload}` (D-28: the agent does it all; no `/h/inbox` drop zone, no `inbox_id`; the hosted server of P9 adds a URL). The bytes stream to `uploads/<id>.part` and are kept only if exactly the size and SHA-256 declared, by the token that began it, once (409 after); 25 MiB at most; 15 minutes; at most 16 under way per token; lapsed ones swept when the books open, whenever a grant is made, and every 10 seconds (owner, 2026-10-19: a W-9 or a file with a TIN "shouldn't just linger there forever... EVENTUALLY cleaned up is fine"). One lock per book orders every change to its uploads (grants, sweeps, a send's start and end, a take, a give-back), and a grant is written aside and renamed. Refusals carry their code (`no_such_upload`, `not_yours`, `already_sent`, `in_use`, `not_as_declared`, `stopped`), and a refused body is read and dropped (up to 25 MiB) so the client gets the answer. Every PUT to books the server holds open is in the access log (action `upload`, with the upload's id, refusals at the door included; a request for books it doesn't hold has no log to go in), written in the same blocking task as the bytes, so a client hanging up never leaves bytes kept without their record, and bytes whose record can't be written are thrown away. An upload's id is `up_` and 32 letters `a`-`p`: with no digits, the access log's TIN masking never hides one.
- **Filed once, and only when filed.** A tool takes the upload by renaming its bytes aside (so two calls never both have it), hashes them again, and spends it only once they are in the books (an import: once its rehearsal passed and the writing begins); a call refused after taking it, or one that panics, gives it back, and a server stopped mid-call gives it back when the books next open. A tool takes only an upload of its own purpose (receipt or document: `attach_receipt`, `file_document`; import: the two CSV imports; w9: `set_w9`). `existing_attachment_id` is offered only for a receipt or document, and never names a withdrawn attachment. - **Filed once, and only when filed.** A tool takes the upload by renaming its bytes aside (so two calls never both have it), hashes them again, and spends it only once they are in the books (an import: once its rehearsal passed and the writing begins); a call refused after taking it, or one that panics, gives it back, and a server stopped mid-call gives it back when the books next open. A tool takes only an upload of its own purpose (receipt or document: `attach_receipt`, `file_document`; import: the two CSV imports; w9: `set_w9`). `existing_attachment_id` is offered only for a receipt or document, and never names a withdrawn attachment.
- **Inline:** `content_base64` (≤ 256 KiB, its length checked before it is decoded) with a required `sha256`; never both, never neither. (Until D-44 the stdio development transport took files inline only; it is gone.) Names and types are checked when a file is filed, whichever way it came: a name of 1 to 255 bytes, no folder in it, no control or invisible characters; a MIME type of visible ASCII. `attach_email` and `inbox_id` aren't built. `set_w9` takes `w9_upload_id` or `w9_content_base64` + `w9_sha256`, judged a PDF, PNG or JPEG by its first bytes, with an optional `w9_file_name`. A request on `/b/{book}/mcp` is at most 1 MiB now that files have their own route (it was 64 MiB). - **Inline: removed by D-46 (2026-10-23).** It was `content_base64` (≤ 256 KiB) with a required `sha256`; now every argument that carried a file's bytes or text (`content_base64`, `sha256`, `csv_text`, `w9_content_base64`, `w9_sha256`) is refused as unknown before the tool runs, pointing to `books.begin_upload`, and the filing tools' schemas take no other argument. Names and types are checked when a file is filed: a name of 1 to 255 bytes, no folder in it, no control or invisible characters; a MIME type of visible ASCII. `attach_email` and `inbox_id` aren't built. `set_w9` takes `w9_upload_id`, judged a PDF, PNG or JPEG by its first bytes, with an optional `w9_file_name`. A request on `/b/{book}/mcp` is at most 1 MiB now that files have their own route (it was 64 MiB).
- **Out:** `books.get_receipt {attachment_id, view: meta (default) | bytes}`: bytes inline for a file ≤ 256 KiB; a larger one answers with `download`, the command `wanderbooks download --book ID ATTACHMENT --to FOLDER`, which GETs `/b/{book}/a/{attachment}` with the agent's token (books:read; `Content-Disposition` with the name in ASCII and RFC 8187 UTF-8, `nosniff`, and the SHA-256 as an RFC 9530 `Repr-Digest`, which the command checks before it saves), into a new file beside any of that name (`name (1).pdf`), never over one: written to a hidden file and linked in whole, so the name never holds part of it. The name it saves under is the server's made plain (no folder, no leading dot, nothing invisible, at most 200 bytes), else `attachment-N`. The read and its record are one blocking task, as for uploads. The `books://receipts/{id}` resource is meta only. Not built yet: `text`, `page_image` and `link` views, `books.download_link`, and links from `export`, `asset_labels` and `export_cpa_package` (slice 6, with the viewer's downloads). - **Out:** `books.get_receipt {attachment_id, view: meta (default) | bytes}`: since D-46, bytes hands the file over (a `HandedFile`, whatever its size), as `books.export` (every format) and `books.asset_labels` do; before it, bytes came inline for a file ≤ 256 KiB. The stored file also comes by `wanderbooks download --book ID ATTACHMENT --to FOLDER`, which GETs `/b/{book}/a/{attachment}` with the agent's token (books:read; `Content-Disposition` with the name in ASCII and RFC 8187 UTF-8, `nosniff`, and the SHA-256 as an RFC 9530 `Repr-Digest`, which the command checks before it saves), into a new file beside any of that name (`name (1).pdf`), never over one: written to a hidden file and linked in whole, so the name never holds part of it. The name it saves under is the server's made plain (no folder, no leading dot, nothing invisible, at most 200 bytes), else `attachment-N`. The read and its record are one blocking task, as for uploads. The `books://receipts/{id}` resource is meta only. Not built yet: `text`, `page_image` and `link` views, `books.download_link`, and links from `export`, `asset_labels` and `export_cpa_package` (slice 6, with the viewer's downloads).
### 4.8 Drafts waiting on evidence ### 4.8 Drafts waiting on evidence
@@ -850,7 +850,7 @@ The first company keeps working between phases.
- Owner passphrase for `/h/` (Argon2id). - Owner passphrase for `/h/` (Argon2id).
- **Files transport:** `begin_upload`, the PUT route, the `/h/inbox` drop page plus `books.inbox` and the `process_inbox` prompt, `download_link`. - **Files transport:** `begin_upload`, the PUT route, the `/h/inbox` drop page plus `books.inbox` and the `process_inbox` prompt, `download_link`.
- **Every server-path parameter deleted:** `import_local_file`, `save_to`, `save_csv_to`, `w9_file_path`, get_w9 `save_to`. W-9 upload moves to `/h/w9/{party}` (sealed with the v1 key until P7). TIN-bearing outputs come only through `request_human_download` → `/h/`, behind the owner passphrase. This closes defect 19. - **Every server-path parameter deleted:** `import_local_file`, `save_to`, `save_csv_to`, `w9_file_path`, get_w9 `save_to`. W-9 upload moves to `/h/w9/{party}` (sealed with the v1 key until P7). TIN-bearing outputs come only through `request_human_download` → `/h/`, behind the owner passphrase. This closes defect 19.
- `get_receipt view=link|bytes(≤256 KiB+sha)` (defect 3, transport part). - `get_receipt view=link|bytes` (defect 3, transport part; bytes is a download since D-46, never inline).
- **Viewer, direct and read-only (3.6):** data-root discovery through `ledger-store::paths::data_root()` or `--root` from `wanderbooks gui <book>`, never a project directory or `wanderbooks.toml`; `books.show` through `ops/show.jsonl` (delete `ledger-store/src/viewer.rs`); presence through the `ops/viewer.lock` shared flock; Save-a-copy and Open-with (defect 3, viewer part). Locally there is no replica; the viewer's only channels are the lease socket's owner intents and its helper (3.9). - **Viewer, direct and read-only (3.6):** data-root discovery through `ledger-store::paths::data_root()` or `--root` from `wanderbooks gui <book>`, never a project directory or `wanderbooks.toml`; `books.show` through `ops/show.jsonl` (delete `ledger-store/src/viewer.rs`); presence through the `ops/viewer.lock` shared flock; Save-a-copy and Open-with (defect 3, viewer part). Locally there is no replica; the viewer's only channels are the lease socket's owner intents and its helper (3.9).
- Archive process and module; `books.backup`; `ops/heads.jsonl`; `verify-archive`; `fetch`. (As built, slice 7, D-31: the server's backup module, `books.backup` and `books.back_up_now`; no archive process, `ops/heads.jsonl`, `verify-archive` or `fetch`: a backup is a repository, cloned and proven by a restore's preview and the drills.) - Archive process and module; `books.backup`; `ops/heads.jsonl`; `verify-archive`; `fetch`. (As built, slice 7, D-31: the server's backup module, `books.backup` and `books.back_up_now`; no archive process, `ops/heads.jsonl`, `verify-archive` or `fetch`: a backup is a repository, cloned and proven by a restore's preview and the drills.)
- **Restore (3.7, D-19):** `books restore`, proven before use, never destructive, onto a new or an existing install; restore drills; the vault and its key travel in the backup (D-31). - **Restore (3.7, D-19):** `books restore`, proven before use, never destructive, onto a new or an existing install; restore drills; the vault and its key travel in the backup (D-31).
@@ -1791,7 +1791,7 @@ Run from clients that hold only a URL and a credential: Claude Code started in `
- Zero failing workpaper checks; cash flow difference 0.00. - Zero failing workpaper checks; cash flow difference 0.00.
- 100% of receipts and posted entries carry a counted attestation, and 0 disagree. - 100% of receipts and posted entries carry a counted attestation, and 0 disagree.
- The access log shows every action under the right principal. No transcript reads the data root or names its path; the only other reader is the read-only viewer. - The access log shows every action under the right principal. No transcript reads the data root or names its path; the only other reader is the read-only viewer.
- No result carried a full TIN or more than 256 KiB of inline bytes. - No result carried a full TIN or any file's bytes (D-46; it was "more than 256 KiB of inline bytes").
- Agent context on receipts never exceeded 100K tokens. - Agent context on receipts never exceeded 100K tokens.
- The owner's sign-off is journaled as `decision.recorded`. - The owner's sign-off is journaled as `decision.recorded`.
@@ -1979,8 +1979,9 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form
| D-41 | The contributed devices' tax basis (P3, defect 13; owner, 2026-10-20) | **Depreciable basis is the lower of fair market value and the member's adjusted basis at conversion** (Treas. Reg. §1.168(i)-4(b); Pub. 946, property changed from personal use): for the four contributed devices, the CPA to confirm at year end. **§704(c) method: traditional**, recorded now and confirmed with the CPA before the first Form 1065 (for members filing jointly every method gives the household the same tax). **The four devices' values on 2026-10-15 are the books' amounts**, citing the entries that recorded them (figures in the company's private change log). **New P3 item: the K-1 worksheet reports tax-basis capital** (Form 1065 instructions, Item L, since 2020), the contributed property at its §723 carryover basis, beside the book figures. | | D-41 | The contributed devices' tax basis (P3, defect 13; owner, 2026-10-20) | **Depreciable basis is the lower of fair market value and the member's adjusted basis at conversion** (Treas. Reg. §1.168(i)-4(b); Pub. 946, property changed from personal use): for the four contributed devices, the CPA to confirm at year end. **§704(c) method: traditional**, recorded now and confirmed with the CPA before the first Form 1065 (for members filing jointly every method gives the household the same tax). **The four devices' values on 2026-10-15 are the books' amounts**, citing the entries that recorded them (figures in the company's private change log). **New P3 item: the K-1 worksheet reports tax-basis capital** (Form 1065 instructions, Item L, since 2020), the contributed property at its §723 carryover basis, beside the book figures. |
| D-42 | A member paying for the company, on the cash flow statement (P3, defect 1; owner, 2026-10-20, revising D-35's first point after the review found ASC 230 silent on a payment a third party makes on the entity's behalf) | **Constructive receipt and disbursement:** when a member (or a lender) pays a company expense or purchase directly, the statement shows a financing inflow (borrowed from the member) and the operating or investing outflow, on that date; paying the member back is a financing outflow. Source: ASC 230 does not address it; practice fills the gap by substance (Deloitte DART Roadmap 7.2 with AICPA TQA 1300.16 and the SEC staff's view; PwC, BDO and EY guides alike), the way a CPA would present it; KPMG accepts noncash only for equipment a lender pays for. Property a member contributes that it already owned stays noncash (ASC 230-10-50-4: no cash moved). Total cash is the same either way; the first company's figures are in its private P3 change log. | | D-42 | A member paying for the company, on the cash flow statement (P3, defect 1; owner, 2026-10-20, revising D-35's first point after the review found ASC 230 silent on a payment a third party makes on the entity's behalf) | **Constructive receipt and disbursement:** when a member (or a lender) pays a company expense or purchase directly, the statement shows a financing inflow (borrowed from the member) and the operating or investing outflow, on that date; paying the member back is a financing outflow. Source: ASC 230 does not address it; practice fills the gap by substance (Deloitte DART Roadmap 7.2 with AICPA TQA 1300.16 and the SEC staff's view; PwC, BDO and EY guides alike), the way a CPA would present it; KPMG accepts noncash only for equipment a lender pays for. Property a member contributes that it already owned stays noncash (ASC 230-10-50-4: no cash moved). Total cash is the same either way; the first company's figures are in its private P3 change log. |
| D-43 | Invoices and bills take a project, line by line (owner, 2026-10-22) | **Each line of an invoice or bill takes one project or none.** A document that spans N projects is N lines, each with its own amount; no percentage split, so every figure is exact. A cost truly shared among projects names none on the document and reaches projects only as allocation entries of their own under the company's monthly allocation convention, when the books have one (otherwise it stays untagged and the agent asks the human). Reasoning: job costing charges a job its traceable (direct) costs whole and reaches shared costs only by allocation on a stated basis. ASC 340-40-25-7 counts as directly related both the costs traceable to a contract (25-7(a), (b), (d), (e)) and allocations of costs that relate directly to it (25-7(c)); 25-8 expenses general and administrative costs unless chargeable to the customer. The books carry a traceable cost whole on the document's line and an allocated cost as entries under the allocation convention on its basis; neither is a percentage on the document. Behind its own journal feature, `document_line_projects`, so books that declared `projects` wait for the owner's next upgrade (rules.md B-37 item 8, B-27). | | D-43 | Invoices and bills take a project, line by line (owner, 2026-10-22) | **Each line of an invoice or bill takes one project or none.** A document that spans N projects is N lines, each with its own amount; no percentage split, so every figure is exact. A cost truly shared among projects names none on the document and reaches projects only as allocation entries of their own under the company's monthly allocation convention, when the books have one (otherwise it stays untagged and the agent asks the human). Reasoning: job costing charges a job its traceable (direct) costs whole and reaches shared costs only by allocation on a stated basis. ASC 340-40-25-7 counts as directly related both the costs traceable to a contract (25-7(a), (b), (d), (e)) and allocations of costs that relate directly to it (25-7(c)); 25-8 expenses general and administrative costs unless chargeable to the customer. The books carry a traceable cost whole on the document's line and an allocated cost as entries under the allocation convention on its basis; neither is a percentage on the document. Behind its own journal feature, `document_line_projects`, so books that declared `projects` wait for the owner's next upgrade (rules.md B-37 item 8, B-27). |
| D-44 | One way to serve books: the stdio server is gone (owner, 2026-10-23: "We should probably just delete the stdio path altogether. THere's no point in that. And it sitting there unused will just lead to bitrot. Nuke it?") | **Books are served only by the books server** (`wanderbooks server`: every book of the data root on its local sockets, `/b/{book}/mcp`, uploads and downloads), reached by agents through the bridge (`wanderbooks mcp --book ID`: stdio to the agent, the server's socket upstream), which stays. Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary and `ledger_mcp::run`'s stdio and `--http` serving of a bare data dir, with everything only that mode had: files the books make coming back inline (`content_base64` on a handed file; every one is now a download), books with no access log and no backups (`books.access_log`, `books.backup` and `books.back_up_now` are always offered, by scope), a settings file read and reloaded (`legacy_files`; old books bring theirs in by `books adopt`), the vault key named by a settings file or `--vault-key-file`, and the key-inside-the-books and books-kept-in-git checks of the books dir (a book in the data root keeps its own key). `books.settings` loses `source`, `books_toml`, `books_toml_exists`, `config_json_present`, `reload_error` and `loads`, and `books.start` its `settings_source` (always the books now). **Owner decisions, 2026-10-23 (asked with the question tool):** (a) `books.import_settings` is removed entirely (the tool, its types, handbook, docs and tests; 118 tools): old books still come in whole through `wanderbooks books adopt`, which reads their settings file once, and ledger-config keeps its settings-file parsing only as far as adoption uses it (`load` and the path-reading `effective_receipt_policy` are gone); `books.chart_template` gives its settings as `books.set_setting` changes. (b) The viewer's loose-books-folder mode is removed (`wanderbooks gui --data-dir DIR --books-toml FILE --vault-key-file FILE`, and following a settings file and `config.json`): the viewer opens only books of the data root (`wanderbooks gui ID`), reads their settings from the journal and their vault from their own `vault/` and `keys/`; tests open throwaway books through the same `Source` and loader the live viewer uses. (c) `wanderbooks dev-stdio` is deleted with no stub: it is an unknown command like any other word. An unknown tool is now a protocol error, as mcp-tools.md asks. Inline file input (`content_base64` with its `sha256`, at most 256 KiB) is unchanged: a separate decision. Every test that drove the removed mode drives a real books server on a data root of its own, reached as an agent reaches it (ledger-cli `tests/common`), and checks a handed file by its download. Supersedes D-18. | | D-44 | One way to serve books: the stdio server is gone (owner, 2026-10-23: "We should probably just delete the stdio path altogether. THere's no point in that. And it sitting there unused will just lead to bitrot. Nuke it?") | **Books are served only by the books server** (`wanderbooks server`: every book of the data root on its local sockets, `/b/{book}/mcp`, uploads and downloads), reached by agents through the bridge (`wanderbooks mcp --book ID`: stdio to the agent, the server's socket upstream), which stays. Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary and `ledger_mcp::run`'s stdio and `--http` serving of a bare data dir, with everything only that mode had: files the books make coming back inline (`content_base64` on a handed file; every one is now a download), books with no access log and no backups (`books.access_log`, `books.backup` and `books.back_up_now` are always offered, by scope), a settings file read and reloaded (`legacy_files`; old books bring theirs in by `books adopt`), the vault key named by a settings file or `--vault-key-file`, and the key-inside-the-books and books-kept-in-git checks of the books dir (a book in the data root keeps its own key). `books.settings` loses `source`, `books_toml`, `books_toml_exists`, `config_json_present`, `reload_error` and `loads`, and `books.start` its `settings_source` (always the books now). **Owner decisions, 2026-10-23 (asked with the question tool):** (a) `books.import_settings` is removed entirely (the tool, its types, handbook, docs and tests; 118 tools): old books still come in whole through `wanderbooks books adopt`, which reads their settings file once, and ledger-config keeps its settings-file parsing only as far as adoption uses it (`load` and the path-reading `effective_receipt_policy` are gone); `books.chart_template` gives its settings as `books.set_setting` changes. (b) The viewer's loose-books-folder mode is removed (`wanderbooks gui --data-dir DIR --books-toml FILE --vault-key-file FILE`, and following a settings file and `config.json`): the viewer opens only books of the data root (`wanderbooks gui ID`), reads their settings from the journal and their vault from their own `vault/` and `keys/`; tests open throwaway books through the same `Source` and loader the live viewer uses. (c) `wanderbooks dev-stdio` is deleted with no stub: it is an unknown command like any other word. An unknown tool is now a protocol error, as mcp-tools.md asks. Inline file input (`content_base64` with its `sha256`, at most 256 KiB) is unchanged: a separate decision (since made: D-46 removed it). Every test that drove the removed mode drives a real books server on a data root of its own, reached as an agent reaches it (ledger-cli `tests/common`), and checks a handed file by its download. Supersedes D-18. |
| D-45 | Receivables and payables, first class (P4b; owner, 2026-10-23: "we kinda need stronger invoice support. Like make it a first class citizen") | **A document's figures come from its tagged ledger lines that still stand**: every line on 1200 or 2000 carries its document; paid, outstanding and status are derived, so a reversal, void or write-off is right by construction and the control accounts always equal their documents (B-23). **Hand entries to 1200 and 2000 are refused.** Invoices: numbered `INV-0001` running, previewed then confirmed, terms and due dates, the issued PDF filed at issue, any payment methods printed (ACH, wire, links, QR codes, check, P2P handles, crypto, other). Bills: the vendor's number, terms, every line on its own account, paid on terms with early-payment discounts by the gross method. **The books never send, receive or charge** (as for payroll and taxes): the owner or a delegate (an agent) does, and the books record the send (who, channel, recipients, reference) and the payment (processor, reference, fee: gross to the receivable, fee to expense, net deposited). Bad debts by direct write-off (IRC §166). The slices and the owner's answers are in P4b. | | D-45 | Receivables and payables, first class (P4b; owner, 2026-10-23: "we kinda need stronger invoice support. Like make it a first class citizen") | **A document's figures come from its tagged ledger lines that still stand**: every line on 1200 or 2000 carries its document; paid, outstanding and status are derived, so a reversal, void or write-off is right by construction and the control accounts always equal their documents (B-23). **Hand entries to 1200 and 2000 are refused.** Invoices: numbered `INV-0001` running, previewed then confirmed, terms and due dates, the issued PDF filed at issue, any payment methods printed (ACH, wire, links, QR codes, check, P2P handles, crypto, other). Bills: the vendor's number, terms, every line on its own account, paid on terms with early-payment discounts by the gross method. **The books never send, receive or charge** (as for payroll and taxes): the owner or a delegate (an agent) does, and the books record the send (who, channel, recipients, reference) and the payment (processor, reference, fee: gross to the receivable, fee to expense, net deposited). Bad debts by direct write-off (IRC §166). The slices and the owner's answers are in P4b. |
| D-46 | Files only by handle: uploads in, downloads out (owner, 2026-10-23, via the question tool: "Uploads only"; and before it: "What we would DEFINITELY want to avoid is passing a bunch of BLOBs through AI context windows. That's just a pure waste.") | **Files never pass through an agent's context, in either direction.** In: every tool argument that carried a file's bytes or text inline is gone (`content_base64` and its `sha256` on `books.attach_receipt` and `books.file_document`, `w9_content_base64` and `w9_sha256` on `books.set_w9`, `csv_text` on `books.import_bank_csv` and `books.import_chart_csv`), with the 256 KiB inline limit; a file comes only by `books.begin_upload` and `wanderbooks upload`, named by its `upload_id` (`w9_upload_id`); the bytes stored already are still named by `existing_attachment_id`. Those five tools take no other argument, and any inline argument, on any tool, is refused before it runs as unknown, pointing to `books.begin_upload`. Out: every file a tool gave inline is handed over (D-29's `HandedFile`, a download command and its expiry): `books.export` in every format, `books.asset_labels`, and `books.get_receipt` view `bytes`; resource reads were and stay text (a receipt's resource is its meta). Small structured answers (reports as JSON data) are not files and stay. A token may keep 64 handed files at once (was 16), since every export is one now. Supersedes the plan's inline-bytes decision (section 2.1) and every planned inline path in 4.7. |
### 10.2 Deferred ### 10.2 Deferred