refactor!: files only by handle: uploads in, downloads out (D-46)
check / check (push) Canceled after 0s
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:
38 files changed
+1702
-1172
No files matched your search
@@ -39,7 +39,7 @@ fn print_usage() {
|
||||
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!(" 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!(" close close the books now: everything holding them lets go");
|
||||
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
|
||||
|
||||
@@ -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.
|
||||
let short = ledger_store::root::SocketPath::new(&home.root().join("run"), "mcp.sock").unwrap();
|
||||
let path = short.path().to_path_buf();
|
||||
@@ -173,11 +215,20 @@ async fn connect(home: &Home, book: &str, token: String) -> Client {
|
||||
.unwrap();
|
||||
let config =
|
||||
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"))
|
||||
.auth_header(token);
|
||||
ClientConfig::default()
|
||||
.serve(StreamableHttpClientTransport::with_client(http, config))
|
||||
.auth_header(token.clone());
|
||||
let client = ClientConfig::default()
|
||||
.serve(StreamableHttpClientTransport::with_client(
|
||||
http.clone(),
|
||||
config,
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
Agent {
|
||||
client,
|
||||
http,
|
||||
book: book.to_string(),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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")] {
|
||||
let _ = call(
|
||||
c,
|
||||
@@ -254,12 +305,19 @@ async fn post_one(c: &Client, n: u64) {
|
||||
.await
|
||||
.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(
|
||||
c,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
|
||||
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
|
||||
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
|
||||
json!({"entry_id": id, "upload_id": upload}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! 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
|
||||
//! 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
|
||||
//! its download, never inline.
|
||||
//! the agents' bridge sends; a file goes in by its upload and comes
|
||||
//! back by its download, never through the session (D-46).
|
||||
//! 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
|
||||
//! (uploads and downloads); ledger-mcp local.rs INVARIANTS 8 and
|
||||
@@ -23,6 +23,10 @@
|
||||
//! reads from the text, made with books.set_setting.
|
||||
//! 4. The harness answers nothing itself: every message a test is
|
||||
//! 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)]
|
||||
|
||||
@@ -511,6 +515,68 @@ impl Wire {
|
||||
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).
|
||||
pub fn end(&mut self) {
|
||||
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
|
||||
/// it, with the settings the test gives.
|
||||
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`
|
||||
/// command's id) from `book` with `token`: its bytes, checked against its
|
||||
/// size and SHA-256.
|
||||
|
||||
@@ -193,6 +193,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let r = self.rpc("tools/call", json!({"name": tool, "arguments": args}));
|
||||
assert!(
|
||||
@@ -258,12 +263,8 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
json!({"name": "Member A", "roles": ["member"]}),
|
||||
);
|
||||
// An unlinked receipt (attachment 1).
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgYSByZWNlaXB0",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a receipt")}),
|
||||
);
|
||||
let up = m.upload(b"%PDF-1.4 a receipt", "r.pdf", "application/pdf", "receipt");
|
||||
m.ok("books.attach_receipt", json!({"upload_id": up}));
|
||||
for (date, dr, cr, amount) in [
|
||||
("2026-01-02", "1000", "3000", "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 up = m.upload(pdf.as_bytes(), "r.pdf", "application/pdf", "receipt");
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(pdf.as_bytes()),
|
||||
"sha256": ledger_store::journal::sha256_hex(pdf.as_bytes())}),
|
||||
json!({"entry_id": id, "upload_id": up}),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
@@ -325,11 +325,15 @@ fn fixture(m: &mut Mcp, p: &Places) {
|
||||
"books.set_tin",
|
||||
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(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "s.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgYSBzdGF0ZW1lbnQ=",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 a statement")}),
|
||||
json!({"category": "statement", "upload_id": up}),
|
||||
);
|
||||
let _ = p;
|
||||
}
|
||||
@@ -624,19 +628,14 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
|
||||
.prop_map(Value::String)
|
||||
.boxed();
|
||||
}
|
||||
if n.contains("base64") {
|
||||
likely.extend(INLINE_FILES.map(String::from));
|
||||
}
|
||||
// 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.
|
||||
// A file's SHA-256 (books.begin_upload's): of one of the files
|
||||
// below (so some calls go on past the check), or not.
|
||||
if n.contains("sha256") {
|
||||
use base64::Engine;
|
||||
likely.extend(INLINE_FILES.iter().filter_map(|f| {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(f)
|
||||
.ok()
|
||||
.map(|b| ledger_store::journal::sha256_hex(&b))
|
||||
}));
|
||||
likely.extend(
|
||||
HOSTILE_FILES
|
||||
.iter()
|
||||
.map(|b| ledger_store::journal::sha256_hex(b)),
|
||||
);
|
||||
likely.push("0".repeat(64));
|
||||
likely.push("not hex".into());
|
||||
}
|
||||
@@ -705,14 +704,14 @@ fn string_for(name: &str, node: &Value, p: &Places) -> BoxedStrategy<Value> {
|
||||
.boxed()
|
||||
}
|
||||
|
||||
/// Files given inline (base64), hostile and not.
|
||||
const INLINE_FILES: [&str; 6] = [
|
||||
"JVBERi0xLjQ=",
|
||||
"",
|
||||
"!!!not base64",
|
||||
"AAAA",
|
||||
"NTcxLTkzLTI4NDY=",
|
||||
"RGF0ZSxBbW91bnQKMjAyNi0wMS0wMSwxCg==",
|
||||
/// Files an upload may declare, hostile and not (no tool takes a file's
|
||||
/// bytes: D-46).
|
||||
const HOSTILE_FILES: [&[u8]; 5] = [
|
||||
b"%PDF-1.4",
|
||||
b"",
|
||||
b"\0\0\0",
|
||||
b"571-93-2846",
|
||||
b"Date,Amount\n2026-01-01,1\n",
|
||||
];
|
||||
|
||||
/// 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",
|
||||
json!({"entry_id": id, "vendor": "Acme"}),
|
||||
);
|
||||
let up = m.upload(b"%PDF-1.4 huge", "h.pdf", "application/pdf", "receipt");
|
||||
pinned(
|
||||
&mut m,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "h.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": "JVBERi0xLjQgaHVnZQ==",
|
||||
"sha256": ledger_store::journal::sha256_hex(b"%PDF-1.4 huge")}),
|
||||
json!({"entry_id": id, "upload_id": up}),
|
||||
);
|
||||
pinned(
|
||||
&mut m,
|
||||
@@ -1292,11 +1290,6 @@ fn hostile_resource_uris_never_stop_the_server() {
|
||||
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.
|
||||
fn truncated(v: &Value) -> String {
|
||||
let s = v.to_string();
|
||||
|
||||
@@ -15,25 +15,15 @@ mod common;
|
||||
const CLIENTS: usize = 6;
|
||||
const ENTRIES: usize = 12;
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
for c in bytes.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16)
|
||||
| (u32::from(*c.get(1).unwrap_or(&0)) << 8)
|
||||
| u32::from(*c.get(2).unwrap_or(&0));
|
||||
for (i, shift) in [18, 12, 6, 0].iter().enumerate() {
|
||||
if i <= c.len() {
|
||||
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 {
|
||||
/// Draft a `cents` entry tagged `tag` on `c` (a session with `token`, of
|
||||
/// the server whose `run/` folder is `run`), with a receipt uploaded for
|
||||
/// it as an agent uploads one, if `with_receipt`.
|
||||
async fn draft(
|
||||
(c, run, token): (&Client, &std::path::Path, &str),
|
||||
tag: &str,
|
||||
cents: u64,
|
||||
with_receipt: bool,
|
||||
) -> u64 {
|
||||
let e = call(
|
||||
c,
|
||||
"books.draft_entry",
|
||||
@@ -53,12 +43,19 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
|
||||
.unwrap();
|
||||
}
|
||||
if with_receipt {
|
||||
let upload = common::upload_on(
|
||||
c,
|
||||
run,
|
||||
common::BOOK,
|
||||
token,
|
||||
tag.as_bytes(),
|
||||
(&format!("{tag}.pdf"), "application/pdf", "receipt"),
|
||||
)
|
||||
.await;
|
||||
call(
|
||||
c,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": format!("{tag}.pdf"),
|
||||
"mime_type": "application/pdf", "content_base64": b64(tag.as_bytes()),
|
||||
"sha256": ledger_store::journal::sha256_hex(tag.as_bytes())}),
|
||||
json!({"entry_id": id, "upload_id": upload}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -102,7 +99,15 @@ async fn agents_posting_at_once_leave_whole_books() {
|
||||
// Drafts every client will race to post.
|
||||
let mut contested = Vec::new();
|
||||
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();
|
||||
@@ -110,13 +115,13 @@ async fn agents_posting_at_once_leave_whole_books() {
|
||||
let (run, token) = (run.clone(), token.clone());
|
||||
let contested = contested.clone();
|
||||
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);
|
||||
for (k, &race) in contested.iter().enumerate() {
|
||||
// A receipted entry posts; a bare one is refused.
|
||||
let receipted = k % 3 != 0;
|
||||
let id = draft(
|
||||
&c,
|
||||
(&c, &run, &token),
|
||||
&format!("c{n} e{k}"),
|
||||
(n * 1000 + k + 1) as u64,
|
||||
receipted,
|
||||
|
||||
@@ -50,8 +50,10 @@ async fn chart(c: &Client) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft a receipted $1.00 + n entry; post it. Ok(entry id) or Err(why).
|
||||
async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
|
||||
/// Draft a receipted $1.00 + n entry on `c`, a session with acme's books
|
||||
/// 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(
|
||||
c,
|
||||
"books.draft_entry",
|
||||
@@ -68,12 +70,37 @@ async fn post_one(c: &Client, n: u64) -> Result<u64, String> {
|
||||
)
|
||||
.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(
|
||||
c,
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
|
||||
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
|
||||
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
|
||||
json!({"entry_id": id, "upload_id": upload}),
|
||||
)
|
||||
.await?;
|
||||
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!(a.to_string().contains("Supplies"), "{a}");
|
||||
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}))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -227,11 +256,12 @@ async fn closing_during_a_burst_of_posts_loses_nothing() {
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..4u64 {
|
||||
let c = connect(&home, "acme").await.unwrap();
|
||||
let (run, token) = (home.run(), home.token("acme"));
|
||||
tasks.push(tokio::spawn(async move {
|
||||
// Until the close turns it away: it can't finish first.
|
||||
let mut posted = Vec::new();
|
||||
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),
|
||||
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 full = connect(&home, "acme").await.unwrap();
|
||||
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;
|
||||
// A call that errs (bad arguments) and one refused by scope.
|
||||
assert!(
|
||||
@@ -1211,8 +1242,8 @@ async fn every_call_is_recorded_once_refusals_included() {
|
||||
assert_eq!(seqs, sorted, "newest first");
|
||||
let tools: Vec<&Value> = records.iter().filter(|r| r["action"] == "tool").collect();
|
||||
// Every tool call made so far, one record each: 3 accounts, the
|
||||
// posting (draft, two lines, post: up to four), the failed lookup,
|
||||
// the refusal.
|
||||
// posting (draft, two lines, its receipt's upload and filing, post),
|
||||
// the failed lookup, the refusal.
|
||||
let by = |subject: &str| tools.iter().filter(|r| r["subject"] == subject).count();
|
||||
assert_eq!(by("books.add_account"), 4);
|
||||
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["mime_type"], json!("application/pdf"));
|
||||
assert_eq!(meta["size_bytes"], json!(bytes.len()));
|
||||
assert!(meta.get("content_base64").is_none_or(Value::is_null));
|
||||
// Too large to come back inline: the command that saves a copy.
|
||||
assert!(meta.get("file").is_none_or(Value::is_null), "{meta}");
|
||||
// Its bytes: handed over (D-46), the command that saves a copy, and
|
||||
// the copy is the stored bytes.
|
||||
let big = call(
|
||||
&c,
|
||||
"books.get_receipt",
|
||||
@@ -1803,12 +1835,17 @@ async fn an_upload_is_filed_once_by_its_own_token_exactly_as_declared() {
|
||||
)
|
||||
.await
|
||||
.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!(
|
||||
big["download"],
|
||||
json!(format!(
|
||||
"wanderbooks download --book acme --token test {att} --to <a folder>"
|
||||
))
|
||||
common::fetch_export(&home, "acme", &home.token("acme"), &big["file"]).await,
|
||||
bytes
|
||||
);
|
||||
// The same bytes again: the attachment that holds them, no upload.
|
||||
let again = call(
|
||||
@@ -1943,11 +1980,19 @@ async fn stored_bytes_are_named_only_where_they_can_be_linked() {
|
||||
home.create("acme", "USD");
|
||||
let c = connect(&home, "acme").await.unwrap();
|
||||
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(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "import_source", "file_name": "chart.csv", "mime_type": "text/csv",
|
||||
"content_base64": b64(&csv), "sha256": ledger_store::journal::sha256_hex(&csv)}),
|
||||
json!({"category": "import_source", "upload_id": up}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1997,65 +2042,56 @@ async fn stored_bytes_are_named_only_where_they_can_be_linked() {
|
||||
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)]
|
||||
async fn bytes_given_inline_are_small_and_carry_their_hash() {
|
||||
let home = Home::new("inline");
|
||||
async fn a_file_comes_only_by_its_upload_and_goes_back_by_its_download() {
|
||||
let home = Home::new("handles");
|
||||
let server = Server::start(&home);
|
||||
home.create("acme", "USD");
|
||||
let c = connect(&home, "acme").await.unwrap();
|
||||
let token = home.token("acme");
|
||||
let small = pdf("small", 1000);
|
||||
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 mut args = json!({"category": "other", "file_name": "s.pdf",
|
||||
"mime_type": "application/pdf"});
|
||||
let mut args = json!({"category": "other"});
|
||||
args.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
args
|
||||
};
|
||||
for (extra, why) in [
|
||||
(json!({"content_base64": b64(&small)}), "sha256"),
|
||||
(
|
||||
json!({"content_base64": b64(&small), "sha256": ledger_store::journal::sha256_hex(b"x")}),
|
||||
"lost on the way",
|
||||
),
|
||||
(
|
||||
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"),
|
||||
for extra in [
|
||||
json!({"content_base64": "JVBERi0xLjQ="}),
|
||||
json!({"content_base64": "JVBERi0xLjQ=", "sha256": sha}),
|
||||
json!({"sha256": sha, "upload_id": up}),
|
||||
json!({"content_base64": "JVBERi0xLjQ=", "upload_id": up}),
|
||||
] {
|
||||
let e = call(&c, "books.file_document", file(extra))
|
||||
let e = call(&c, "books.file_document", file(extra.clone()))
|
||||
.await
|
||||
.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 over = pdf("over", 256 * 1024 + 1);
|
||||
let e = call(
|
||||
&c,
|
||||
"books.file_document",
|
||||
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.
|
||||
let e = call(&c, "books.file_document", file(json!({})))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.contains("upload_id"), "{e}");
|
||||
// A plain name and a real type, given or the upload's.
|
||||
for (name, mime, why) in [
|
||||
("../escape.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(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": name, "mime_type": mime,
|
||||
"content_base64": b64(&small), "sha256": sha}),
|
||||
json!({"category": "other", "file_name": name, "mime_type": mime, "upload_id": up}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -2078,20 +2113,15 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
|
||||
let e = call(
|
||||
&c,
|
||||
"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
|
||||
.unwrap_err();
|
||||
assert!(e.contains("at most 255 bytes"), "{e}");
|
||||
let at = pdf("at", 256 * 1024);
|
||||
let filed = call(
|
||||
&c,
|
||||
"books.file_document",
|
||||
file(json!({"content_base64": b64(&at),
|
||||
"sha256": ledger_store::journal::sha256_hex(&at).to_uppercase()})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Every refusal left the upload: filed now, under its own name.
|
||||
let filed = call(&c, "books.file_document", file(json!({"upload_id": up})))
|
||||
.await
|
||||
.unwrap();
|
||||
let att = filed["attachment_id"].as_u64().unwrap();
|
||||
let back = call(
|
||||
&c,
|
||||
@@ -2100,8 +2130,13 @@ async fn bytes_given_inline_are_small_and_carry_their_hash() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(back["content_base64"], json!(b64(&at)));
|
||||
assert!(back.get("download").is_none_or(Value::is_null));
|
||||
assert!(back.get("content_base64").is_none(), "{back}");
|
||||
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(
|
||||
&c,
|
||||
"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 token = home.token("acme");
|
||||
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(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "Résumé \"final\".pdf",
|
||||
"mime_type": "application/pdf", "content_base64": b64(&bytes),
|
||||
"sha256": ledger_store::journal::sha256_hex(&bytes)}),
|
||||
json!({"category": "other", "upload_id": up}),
|
||||
)
|
||||
.await
|
||||
.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!(records.iter().filter(|r| r["subject"] == json!(id)).count() >= 5);
|
||||
// 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(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": "p.zip", "mime_type": "application/zip",
|
||||
"content_base64": b64(&zip), "sha256": ledger_store::journal::sha256_hex(&zip)}),
|
||||
json!({"category": "tax", "upload_id": up}),
|
||||
)
|
||||
.await;
|
||||
assert!(zip.len() <= 256 * 1024, "small enough to give inline");
|
||||
let e = e.unwrap_err();
|
||||
assert!(e.contains("TIN"), "{e}");
|
||||
// Nothing loose in the book's folder: the export is in its own.
|
||||
|
||||
@@ -85,6 +85,21 @@ impl Mcp {
|
||||
assert!(file["expires_at"].is_string(), "{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]
|
||||
@@ -98,7 +113,7 @@ fn mcp_end_to_end() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
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");
|
||||
|
||||
// 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(
|
||||
"books.attach_receipt",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"file_name": "rent.png",
|
||||
"mime_type": "image/png",
|
||||
"content_base64": png,
|
||||
"sha256": sha(b"fake-png-bytes"),
|
||||
}),
|
||||
json!({"entry_id": entry_id, "upload_id": png}),
|
||||
);
|
||||
assert_eq!(attached["deduplicated"], false);
|
||||
assert_eq!(attached["sha256"].as_str().unwrap().len(), 64);
|
||||
@@ -243,7 +252,7 @@ fn mcp_end_to_end() {
|
||||
"books.export",
|
||||
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.
|
||||
let rev_preview = mcp.call(
|
||||
@@ -270,7 +279,7 @@ fn mcp_assets_end_to_end() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||||
@@ -300,7 +309,7 @@ fn mcp_assets_end_to_end() {
|
||||
"useful_life_months": 60,
|
||||
"cost_account": "1500",
|
||||
"accum_depr_account": "1501",
|
||||
"expense_account": "6100",
|
||||
"expense_account": "6100"
|
||||
}),
|
||||
);
|
||||
let asset_id = reg["asset_id"].as_u64().unwrap();
|
||||
@@ -358,7 +367,7 @@ fn mcp_assets_end_to_end() {
|
||||
"date": "2026-02-28",
|
||||
"proceeds": "60000",
|
||||
"proceeds_account": "1000",
|
||||
"gain_loss_account": "4900",
|
||||
"gain_loss_account": "4900"
|
||||
}),
|
||||
);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
@@ -376,7 +385,7 @@ fn mcp_assets_end_to_end() {
|
||||
"proceeds": "60000",
|
||||
"proceeds_account": "1000",
|
||||
"gain_loss_account": "4900",
|
||||
"confirmed": true,
|
||||
"confirmed": true
|
||||
}),
|
||||
);
|
||||
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");
|
||||
|
||||
// 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(
|
||||
"books.file_document",
|
||||
json!({
|
||||
"category": "legal",
|
||||
"file_name": "bill-of-sale.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"fake bill of sale"),
|
||||
"sha256": sha(b"fake bill of sale"),
|
||||
"upload_id": up1
|
||||
}),
|
||||
);
|
||||
let aid = bill["attachment_id"].as_u64().unwrap();
|
||||
@@ -404,7 +416,7 @@ fn mcp_assets_end_to_end() {
|
||||
"proceeds_account": "1000",
|
||||
"gain_loss_account": "4900",
|
||||
"confirmed": true,
|
||||
"receipt_attachment_ids": [aid],
|
||||
"receipt_attachment_ids": [aid]
|
||||
}),
|
||||
);
|
||||
assert_eq!(posted["status"], "posted");
|
||||
@@ -440,7 +452,7 @@ fn mcp_receipt_policy_rejects_split_entries() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||||
@@ -532,7 +544,7 @@ fn mcp_receipt_corrections() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -556,11 +568,10 @@ fn mcp_receipt_corrections() {
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": amount}),
|
||||
);
|
||||
let up2 = mcp.upload(receipt, "receipt.eml", "message/rfc822", "receipt");
|
||||
let att = mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "receipt.eml", "mime_type": "message/rfc822",
|
||||
"content_base64": base64_like(receipt),
|
||||
"sha256": sha(receipt)}),
|
||||
json!({"entry_id": id, "upload_id": up2}),
|
||||
);
|
||||
let posted = mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -584,11 +595,10 @@ fn mcp_receipt_corrections() {
|
||||
assert_eq!(err_json["remedy_tool"], "books.attach_receipt");
|
||||
|
||||
// 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(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": big, "file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"pdf-bytes"),
|
||||
"sha256": sha(b"pdf-bytes")}),
|
||||
json!({"entry_id": big, "upload_id": up3}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -669,7 +679,7 @@ fn mcp_resources() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "wanderbooks");
|
||||
@@ -722,7 +732,7 @@ fn init(mcp: &mut Mcp) -> Value {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -754,11 +764,11 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
"books.add_party",
|
||||
json!({"name": "Acme", "roles": ["contractor"]}),
|
||||
);
|
||||
let w9_upload = mcp.upload(w9, "w9.pdf", "application/pdf", "w9");
|
||||
mcp.call(
|
||||
"books.set_w9",
|
||||
json!({"party": "Acme", "received_on": "2026-01-03", "entity_type": "Individual",
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
|
||||
"tin": "291-12-2846", "tin_type": "ssn", "w9_upload_id": w9_upload}),
|
||||
);
|
||||
// Each comes back by its download.
|
||||
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.
|
||||
let journal = books.journal();
|
||||
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 [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "receipt.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(bytes),
|
||||
"sha256": sha(bytes)}),
|
||||
),
|
||||
("books.attach_receipt", json!({"upload_id": up})),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "statement.csv",
|
||||
"mime_type": "text/csv",
|
||||
"content_base64": base64_like(bytes),
|
||||
"sha256": sha(bytes)}),
|
||||
json!({"category": "statement", "upload_id": up}),
|
||||
),
|
||||
] {
|
||||
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_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv");
|
||||
let bytes = mcp.handed(&plain["csv"]);
|
||||
let up6 = mcp.upload(&bytes, "plain.csv", "text/csv", "document");
|
||||
mcp.call(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv",
|
||||
"content_base64": base64_like(&bytes), "sha256": sha(&bytes)}),
|
||||
json!({"category": "statement", "upload_id": up6}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -840,7 +844,7 @@ fn every_receipt_account_left_out_of_the_connect_text_is_in_books_settings() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -902,7 +906,7 @@ fn digest_bare_reads_once(attempt: u32) -> bool {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -972,7 +976,7 @@ fn handbook_tool_and_resources() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"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();
|
||||
@@ -1057,30 +1061,6 @@ fn handbook_tool_and_resources() {
|
||||
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]
|
||||
fn mcp_import_export() {
|
||||
let books = Books::new("import_export");
|
||||
@@ -1090,30 +1070,37 @@ fn mcp_import_export() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
|
||||
let chart = mcp.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n"}),
|
||||
);
|
||||
let up1 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n4000,Revenue,revenue,\n6010,Café 北京,expense,\n");
|
||||
let chart = mcp.call("books.import_chart_csv", json!({"upload_id": up1}));
|
||||
assert_eq!(chart["count"], 3);
|
||||
|
||||
// The IIF is plain ASCII text with CR LF line endings, inline; what it
|
||||
// writes differently from the books is in warnings (rules.md B-34).
|
||||
// The IIF is plain ASCII text with CR LF line endings, handed over
|
||||
// (D-46); what it writes differently from the books is in warnings
|
||||
// (rules.md B-34).
|
||||
let iif = mcp.call(
|
||||
"books.export",
|
||||
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("\r\nACCNT\tCafe Bei Jing\tEXP\t6010\r\n"),
|
||||
"{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!(
|
||||
iif["warnings"],
|
||||
json!(["account 6010's name \"Café 北京\" is written \"Cafe Bei Jing\""])
|
||||
@@ -1123,14 +1110,15 @@ fn mcp_import_export() {
|
||||
"books.export",
|
||||
json!({"format": "xlsx", "report": "trial_balance"}),
|
||||
);
|
||||
assert_eq!(xlsx["content_encoding"], "base64");
|
||||
assert!(xlsx["content"].as_str().unwrap().starts_with("UEs"));
|
||||
assert_eq!(
|
||||
xlsx["file"]["mime_type"],
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
);
|
||||
assert!(mcp.handed(&xlsx["file"]).starts_with(b"PK"), "a zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_statement_pdfs_and_cpa_package() {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let books = Books::new("statement-pdfs");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
@@ -1138,14 +1126,12 @@ fn mcp_statement_pdfs_and_cpa_package() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n"}),
|
||||
);
|
||||
let up2 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n3000,Owner Capital,equity,\n6000,Software,expense,\n");
|
||||
mcp.call("books.import_chart_csv", json!({"upload_id": up2}));
|
||||
for (desc, debit, credit, amount) in [
|
||||
("Owner contribution", "1000", "3000", "1000.00"),
|
||||
("Domain", "6000", "1000", "12.00"),
|
||||
@@ -1163,11 +1149,10 @@ fn mcp_statement_pdfs_and_cpa_package() {
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": credit, "side": "credit", "amount": amount}),
|
||||
);
|
||||
let up = mcp.upload(desc.as_bytes(), "r.pdf", "application/pdf", "receipt");
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(desc.as_bytes()),
|
||||
"sha256": sha(desc.as_bytes())}),
|
||||
json!({"entry_id": id, "upload_id": up}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -1187,8 +1172,8 @@ fn mcp_statement_pdfs_and_cpa_package() {
|
||||
.unwrap()
|
||||
.extend(extra.as_object().unwrap().clone());
|
||||
let out = mcp.call("books.export", args);
|
||||
assert_eq!(out["content_encoding"], "base64");
|
||||
let bytes = b64.decode(out["content"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(out["file"]["mime_type"], "application/pdf");
|
||||
let bytes = mcp.handed(&out["file"]);
|
||||
assert!(bytes.starts_with(b"%PDF"), "{report} is not a PDF");
|
||||
}
|
||||
// The cash flow statement reconciles to named cash accounts: required.
|
||||
@@ -1297,14 +1282,12 @@ fn mcp_period_statements() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
|
||||
);
|
||||
let up3 = mcp.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n");
|
||||
mcp.call("books.import_chart_csv", json!({"upload_id": up3}));
|
||||
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
@@ -1328,11 +1311,10 @@ fn mcp_period_statements() {
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
);
|
||||
assert!(err.contains("5.00"), "{err}");
|
||||
let up7 = mcp.upload(b"r", "r.pdf", "application/pdf", "receipt");
|
||||
mcp.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"r"),
|
||||
"sha256": sha(b"r")}),
|
||||
json!({"entry_id": id, "upload_id": up7}),
|
||||
);
|
||||
mcp.call(
|
||||
"books.post_entry",
|
||||
@@ -1388,7 +1370,7 @@ fn mcp_period_statements() {
|
||||
"books.export",
|
||||
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];
|
||||
assert_eq!(cash["beginning_balance"], "1004.00");
|
||||
assert_eq!(cash["postings"][0]["split"], "6000 Software");
|
||||
@@ -1403,7 +1385,7 @@ fn mcp_period_statements() {
|
||||
"books.export",
|
||||
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["total"]["contributions"], "1000.00");
|
||||
assert_eq!(me["total"]["net_income"], "3.50");
|
||||
@@ -1412,7 +1394,7 @@ fn mcp_period_statements() {
|
||||
"books.export",
|
||||
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
|
||||
// account, not Retained Earnings. The preview is exactly what posts.
|
||||
@@ -1442,7 +1424,7 @@ fn mcp_period_statements() {
|
||||
"books.export",
|
||||
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"]["net_income"], "4.00");
|
||||
assert_eq!(me["total"]["ending"], "1004.00");
|
||||
@@ -1488,17 +1470,15 @@ fn mcp_period_statements() {
|
||||
"books.export",
|
||||
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["income"][0]["book"], "6.50");
|
||||
assert_eq!(ts["deductions"][0]["category"], "other_deductions");
|
||||
assert_eq!(ts["uncategorized"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(ts["tax_net"], "3.50");
|
||||
// A chart import validates categories before creating anything.
|
||||
let err = mcp.call_err(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n"}),
|
||||
);
|
||||
let up4 = mcp.csv_upload("code,name,type,parent_code,tax_category\n6100,Meals,expense,,meals\n4100,Fees,revenue,,commissions_fees\n");
|
||||
let err = mcp.call_err("books.import_chart_csv", json!({"upload_id": up4}));
|
||||
assert!(err.contains("4100"), "{err}");
|
||||
let chart = mcp.call("books.chart", json!({}));
|
||||
assert!(
|
||||
@@ -1555,7 +1535,8 @@ fn mcp_partnership_from_template() {
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
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(
|
||||
"books.set_setting",
|
||||
json!({"changes": settings, "decided_by": "Ann"}),
|
||||
@@ -1589,7 +1570,7 @@ fn mcp_partnership_from_template() {
|
||||
"books.export",
|
||||
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]["ending"], "1150.00");
|
||||
|
||||
@@ -1662,7 +1643,8 @@ fn close_period_shows_each_members_running_split() {
|
||||
mcp
|
||||
};
|
||||
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(
|
||||
"books.set_setting",
|
||||
json!({"changes": t["settings"], "decided_by": "Ann"}),
|
||||
@@ -1759,7 +1741,7 @@ fn close_period_shows_each_members_running_split() {
|
||||
"books.export",
|
||||
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"][1]["ending"], "0.01");
|
||||
}
|
||||
@@ -1934,7 +1916,7 @@ fn mcp_schedules_and_recurring() {
|
||||
"months": 3,
|
||||
"balance_account": "1300",
|
||||
"pl_account": "6300",
|
||||
"convention": "monthly_even",
|
||||
"convention": "monthly_even"
|
||||
}),
|
||||
);
|
||||
assert_eq!(s["convention"], "monthly_even");
|
||||
@@ -1950,7 +1932,7 @@ fn mcp_schedules_and_recurring() {
|
||||
json!({
|
||||
"kind": "prepaid_expense", "description": "x", "total": "10",
|
||||
"start": "2026-11-01", "months": 2,
|
||||
"balance_account": "6300", "pl_account": "6300",
|
||||
"balance_account": "6300", "pl_account": "6300"
|
||||
}),
|
||||
);
|
||||
assert!(err.contains("Asset"), "{err}");
|
||||
@@ -1989,7 +1971,7 @@ fn mcp_schedules_and_recurring() {
|
||||
"lines": [
|
||||
{"account": "6600", "side": "debit", "amount": "2000.00"},
|
||||
{"account": "1000", "side": "credit", "amount": "2000.00"},
|
||||
],
|
||||
]
|
||||
}),
|
||||
);
|
||||
let drafts = mcp.call("books.run_recurring", json!({"through": "2026-12-15"}));
|
||||
@@ -2025,7 +2007,7 @@ fn mcp_payout_gross_up() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -2082,14 +2064,17 @@ fn mcp_payout_gross_up() {
|
||||
|
||||
// The statement is the receipt; then it posts like any entry.
|
||||
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(
|
||||
"books.attach_receipt",
|
||||
json!({
|
||||
"entry_id": entry_id,
|
||||
"file_name": "payout.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 statement"),
|
||||
"sha256": sha(b"%PDF-1.4 statement"),
|
||||
"upload_id": up8
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
@@ -2178,14 +2163,17 @@ fn mcp_vendor_1099() {
|
||||
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(
|
||||
"books.attach_receipt",
|
||||
json!({
|
||||
"entry_id": id,
|
||||
"file_name": "invoice.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"content_base64": base64_like(b"%PDF-1.4 invoice"),
|
||||
"sha256": sha(b"%PDF-1.4 invoice"),
|
||||
"upload_id": up9
|
||||
}),
|
||||
);
|
||||
mcp.call(
|
||||
@@ -2283,7 +2271,7 @@ fn mcp_receipts_always_required_when_threshold_is_zero() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"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();
|
||||
@@ -2341,7 +2329,7 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -2440,7 +2428,7 @@ fn cash_flow_refuses_contradictory_roles() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -2487,7 +2475,7 @@ fn a_chart_file_with_a_bad_row_imports_nothing() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
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.
|
||||
"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_eq!(count(&mut mcp), before, "half-imported: {bad}");
|
||||
}
|
||||
let ok = mcp.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,Checking,asset,1000\n"}),
|
||||
);
|
||||
let up8 =
|
||||
mcp.csv_upload("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);
|
||||
}
|
||||
|
||||
@@ -2529,7 +2517,7 @@ fn bank_import_reads_dates_rules_and_files_whole() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
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}),
|
||||
);
|
||||
}
|
||||
let base = |csv: &str| {
|
||||
// The CSV's upload: one refused is left for the next call.
|
||||
let base = |upload: &str| {
|
||||
json!({
|
||||
"csv_text": csv,
|
||||
"upload_id": upload,
|
||||
"bank_account_code": "1000",
|
||||
"default_offset_code": "6000",
|
||||
"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.
|
||||
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}");
|
||||
let mut args = base(ambiguous);
|
||||
let mut args = base(&up);
|
||||
args["date_order"] = json!("day_first");
|
||||
let out = mcp.call("books.import_bank_csv", args);
|
||||
assert_eq!(out["imported"][0]["date"], "2026-04-03");
|
||||
// Both patterns match "Coffee beans": the longer wins, every time.
|
||||
for _ in 0..5 {
|
||||
let out = mcp.call(
|
||||
"books.import_bank_csv",
|
||||
base("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n"),
|
||||
);
|
||||
let up = mcp.csv_upload("Date,Description,Amount\n2026-05-01,Coffee beans 2kg,-30.00\n");
|
||||
let out = mcp.call("books.import_bank_csv", base(&up));
|
||||
assert_eq!(out["imported"][0]["offset_account"], "6200");
|
||||
}
|
||||
// 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",
|
||||
json!({"period_id": p["period_id"], "confirmed": true}),
|
||||
);
|
||||
let err = mcp.call_err(
|
||||
"books.import_bank_csv",
|
||||
base(
|
||||
"Date,Description,Amount\n2026-06-01,Fine,-1.00\n2026-01-15,In a closed month,-2.00\n",
|
||||
),
|
||||
let up = mcp.csv_upload(
|
||||
"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}");
|
||||
let journal = String::from_utf8(books.journal()).unwrap();
|
||||
assert!(
|
||||
@@ -2608,7 +2594,7 @@ fn renumbering_an_account_keeps_its_history() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -2690,7 +2676,7 @@ fn renumbering_an_account_keeps_its_history() {
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"}
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
@@ -2756,10 +2742,8 @@ fn one_default_period_everywhere_defect_16() {
|
||||
&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",
|
||||
);
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
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 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");
|
||||
mcp.call("books.import_chart_csv", json!({"upload_id": up9}));
|
||||
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 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",
|
||||
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}");
|
||||
}
|
||||
// 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",
|
||||
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
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -2806,7 +2790,7 @@ fn one_default_period_everywhere_defect_16() {
|
||||
"books.export",
|
||||
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");
|
||||
// An inverted range is refused by the journal too (review R1).
|
||||
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"] {
|
||||
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));
|
||||
}
|
||||
let after = today();
|
||||
@@ -2886,10 +2870,8 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
);
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
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 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");
|
||||
mcp.call("books.import_chart_csv", json!({"upload_id": up10}));
|
||||
let entries = [
|
||||
("2025-03-15", "1000", "3000", "500.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"}),
|
||||
);
|
||||
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
|
||||
.as_array()
|
||||
.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"}));
|
||||
assert_eq!(iif["report"], report.to_lowercase());
|
||||
assert_eq!(
|
||||
iif_docnums(iif["content"].as_str().unwrap()),
|
||||
iif_docnums(&mcp.handed_text(&iif["file"])),
|
||||
every,
|
||||
"{report}"
|
||||
);
|
||||
@@ -2986,10 +2968,8 @@ fn rename_account_end_to_end_defect_15() {
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
);
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
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"}),
|
||||
);
|
||||
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");
|
||||
mcp.call("books.import_chart_csv", json!({"upload_id": up11}));
|
||||
post_pair(&mut mcp, "2025-11-04", "1000", "3000", "800.00");
|
||||
post_pair(&mut mcp, "2025-11-20", "6150", "1000", "29.00");
|
||||
let verified = mcp.call("books.verify", json!({"blobs": false}));
|
||||
@@ -3045,14 +3025,14 @@ fn rename_account_end_to_end_defect_15() {
|
||||
"books.export",
|
||||
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]["amount"], "29.00");
|
||||
let iif = mcp.call(
|
||||
"books.export",
|
||||
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}");
|
||||
// A renamed parent: its children's full names follow, and the note
|
||||
// says so.
|
||||
@@ -3112,10 +3092,8 @@ fn rename_says_what_the_new_name_moves_defect_15() {
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n",
|
||||
);
|
||||
mcp.call(
|
||||
"books.import_chart_csv",
|
||||
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"}),
|
||||
);
|
||||
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");
|
||||
mcp.call("books.import_chart_csv", json!({"upload_id": up12}));
|
||||
mcp.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "software-note", "kind": "note", "title": "Subscriptions",
|
||||
@@ -3258,3 +3236,177 @@ fn mcp_daily_schedule() {
|
||||
);
|
||||
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}");
|
||||
}
|
||||
@@ -301,13 +301,19 @@ struct Agent {
|
||||
next: u64,
|
||||
/// Notifications received.
|
||||
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 {
|
||||
fn start(home: &Home, book: &str) -> Self {
|
||||
let mut c = home.cmd("mcp");
|
||||
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 {
|
||||
@@ -315,9 +321,50 @@ impl Agent {
|
||||
run: Running::start(c),
|
||||
next: 1,
|
||||
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) {
|
||||
let stdin = self.run.child.stdin.as_mut().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"}),
|
||||
)?;
|
||||
}
|
||||
// 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(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": id, "file_name": format!("r{n}.txt"),
|
||||
"mime_type": "text/plain", "content_base64": "cmVjZWlwdA==",
|
||||
"sha256": "6f32860910ca0fb2a20c7fda143666b09dbf8db5238195c90a586fb542ff0cad"}),
|
||||
json!({"entry_id": id, "upload_id": upload}),
|
||||
)?;
|
||||
a.call(
|
||||
"books.post_entry",
|
||||
@@ -2257,15 +2305,15 @@ fn an_agent_sends_and_saves_files_with_the_commands_it_is_given() {
|
||||
)
|
||||
.unwrap();
|
||||
let att = filed["attachment_id"].as_u64().unwrap();
|
||||
// Too big to come back inline: the command saves a copy, beside any
|
||||
// file of its name, never over it.
|
||||
// Handed over (D-46): the command saves a copy, beside any file of
|
||||
// its name, never over it.
|
||||
let back = a
|
||||
.call(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": att, "view": "bytes"}),
|
||||
)
|
||||
.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 {
|
||||
let out = run_given(
|
||||
&home,
|
||||
@@ -2348,7 +2396,7 @@ fn the_commands_given_run_as_the_agents_own_token() {
|
||||
json!({"attachment_id": filed["attachment_id"], "view": "bytes"}),
|
||||
)
|
||||
.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}");
|
||||
let out = run_given(
|
||||
&home,
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
//! folder of a data root of the test's own; then the store is closed and
|
||||
//! the server started on that root.
|
||||
|
||||
use base64::Engine as _;
|
||||
use common::{Place, Wire};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital,
|
||||
@@ -148,25 +147,21 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// `books.export`, returning the content (base64-decoded when the
|
||||
/// server says it is base64).
|
||||
/// `books.export`'s file: handed over (D-46), fetched by its
|
||||
/// download, never its bytes in the answer.
|
||||
fn export(&mut self, args: Value) -> Vec<u8> {
|
||||
let out = self.ok("books.export", args);
|
||||
let content = out["content"].as_str().unwrap();
|
||||
if out["content_encoding"] == "base64" {
|
||||
b64d(content)
|
||||
} else {
|
||||
content.as_bytes().to_vec()
|
||||
}
|
||||
let out = self.ok("books.export", args.clone());
|
||||
assert!(out.get("content").is_none(), "{args} came back inline");
|
||||
self.handed(&out["file"])
|
||||
}
|
||||
|
||||
fn export_text(&mut self, args: Value) -> String {
|
||||
let out = self.ok("books.export", args.clone());
|
||||
assert!(
|
||||
out.get("content_encoding").is_none_or(|e| e.is_null()),
|
||||
"text export {args} came back encoded"
|
||||
);
|
||||
out["content"].as_str().unwrap().to_string()
|
||||
String::from_utf8(self.export(args)).expect("a text export")
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// The viewer command the last `books.show` wrote, in the books' own
|
||||
@@ -185,16 +180,6 @@ impl Mcp {
|
||||
|
||||
// ---------------------------------------------------------------- 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 {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
@@ -1440,7 +1425,8 @@ fn iif_declares_accounts_by_role_and_balances_every_transaction() {
|
||||
"books.export",
|
||||
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
|
||||
.posted_entries()
|
||||
.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));
|
||||
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']));
|
||||
|
||||
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"}),
|
||||
);
|
||||
}
|
||||
let up = m.upload(b"contribution", "c.txt", "text/plain", "receipt");
|
||||
m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64e(b"contribution"), "sha256": sha256(b"contribution")}),
|
||||
json!({"entry_id": e, "upload_id": up}),
|
||||
);
|
||||
m.ok(
|
||||
"books.post_entry",
|
||||
@@ -2739,7 +2725,7 @@ fn resources_read_back_as_the_matching_tools() {
|
||||
"books.get_receipt",
|
||||
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 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 first: &[u8] = b"%PDF-1.4 first";
|
||||
let second: &[u8] = b"\x89PNG second \x00\xff";
|
||||
let up = m.upload(first, "first.pdf", "application/pdf", "receipt");
|
||||
let a = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(first), "sha256": sha256(first),
|
||||
json!({"entry_id": e, "upload_id": up,
|
||||
"description": "Receipt ID 1007; Paid from: Quillmere"}),
|
||||
);
|
||||
let up = m.upload(second, "oa.png", "image/png", "document");
|
||||
let b = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png",
|
||||
"content_base64": b64e(second), "sha256": sha256(second),
|
||||
"description": "Operating agreement"}),
|
||||
json!({"category": "legal", "upload_id": up, "description": "Operating agreement"}),
|
||||
);
|
||||
let up = m.upload(
|
||||
b"EIN letter bytes",
|
||||
"ein-letter.pdf",
|
||||
"application/pdf",
|
||||
"document",
|
||||
);
|
||||
let c = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": "ein-letter.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64e(b"EIN letter bytes"), "sha256": sha256(b"EIN letter bytes"),
|
||||
"description": "IRS CP 575"}),
|
||||
json!({"category": "tax", "upload_id": up, "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(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "renamed.png", "mime_type": "image/png",
|
||||
"content_base64": b64e(b"a scanned receipt"),
|
||||
"sha256": sha256(b"a scanned receipt"), "description": "scan"}),
|
||||
json!({"entry_id": e, "upload_id": up, "file_name": "renamed.png",
|
||||
"mime_type": "image/png", "description": "scan"}),
|
||||
);
|
||||
|
||||
// (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();
|
||||
assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes");
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
|
||||
assert!(
|
||||
meta.get("content_base64").is_none(),
|
||||
"{name}: meta, no bytes"
|
||||
);
|
||||
assert!(meta.get("file").is_none(), "{name}: meta, no file");
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(got["attachment_id"], json!(id));
|
||||
assert_eq!(
|
||||
b64d(got["content_base64"].as_str().unwrap()),
|
||||
bytes,
|
||||
"{name}"
|
||||
);
|
||||
// Handed over (D-46): its download is the bytes, under its name.
|
||||
assert!(got.get("content_base64").is_none(), "{name}: {got}");
|
||||
assert_eq!(m.handed(&got["file"]), bytes, "{name}");
|
||||
assert_eq!(got["file"]["file_name"], name);
|
||||
assert_eq!(got["file"]["mime_type"], mime);
|
||||
let mut without = got.clone();
|
||||
without.as_object_mut().unwrap().remove("content_base64");
|
||||
assert_eq!(without, meta, "{name}: view bytes is meta and the bytes");
|
||||
without.as_object_mut().unwrap().remove("file");
|
||||
assert_eq!(without, meta, "{name}: view bytes is meta and the file");
|
||||
assert_eq!(got["sha256"], sha256(bytes));
|
||||
assert_eq!(got["file_name"], name);
|
||||
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
|
||||
/// the limit is accepted by books.attach_receipt and books.file_document
|
||||
/// and stored whole, one byte more is refused by both, pointing at
|
||||
/// books.begin_upload, and nothing is stored. Kills: the limit comparison
|
||||
/// in take_file.
|
||||
/// No file is given inline any more (D-46): one past the old inline limit
|
||||
/// (256 KiB) is filed by upload through books.attach_receipt and
|
||||
/// books.file_document and stored whole, and comes back whole by its
|
||||
/// download; the inline arguments are refused by both as unknown,
|
||||
/// pointing at books.begin_upload, and nothing is stored.
|
||||
#[test]
|
||||
fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
fn files_past_the_old_inline_limit_go_by_upload_and_come_back_by_download() {
|
||||
const OLD_LIMIT: usize = 256 * 1024;
|
||||
let dir = test_dir("limits");
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
let at = vec![b'a'; LIMIT];
|
||||
let out = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "big.bin", "mime_type": "application/octet-stream",
|
||||
"content_base64": b64e(&at), "sha256": sha256(&at)}),
|
||||
);
|
||||
let at = vec![b'a'; OLD_LIMIT + 1];
|
||||
let up = m.upload(&at, "big.bin", "application/octet-stream", "receipt");
|
||||
let out = m.ok("books.attach_receipt", json!({"upload_id": up}));
|
||||
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(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "big.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&doc),
|
||||
"sha256": sha256(&doc)}),
|
||||
json!({"category": "other", "upload_id": up}),
|
||||
);
|
||||
let doc_id = out["attachment_id"].as_u64().unwrap();
|
||||
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))
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole");
|
||||
// And it comes back inline, whole.
|
||||
assert_eq!(row["size_bytes"], json!(OLD_LIMIT + 1), "stored whole");
|
||||
// And it comes back by its download, whole.
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
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 over = vec![b'c'; LIMIT + 1];
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "over.bin", "mime_type": "application/octet-stream",
|
||||
"content_base64": b64e(&over), "sha256": sha256(&over)}),
|
||||
json!({"file_name": "s.bin", "mime_type": "application/octet-stream",
|
||||
"content_base64": "YQ==", "sha256": sha256(b"a")}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "over.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": b64e(&over),
|
||||
"sha256": sha256(&over)}),
|
||||
json!({"category": "other", "file_name": "s.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": "YQ==",
|
||||
"sha256": sha256(b"a")}),
|
||||
),
|
||||
] {
|
||||
let why = m.refuse(tool, args);
|
||||
assert!(
|
||||
why.contains("books.begin_upload") && why.contains(&(LIMIT + 1).to_string()),
|
||||
why.contains("books.begin_upload") && why.contains("unknown argument"),
|
||||
"{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,
|
||||
/// or an upload; never both, never neither. A sha256 missing, or not the
|
||||
/// bytes' own (a byte lost on the way), or base64 that isn't, is refused
|
||||
/// (guard base64_corrupt); inline bytes need their file_name and
|
||||
/// mime_type. An upload_id that names no upload is refused, and
|
||||
/// books.begin_upload names the attachment for bytes stored already, and
|
||||
/// begins an upload for new ones (journaling nothing). Every refusal
|
||||
/// A filing tool takes its file only as an upload (D-46): with none it is
|
||||
/// refused, naming upload_id; an argument that once carried the bytes
|
||||
/// inline (content_base64, sha256, csv_text), alone or beside an upload,
|
||||
/// is refused as unknown, pointing at books.begin_upload; an upload_id
|
||||
/// that names no upload is refused. books.begin_upload names the
|
||||
/// attachment for bytes stored already (its sha256 in any case), and
|
||||
/// begins an upload for new ones, journaling nothing. Every refusal
|
||||
/// stores nothing.
|
||||
#[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 s = new_store(&dir);
|
||||
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 journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let before = journal();
|
||||
let up = m.upload(bytes, "r.pdf", "application/pdf", "receipt");
|
||||
for (tool, base) in [
|
||||
("books.attach_receipt", json!({})),
|
||||
("books.file_document", json!({"category": "other"})),
|
||||
] {
|
||||
let with = |extra: Value| {
|
||||
let mut args = base.clone();
|
||||
for (k, v) in json!({"file_name": "r.pdf", "mime_type": "application/pdf"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.chain(extra.as_object().unwrap())
|
||||
{
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
args
|
||||
};
|
||||
for (args, said) in [
|
||||
// Neither, both.
|
||||
(with(json!({})), "books.begin_upload"),
|
||||
(with(json!({})), "upload_id"),
|
||||
(
|
||||
with(json!({"upload_id": "u1", "content_base64": b64e(bytes),
|
||||
"sha256": sha256(bytes)})),
|
||||
"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": "JVBERi0xLjQ=", "sha256": sha256(bytes)})),
|
||||
"books.begin_upload",
|
||||
),
|
||||
(
|
||||
with(json!({"content_base64": b64e(bytes), "sha256": "not hex"})),
|
||||
"base64_corrupt",
|
||||
with(json!({"content_base64": "JVBERi0xLjQ="})),
|
||||
"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)})),
|
||||
"base64_corrupt",
|
||||
with(json!({"upload_id": up, "sha256": sha256(bytes)})),
|
||||
"unknown argument",
|
||||
),
|
||||
// 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());
|
||||
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
|
||||
// hash.
|
||||
let out = m.ok(
|
||||
"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!(journal(), before, "a refusal stored something");
|
||||
// Every refusal left the upload: filed now.
|
||||
let out = m.ok("books.attach_receipt", json!({"upload_id": up}));
|
||||
assert_eq!(out["sha256"], sha256(bytes));
|
||||
let id = out["attachment_id"].as_u64().unwrap();
|
||||
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)));
|
||||
assert!(begun["upload_id"].is_string(), "{begun}");
|
||||
assert_eq!(journal(), stored, "beginning an upload journals nothing");
|
||||
// The CSV imports take csv_text or an upload, never both, never
|
||||
// neither; an upload there is none of is refused.
|
||||
// The CSV imports take only an upload; the text inline is refused as
|
||||
// unknown, alone or beside one; an upload there is none of is refused.
|
||||
for (tool, base) in [
|
||||
(
|
||||
"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!({})),
|
||||
] {
|
||||
for (extra, said) in [
|
||||
(json!({}), "csv_text"),
|
||||
(json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"),
|
||||
(json!({}), "upload_id"),
|
||||
(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\""),
|
||||
] {
|
||||
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");
|
||||
}
|
||||
|
||||
/// books.get_receipt with view "bytes" gives a file's bytes inline up to
|
||||
/// 256 KiB; a larger one, its download (a stored file, fetched with
|
||||
/// books:read by `GET /b/{book}/a/{id}`: those very bytes), never its
|
||||
/// bytes inline.
|
||||
/// books.get_receipt with view "bytes" hands every file over, whatever its
|
||||
/// size (D-46): its download, made for the caller's token, is those very
|
||||
/// bytes, as is the stored file's own route (`GET /b/{book}/a/{id}`, for
|
||||
/// books:read); the answer never carries the bytes.
|
||||
#[test]
|
||||
fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
fn get_receipt_hands_every_file_over_by_its_download() {
|
||||
const OLD_LIMIT: usize = 256 * 1024;
|
||||
let dir = test_dir("get-bytes");
|
||||
let mut s = new_store(&dir);
|
||||
let at = vec![b'x'; LIMIT];
|
||||
let over = vec![b'y'; LIMIT + 1];
|
||||
let at = vec![b'x'; OLD_LIMIT];
|
||||
let over = vec![b'y'; OLD_LIMIT + 1];
|
||||
let (at_id, _, _) = s
|
||||
.attach_receipt(None, "at.bin", "application/octet-stream", &at, None)
|
||||
.unwrap();
|
||||
@@ -3098,27 +3070,29 @@ fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() {
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": at_id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at);
|
||||
let big = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": over_id, "view": "bytes"}),
|
||||
);
|
||||
assert!(big.get("content_base64").is_none(), "{big}");
|
||||
let download = big["download"].as_str().unwrap_or_else(|| panic!("{big}"));
|
||||
assert!(download.contains(&format!(" {over_id} ")), "{download}");
|
||||
assert_eq!(big["size_bytes"], json!(LIMIT + 1));
|
||||
assert_eq!(big["sha256"], sha256(&over));
|
||||
let (status, bytes) = m.wire.get(&format!("a/{over_id}"));
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(bytes, over);
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id}));
|
||||
assert_eq!(meta["size_bytes"], json!(LIMIT + 1));
|
||||
assert_eq!(meta["sha256"], sha256(&over));
|
||||
assert!(meta.get("content_base64").is_none(), "{meta}");
|
||||
for (id, bytes, name) in [(at_id, &at, "at.bin"), (over_id, &over, "over.bin")] {
|
||||
let got = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": id, "view": "bytes"}),
|
||||
);
|
||||
assert!(got.get("content_base64").is_none(), "{got}");
|
||||
assert!(got.get("download").is_none(), "{got}");
|
||||
let file = &got["file"];
|
||||
assert_eq!(file["file_name"], name);
|
||||
assert_eq!(file["mime_type"], "application/octet-stream");
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()));
|
||||
assert_eq!(file["sha256"], sha256(bytes));
|
||||
let download = file["download"].as_str().unwrap_or_else(|| panic!("{got}"));
|
||||
assert!(download.contains(" ex_"), "{download}");
|
||||
assert_eq!(&m.handed(file), bytes);
|
||||
let (status, stored) = m.wire.get(&format!("a/{id}"));
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(&stored, bytes);
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": id}));
|
||||
assert_eq!(meta["size_bytes"], json!(bytes.len()));
|
||||
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
|
||||
@@ -3131,13 +3105,15 @@ fn bank_import_puts_money_in_and_out_on_the_right_sides() {
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
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(
|
||||
"books.import_bank_csv",
|
||||
json!({
|
||||
"csv_text": "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",
|
||||
"upload_id": up,
|
||||
"bank_account_code": "1000",
|
||||
"default_offset_code": "4000",
|
||||
"rules": {"coffee": "6010", "starbucks coffee": "6020"},
|
||||
|
||||
@@ -202,7 +202,7 @@ fn the_tools_figures_are_the_engines_on_every_date() {
|
||||
"books.export",
|
||||
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"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -263,7 +263,7 @@ fn the_tools_figures_are_the_engines_on_every_date() {
|
||||
"books.export",
|
||||
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.
|
||||
for (name, a) in &parties {
|
||||
|
||||
@@ -282,12 +282,15 @@ impl Mcp {
|
||||
}
|
||||
|
||||
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(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "bill-of-sale.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"bill of sale"),
|
||||
"sha256": ledger_store::journal::sha256_hex(b"bill of sale"),
|
||||
"description": "Bill of sale"}),
|
||||
json!({"category": "legal", "upload_id": upload, "description": "Bill of sale"}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
@@ -313,49 +316,6 @@ fn books(name: &str) -> Mcp {
|
||||
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.
|
||||
fn canon(s: &str) -> String {
|
||||
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 r = m.read_only("books.asset_labels", json!({"assets": [t3, t1]}));
|
||||
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!(!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.
|
||||
let r = m.read_only("books.asset_labels", json!({"assets": [a3.to_string()]}));
|
||||
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);
|
||||
// skip: 0 to 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}));
|
||||
// Unknown asset.
|
||||
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["format"], format);
|
||||
r["content"].as_str().unwrap().to_string()
|
||||
m.wire.fetch_text(&r["file"])
|
||||
};
|
||||
|
||||
// Agreeing: depreciation posted through February.
|
||||
@@ -839,7 +802,7 @@ fn export_fixed_asset_register_as_of_leaves_out_later_assets() {
|
||||
"books.export",
|
||||
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("LATE-1"), "{text}");
|
||||
}
|
||||
@@ -1385,13 +1348,11 @@ fn export_fixed_asset_register_carries_posted_scheduled_and_704c() {
|
||||
"confirmed": true, "receipt_attachment_ids": [doc]}),
|
||||
);
|
||||
let export = |m: &mut Mcp, format: &str| -> String {
|
||||
m.read_only(
|
||||
let r = m.read_only(
|
||||
"books.export",
|
||||
json!({"report": "fixed_asset_register", "format": format, "as_of": "2026-06-30"}),
|
||||
)["content"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
);
|
||||
m.wire.fetch_text(&r["file"])
|
||||
};
|
||||
let j: Value = serde_json::from_str(&export(&mut m, "json")).unwrap();
|
||||
let rows = j["assets"].as_array().unwrap();
|
||||
|
||||
@@ -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)] = &[
|
||||
("1000", "Bank", "asset"),
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
@@ -314,13 +304,22 @@ fn books(name: &str) -> 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.
|
||||
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(
|
||||
"books.file_document",
|
||||
json!({"category": category, "file_name": format!("{category}.pdf"),
|
||||
"mime_type": "application/pdf", "content_base64": b64(bytes), "sha256": sha(bytes),
|
||||
"link_to": link_to}),
|
||||
json!({"category": category, "upload_id": upload, "link_to": link_to}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
@@ -328,8 +327,8 @@ impl Mcp {
|
||||
|
||||
/// Attach a receipt (to `entry`, or unlinked); its id.
|
||||
fn receipt(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
|
||||
let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(bytes), "sha256": sha(bytes)});
|
||||
let upload = self.upload(bytes, "r.pdf", "application/pdf", "receipt");
|
||||
let mut args = json!({"upload_id": upload});
|
||||
if let Some(e) = entry {
|
||||
args["entry_id"] = json!(e);
|
||||
}
|
||||
@@ -691,6 +690,8 @@ fn filing_links_in_the_same_call_all_or_nothing() {
|
||||
.len()
|
||||
};
|
||||
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 [
|
||||
json!([{"kind": "party", "id": "Acme Design"}, {"kind": "entry", "id": 999}]),
|
||||
json!([{"kind": "company"}, {"kind": "company"}]),
|
||||
@@ -699,26 +700,23 @@ fn filing_links_in_the_same_call_all_or_nothing() {
|
||||
] {
|
||||
m.refuse(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": link_to}),
|
||||
json!({"entry_id": e, "upload_id": r, "link_to": link_to}),
|
||||
);
|
||||
}
|
||||
let c = m.upload(b"c", "c.pdf", "application/pdf", "document");
|
||||
m.refuse(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"c"), "sha256": sha(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}),
|
||||
json!({"category": "contract", "upload_id": c, "link_to": [{"kind": "asset", "id": "NOPE"}]}),
|
||||
);
|
||||
// An unknown entry_id is refused before anything is filed.
|
||||
m.refuse(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"), "link_to": [{"kind": "company"}]}),
|
||||
json!({"entry_id": 999, "upload_id": r, "link_to": [{"kind": "company"}]}),
|
||||
);
|
||||
assert_eq!(count(&mut m), n, "nothing filed");
|
||||
let out = m.ok(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"r"), "sha256": sha(b"r"),
|
||||
json!({"entry_id": e, "upload_id": r,
|
||||
"link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}),
|
||||
);
|
||||
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!(m.count("receipt.attached"), 1);
|
||||
assert_eq!(m.count("attachment.linked"), 2);
|
||||
let cert = m.upload(b"certificate", "cert.pdf", "application/pdf", "document");
|
||||
let doc = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"certificate"), "sha256": sha(b"certificate"), "link_to": [{"kind": "company"}]}),
|
||||
json!({"category": "formation", "upload_id": cert, "link_to": [{"kind": "company"}]}),
|
||||
);
|
||||
assert_eq!(kinds(&doc), ["company"]);
|
||||
assert_eq!(m.count("document.filed"), 1);
|
||||
@@ -759,10 +757,10 @@ fn documents_file_under_the_attachment_kinds() {
|
||||
c
|
||||
);
|
||||
}
|
||||
let x = m.upload(b"x", "x", "text/plain", "document");
|
||||
m.refuse(
|
||||
"books.file_document",
|
||||
json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"x"), "sha256": sha(b"x")}),
|
||||
json!({"category": "receipt-ish", "upload_id": x}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1249,13 +1247,15 @@ fn a_bank_import_links_its_source_to_the_account_not_the_drafts() {
|
||||
"books.withdraw_attachment",
|
||||
json!({"attachment_id": gone, "reason": "superseded"}),
|
||||
);
|
||||
let args = |source: u64| {
|
||||
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
|
||||
let args = |upload: &str, source: u64| {
|
||||
json!({"upload_id": upload, "bank_account_code": "1000", "default_offset_code": "6999",
|
||||
"source_attachment_id": source})
|
||||
};
|
||||
m.refuse("books.import_bank_csv", args(999));
|
||||
m.refuse("books.import_bank_csv", args(gone));
|
||||
let out = m.ok("books.import_bank_csv", args(src));
|
||||
// One upload, left for the next call by each refused one.
|
||||
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
|
||||
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["source_attachment_id"], json!(src));
|
||||
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.
|
||||
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!(m.count("attachment.linked"), 1);
|
||||
// Without a source, as before.
|
||||
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
|
||||
let plain = m.ok(
|
||||
"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_eq!(plain["source_linked"], false);
|
||||
|
||||
@@ -426,13 +426,14 @@ impl Mcp {
|
||||
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 {
|
||||
let mut args = json!({
|
||||
"file_name": "receipt.txt",
|
||||
"mime_type": "text/plain",
|
||||
"content_base64": b64(bytes),
|
||||
"sha256": sha(bytes),
|
||||
});
|
||||
let upload = self.upload(bytes, "receipt.txt", "text/plain", "receipt");
|
||||
let mut args = json!({"upload_id": upload});
|
||||
if let Some(e) = entry {
|
||||
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.
|
||||
const BASE_CHART: &[(&str, &str, &str)] = &[
|
||||
("1000", "Bank", "asset"),
|
||||
@@ -627,10 +605,13 @@ fn happy_path(name: &str) -> Mcp {
|
||||
("6010", "Platform Fees", "expense"),
|
||||
("6500", "Depreciation Expense", "expense"),
|
||||
]);
|
||||
m.ok(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n"}),
|
||||
let chart = m.upload(
|
||||
b"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(
|
||||
"books.renumber_account",
|
||||
json!({"account": "7000", "code": "7100"}),
|
||||
@@ -717,11 +698,11 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"tin_last4": "4321", "kept_at": "CPA portal"}),
|
||||
);
|
||||
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(
|
||||
"books.set_w9",
|
||||
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
|
||||
"tin": "79-9714321", "tin_type": "ein",
|
||||
"w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}),
|
||||
"tin": "79-9714321", "tin_type": "ein", "w9_upload_id": w9}),
|
||||
);
|
||||
m.ok("books.get_w9", json!({"party": "Landlord"}));
|
||||
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
|
||||
@@ -800,19 +781,22 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": r_old, "view": "bytes"}),
|
||||
);
|
||||
let oa = m.upload(
|
||||
b"operating agreement",
|
||||
"agreement.txt",
|
||||
"text/plain",
|
||||
"document",
|
||||
);
|
||||
let doc = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"operating agreement"),
|
||||
"sha256": sha(b"operating agreement"), "description": "OA"}),
|
||||
json!({"category": "legal", "upload_id": oa, "description": "OA"}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let lease = m.upload(b"lease", "lease.txt", "text/plain", "document");
|
||||
let filed = m.ok(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(b"lease"),
|
||||
"sha256": sha(b"lease"), "link_to": [{"kind": "company"}]}),
|
||||
json!({"category": "contract", "upload_id": lease, "link_to": [{"kind": "company"}]}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -833,7 +817,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
let local = m.attach(None, b"a local receipt");
|
||||
let up = m.ok(
|
||||
"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"}),
|
||||
);
|
||||
assert_eq!(up["existing_attachment_id"], json!(local), "{up}");
|
||||
@@ -983,17 +967,17 @@ fn happy_path(name: &str) -> Mcp {
|
||||
|
||||
// Bank import and reconciliation.
|
||||
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(
|
||||
"books.file_document",
|
||||
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
|
||||
"content_base64": b64(csv.as_bytes()),
|
||||
"sha256": sha(csv.as_bytes())}),
|
||||
json!({"category": "import_source", "upload_id": up}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import");
|
||||
m.ok(
|
||||
"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}),
|
||||
);
|
||||
m.ok(
|
||||
@@ -1005,11 +989,15 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.mark_cleared",
|
||||
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(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": b64(b"%PDF statement"),
|
||||
"sha256": sha(b"%PDF statement")}),
|
||||
json!({"category": "statement", "upload_id": up}),
|
||||
)["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -1324,9 +1312,10 @@ enum Op {
|
||||
date: String,
|
||||
confirmed: Option<bool>,
|
||||
},
|
||||
/// A receipt: these bytes, or (None) bytes given inline.
|
||||
Attach {
|
||||
entry: Option<Pick>,
|
||||
content: String,
|
||||
content: Option<Vec<u8>>,
|
||||
},
|
||||
Link {
|
||||
att: Pick,
|
||||
@@ -1424,8 +1413,8 @@ fn op_strategy() -> impl Strategy<Value = Op> {
|
||||
2 => (pick(), date_strategy(), confirmed())
|
||||
.prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }),
|
||||
3 => (prop::option::of(pick()), prop_oneof![
|
||||
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(|b| b64(&b)),
|
||||
1 => Just("!!not base64!!".to_string()),
|
||||
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(Some),
|
||||
1 => Just(None),
|
||||
])
|
||||
.prop_map(|(entry, content)| Op::Attach { entry, content }),
|
||||
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 {
|
||||
let bytes = format!("receipt for entry {e}");
|
||||
let up = m.upload(bytes.as_bytes(), "r.txt", "text/plain", "receipt");
|
||||
let out = m.tool(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain",
|
||||
"content_base64": b64(bytes.as_bytes()),
|
||||
"sha256": sha(bytes.as_bytes())}),
|
||||
json!({"entry_id": e, "upload_id": up}),
|
||||
);
|
||||
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)}),
|
||||
),
|
||||
Op::Attach { entry, content } => {
|
||||
// The bytes' own SHA-256 (content that is no base64 has none).
|
||||
let sha256 = {
|
||||
use base64::Engine as _;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(content)
|
||||
.map_or_else(|_| "0".repeat(64), |b| sha(&b))
|
||||
let entry = entry.map(|p| p.resolve(&k.entries));
|
||||
// New bytes go by their upload; bytes stored already (begin_upload
|
||||
// names their attachment) are attached by an upload there is
|
||||
// none of; none at all, by the inline argument D-46 removed.
|
||||
let given = match content {
|
||||
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=="}),
|
||||
};
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
|
||||
"mime_type": "application/octet-stream", "content_base64": content,
|
||||
"sha256": sha256}),
|
||||
)
|
||||
let mut args = json!({"entry_id": entry});
|
||||
for (key, v) in given.as_object().unwrap() {
|
||||
args[key] = v.clone();
|
||||
}
|
||||
("books.attach_receipt", args)
|
||||
}
|
||||
Op::Link { att, entry } => (
|
||||
"books.link_receipt",
|
||||
@@ -2298,7 +2297,6 @@ fn helpers_self_check() {
|
||||
assert_eq!(canon("007.50"), "7.5");
|
||||
assert_eq!(canon("5.00"), "5");
|
||||
assert_eq!(canon("-0.00"), "0");
|
||||
assert_eq!(b64(b"abcd"), "YWJjZA==");
|
||||
let mut bad = Vec::new();
|
||||
money_walk(
|
||||
&json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}),
|
||||
|
||||
@@ -480,7 +480,7 @@ fn defect14_party_totals_take_a_basis() {
|
||||
"books.export",
|
||||
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"] {
|
||||
let col = me["members"]
|
||||
.as_array()
|
||||
|
||||
@@ -271,15 +271,6 @@ fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
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 {
|
||||
/// Draft with lines (account, side, amount, class), post it; the id.
|
||||
fn tagged(
|
||||
@@ -308,12 +299,14 @@ impl Mcp {
|
||||
id
|
||||
}
|
||||
|
||||
/// File `bytes` as a contract, uploaded as an agent uploads them.
|
||||
fn doc(&mut self, bytes: &[u8], link_to: Value) -> Out {
|
||||
let upload = self
|
||||
.wire
|
||||
.upload(bytes, "spec.pdf", "application/pdf", "document");
|
||||
self.tool(
|
||||
"books.file_document",
|
||||
json!({"category": "contract", "file_name": "spec.pdf",
|
||||
"mime_type": "application/pdf", "content_base64": b64(bytes),
|
||||
"sha256": sha(bytes), "link_to": link_to}),
|
||||
json!({"category": "contract", "upload_id": upload, "link_to": link_to}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,12 @@ impl Mcp {
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
let result = self.result(tool, args);
|
||||
assert_ne!(
|
||||
@@ -243,10 +249,8 @@ fn require_tax_category_refuses_defect_8() {
|
||||
);
|
||||
// The import: refused whole, saying why.
|
||||
let before = m.events();
|
||||
let err = m.call_err(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n"}),
|
||||
);
|
||||
let up = m.csv_upload("code,name,type,parent_code,tax_category\n6200,Rent,expense,,rent_property\n6210,Misc,expense,,\n");
|
||||
let err = m.call_err("books.import_chart_csv", json!({"upload_id": up}));
|
||||
assert!(
|
||||
err.contains("6210") && err.contains("fifth column") && err.contains("never a header"),
|
||||
"{err}"
|
||||
@@ -296,10 +300,8 @@ fn require_tax_category_refuses_defect_8() {
|
||||
json!({"code": "6210", "name": "Misc", "account_type": "expense"}),
|
||||
);
|
||||
assert_eq!(out["warnings"].as_array().unwrap().len(), 1, "{out}");
|
||||
let out = m.call(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n"}),
|
||||
);
|
||||
let up = m.csv_upload("code,name,type,parent_code\n6220,Postage,expense,\n6230,Fees,revenue,\n1100,Petty Cash,asset,\n");
|
||||
let out = m.call("books.import_chart_csv", json!({"upload_id": up}));
|
||||
assert_eq!(out["count"], 3);
|
||||
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());
|
||||
assert!(err.contains(says), "{args}: {err}");
|
||||
}
|
||||
let err = m.call_err(
|
||||
"books.import_chart_csv",
|
||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n"}),
|
||||
);
|
||||
let up = m.csv_upload("code,name,type,parent_code\n1000,Cash,asset,\n1010,cash,asset,\n");
|
||||
let err = m.call_err("books.import_chart_csv", json!({"upload_id": up}));
|
||||
assert!(err.contains("1010") && err.contains("1000 Cash"), "{err}");
|
||||
assert_eq!(m.events(), before);
|
||||
// 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 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!(
|
||||
j["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["tax_net"], "10.00");
|
||||
let out = m.call("books.export", range("csv"));
|
||||
let csv = out["content"].as_str().unwrap();
|
||||
let csv = &m.wire.fetch_text(&out["file"]);
|
||||
assert!(
|
||||
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,"),
|
||||
|
||||
@@ -60,17 +60,6 @@ struct Mcp {
|
||||
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.
|
||||
type TestDir = Place;
|
||||
|
||||
@@ -466,6 +455,24 @@ impl Mcp {
|
||||
}
|
||||
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.
|
||||
fn w9_file(&self, tin: &str) -> Vec<u8> {
|
||||
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"]);
|
||||
let pdf = m.w9_file(SSN);
|
||||
let txt = b"W-9";
|
||||
let big = [b"%PDF-1.4 ".as_slice(), &vec![b' '; 256 * 1024]].concat();
|
||||
for (party, extra) in [
|
||||
("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits
|
||||
(
|
||||
@@ -652,41 +658,28 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
out.err_text()
|
||||
);
|
||||
}
|
||||
// The W-9 file, each refused for its own reason.
|
||||
let long = "!".repeat((256 * 1024usize).div_ceil(3) * 4 + 4);
|
||||
// The W-9 file, each refused for its own reason. Every upload is
|
||||
// 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 [
|
||||
(
|
||||
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": up_txt}), "a PDF, PNG, or JPEG"),
|
||||
(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)}),
|
||||
"not both",
|
||||
json!({"w9_upload_id": up_pdf, "w9_file_name": "../w9.pdf"}),
|
||||
"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"}),
|
||||
"no folder",
|
||||
json!({"w9_upload_id": up_pdf, "w9_sha256": "00"}),
|
||||
"unknown argument",
|
||||
),
|
||||
] {
|
||||
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 acme = m.party_id("Acme Design");
|
||||
let src = m.w9_file(SSN);
|
||||
let up1 = m.w9_upload(&src);
|
||||
let Out::Ok(p) = m.w9(
|
||||
"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 {
|
||||
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.
|
||||
m.add_party("Solo", &["contractor"]);
|
||||
let solo = m.w9_file("000");
|
||||
let Out::Ok(p) = m.w9(
|
||||
"Solo",
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(&solo), "w9_sha256": sha(&solo)}),
|
||||
) else {
|
||||
let up2 = m.w9_upload(&solo);
|
||||
let Out::Ok(p) = m.w9("Solo", json!({"tin_last4": "0000", "w9_upload_id": up2})) else {
|
||||
panic!("a W-9 file alone")
|
||||
};
|
||||
assert_eq!(p["w9"]["in_vault"], true);
|
||||
@@ -816,10 +808,8 @@ fn every_w9_file_type_is_taken() {
|
||||
{
|
||||
let party = format!("Payee {n}");
|
||||
m.add_party(&party, &["contractor"]);
|
||||
let Out::Ok(p) = m.w9(
|
||||
&party,
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
|
||||
) else {
|
||||
let up3 = m.w9_upload(bytes);
|
||||
let Out::Ok(p) = m.w9(&party, json!({"tin_last4": "0000", "w9_upload_id": up3})) else {
|
||||
panic!("{name} refused")
|
||||
};
|
||||
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.
|
||||
m.add_party("Payee N", &["contractor"]);
|
||||
let bytes = b"%PDF-1.4 Form W-9 named";
|
||||
let up4 = m.w9_upload(bytes);
|
||||
let Out::Ok(_) = m.w9(
|
||||
"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"}),
|
||||
) else {
|
||||
panic!("named W-9 refused")
|
||||
@@ -855,11 +846,9 @@ fn every_w9_file_type_is_taken() {
|
||||
b"\xff\xd8 short".as_slice(),
|
||||
b"GIF89a".as_slice(),
|
||||
] {
|
||||
let up5 = m.w9_upload(bytes);
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee X",
|
||||
json!({"tin_last4": "0000", "w9_content_base64": b64(bytes), "w9_sha256": sha(bytes)}),
|
||||
)
|
||||
.w9("Payee X", json!({"tin_last4": "0000", "w9_upload_id": up5}))
|
||||
.err_text();
|
||||
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() {
|
||||
let mut m = books("locked");
|
||||
let src = m.w9_file(SSN);
|
||||
let up6 = m.w9_upload(&src);
|
||||
m.w9(
|
||||
"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");
|
||||
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 acme = m.party_id("Acme Design");
|
||||
let src = m.w9_file(SSN);
|
||||
let up7 = m.w9_upload(&src);
|
||||
m.w9(
|
||||
"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.
|
||||
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",
|
||||
);
|
||||
let src = m.w9_file(SSN);
|
||||
let up8 = m.w9_upload(&src);
|
||||
m.w9(
|
||||
"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.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:?}");
|
||||
// The handed-over files were scanned: two packages (without TINs,
|
||||
// with them), the W-9 file, the worksheet CSV.
|
||||
assert_eq!(scanned.len(), 4, "{scanned:?}");
|
||||
// with them), the W-9 file, the worksheet CSV, and the journal's two
|
||||
// exports (D-46: handed over too).
|
||||
assert_eq!(scanned.len(), 6, "{scanned:?}");
|
||||
assert!(m.transcript.len() > 40, "the session used the books");
|
||||
// The server's log.
|
||||
let log = m.server_log();
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
//! INVARIANTS (oracle):
|
||||
//! 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
|
||||
//! books.file_document, its bytes given inline), from any session and
|
||||
//! after the server restarts.
|
||||
//! books.file_document, its bytes uploaded as an agent uploads them:
|
||||
//! D-46), from any session and after the server restarts.
|
||||
//! 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
|
||||
//! hyphens, spaces or dots; 2-7 joined by a hyphen or a space), and,
|
||||
@@ -55,6 +55,10 @@ struct Books {
|
||||
dir: PathBuf,
|
||||
inbox: 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 {
|
||||
@@ -83,6 +87,38 @@ fn books(tag: &str) -> Books {
|
||||
served,
|
||||
inbox,
|
||||
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}"))
|
||||
}
|
||||
|
||||
/// `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
|
||||
/// download, matching the size and SHA-256 it states.
|
||||
async fn handed(b: &Books, file: &Value) -> Vec<u8> {
|
||||
@@ -123,20 +150,19 @@ async fn refused_everywhere(
|
||||
why: &str,
|
||||
) -> Vec<String> {
|
||||
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 before = std::fs::read(&journal).unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": name, "mime_type": "application/octet-stream",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
json!({"upload_id": upload, "mime_type": "application/octet-stream"}),
|
||||
),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "tax", "file_name": name, "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
json!({"category": "tax", "upload_id": upload, "mime_type": "application/pdf"}),
|
||||
),
|
||||
] {
|
||||
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,
|
||||
/// 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
|
||||
/// 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)]
|
||||
async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() {
|
||||
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",
|
||||
)
|
||||
.unwrap();
|
||||
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
|
||||
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 [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
@@ -196,9 +224,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
&one,
|
||||
"books.set_w9",
|
||||
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;
|
||||
b.spent(&std::fs::read(&w9).unwrap(), "w9");
|
||||
ok(
|
||||
&one,
|
||||
"books.set_tin",
|
||||
@@ -207,7 +236,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
.await;
|
||||
// A bank statement filed in the books: the package carries it, and it
|
||||
// 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
|
||||
// outside the books as the human would save it.
|
||||
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();
|
||||
refused_everywhere(&two, &b, &fresh, "payees.csv", TIN_PRINTED).await;
|
||||
// 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(
|
||||
&two,
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "lunch.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": fine, "sha256": fine_sha}),
|
||||
json!({"upload_id": fine, "mime_type": "application/pdf"}),
|
||||
)
|
||||
.await;
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&two, &b, "statement-again.csv", BANK_CSV).await;
|
||||
drop((one, two));
|
||||
b.served.restart();
|
||||
|
||||
@@ -329,49 +364,60 @@ async fn a_refused_set_w9_never_marks_its_file() {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let w9_args = |party: &str, path: &Path| {
|
||||
let (content, sha) = inline(&std::fs::read(path).unwrap());
|
||||
let w9_args = |party: &str, upload: &str| {
|
||||
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 [
|
||||
("Nobody", &w9, "unknown party"),
|
||||
("Mo", &w9, "1099"),
|
||||
("Acme", ¬es, "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
|
||||
.expect_err("refused");
|
||||
assert!(err.contains(why), "{party}: {err}");
|
||||
}
|
||||
// Neither file was marked: each is filed as any document is.
|
||||
let mut filed = Vec::new();
|
||||
for (name, bytes) in [
|
||||
("w9-scan.pdf", w9_bytes.clone()),
|
||||
("notes.txt", std::fs::read(¬es).unwrap()),
|
||||
] {
|
||||
let (content, sha) = inline(&bytes);
|
||||
let upload = b.upload(&c, &bytes, name, "document").await;
|
||||
let out = ok(
|
||||
&c,
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": name, "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
json!({"category": "other", "upload_id": upload, "mime_type": "application/pdf"}),
|
||||
)
|
||||
.await;
|
||||
b.spent(&bytes, "document");
|
||||
assert!(out["attachment_id"].is_u64(), "{name}: {out}");
|
||||
filed.push(out["attachment_id"].clone());
|
||||
}
|
||||
// A set_w9 that succeeds marks it.
|
||||
ok(&c, "books.set_w9", w9_args("Acme", &w9)).await;
|
||||
let (content, sha) = inline(&w9_bytes);
|
||||
let upload = b.upload(&c, &w9_bytes, "w9.pdf", "w9").await;
|
||||
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 [
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "other", "file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
json!({"category": "other", "upload_id": upload, "mime_type": "application/pdf"}),
|
||||
),
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": "again.pdf", "mime_type": "application/pdf",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
json!({"upload_id": upload, "mime_type": "application/pdf"}),
|
||||
),
|
||||
] {
|
||||
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
|
||||
/// `text`, and the journal shows it.
|
||||
async fn accepted_everywhere(c: &Client, name: &str, text: &str) {
|
||||
let (content, sha) = inline(text.as_bytes());
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.attach_receipt",
|
||||
json!({"file_name": name, "mime_type": "text/csv", "content_base64": content, "sha256": sha}),
|
||||
),
|
||||
/// `text`, and the journal shows it. The same bytes filed already are
|
||||
/// withdrawn first (books.begin_upload would name them instead of
|
||||
/// taking them again), so each tool takes them itself.
|
||||
async fn accepted_everywhere(c: &Client, b: &Books, name: &str, text: &str) {
|
||||
let token = b.served.home.token(common::BOOK);
|
||||
for (tool, mut args) in [
|
||||
("books.attach_receipt", json!({"mime_type": "text/csv"})),
|
||||
(
|
||||
"books.file_document",
|
||||
json!({"category": "import_source", "file_name": name, "mime_type": "text/csv",
|
||||
"content_base64": content, "sha256": sha}),
|
||||
json!({"category": "import_source", "mime_type": "text/csv"}),
|
||||
),
|
||||
] {
|
||||
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;
|
||||
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();
|
||||
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);
|
||||
|
||||
// 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;
|
||||
accepted_everywhere(
|
||||
&c,
|
||||
&b,
|
||||
"bank-2.csv",
|
||||
&format!("{BANK_CSV}2026-10-06,x,1.00,1.00,1\n"),
|
||||
)
|
||||
.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
|
||||
@@ -494,6 +563,7 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
|
||||
.unwrap();
|
||||
accepted_everywhere(
|
||||
&c,
|
||||
&b,
|
||||
"plan.md",
|
||||
"# 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;
|
||||
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, "bank.csv", BANK_CSV).await;
|
||||
accepted_everywhere(&c, &b, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
|
||||
accepted_everywhere(&c, &b, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
|
||||
// The vault locked: the same refusals, word for word.
|
||||
|
||||
@@ -66,8 +66,8 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{Notify, mpsc, watch};
|
||||
use tokio::time::Instant;
|
||||
|
||||
/// The longest message the agent may send (a file bigger than a call
|
||||
/// takes inline goes by an upload).
|
||||
/// The longest message the agent may send (a file never comes in one: it
|
||||
/// goes by an upload, D-46).
|
||||
const MAX_MESSAGE: usize = ledger_mcp::local::MAX_BODY;
|
||||
/// The bridge's own request ids upstream start so.
|
||||
const OWN_ID: &str = "wanderbooks-bridge-";
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# 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):
|
||||
- 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.
|
||||
- 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.
|
||||
- A file never passes through you, in either direction: no tool takes a file's bytes or text, and none gives them back.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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, 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -96,6 +96,8 @@ pub(crate) const INSUFFICIENT_SCOPE: &str = "insufficient_scope";
|
||||
pub(crate) const CLOSING: &str = "closing";
|
||||
/// The server had 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
|
||||
/// call that ran is told instead of its answer (INVARIANT 3).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
|
||||
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
|
||||
//! 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
|
||||
//! tool input (inline, at most 256 KiB) or as uploads, and the files the
|
||||
//! books make go out as downloads.
|
||||
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in only
|
||||
//! as uploads and go out only as downloads: never through an agent's
|
||||
//! context (D-46).
|
||||
|
||||
pub mod access;
|
||||
pub mod admin;
|
||||
|
||||
@@ -86,8 +86,8 @@ pub const SERVER_LOCK: &str = "server.lock";
|
||||
/// What the server prints on stdout when it is ready (INVARIANT 5).
|
||||
pub const READY: &str = "ready";
|
||||
|
||||
/// How large an MCP request may be (plan 3.2): files of any size come
|
||||
/// through uploads (`/b/{book}/u/{upload}`); inline, at most 256 KiB.
|
||||
/// How large an MCP request may be (plan 3.2): files come only through
|
||||
/// uploads (`/b/{book}/u/{upload}`), never in a request (D-46).
|
||||
pub const MAX_BODY: usize = 1024 * 1024;
|
||||
|
||||
/// How long the admin socket waits for a request to arrive.
|
||||
|
||||
@@ -317,12 +317,8 @@ fn named(
|
||||
mime_type: Option<String>,
|
||||
file: &files::TakenFile,
|
||||
) -> Result<(String, String), String> {
|
||||
let name = file_name
|
||||
.or_else(|| file.file_name.clone())
|
||||
.ok_or("file_name is needed")?;
|
||||
let mime = mime_type
|
||||
.or_else(|| file.mime.clone())
|
||||
.ok_or("mime_type is needed")?;
|
||||
let name = file_name.unwrap_or_else(|| file.file_name.clone());
|
||||
let mime = mime_type.unwrap_or_else(|| file.mime.clone());
|
||||
if let Some(why) = ledger_store::uploads::file_name_problem(&name)
|
||||
.or_else(|| ledger_store::uploads::mime_problem(&mime))
|
||||
{
|
||||
@@ -939,35 +935,30 @@ impl BooksServer {
|
||||
.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.
|
||||
fn w9_file(
|
||||
&self,
|
||||
p: &SetW9Params,
|
||||
upload_id: &str,
|
||||
) -> Result<(ledger_vault::W9File, Option<ledger_store::uploads::Claim>), String> {
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: p.w9_upload_id.as_deref(),
|
||||
content_base64: p.w9_content_base64.as_deref(),
|
||||
sha256: p.w9_sha256.as_deref(),
|
||||
upload_id,
|
||||
purposes: files::W9,
|
||||
})?;
|
||||
// Not recorded here: set_w9 records it once the call can only
|
||||
// succeed, just before the vault write (tin_files.rs INVARIANT 1),
|
||||
// so a refused call never marks an ordinary file.
|
||||
let (mime_type, ext) = if file.bytes.starts_with(b"%PDF-") {
|
||||
("application/pdf", "pdf")
|
||||
let mime_type = if file.bytes.starts_with(b"%PDF-") {
|
||||
"application/pdf"
|
||||
} 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]) {
|
||||
("image/jpeg", "jpg")
|
||||
"image/jpeg"
|
||||
} else {
|
||||
return Err("the W-9 is a PDF, PNG, or JPEG".into());
|
||||
};
|
||||
let file_name = p
|
||||
.w9_file_name
|
||||
.clone()
|
||||
.or(file.file_name)
|
||||
.unwrap_or_else(|| format!("w9.{ext}"));
|
||||
let file_name = p.w9_file_name.clone().unwrap_or(file.file_name);
|
||||
if let Some(why) = ledger_store::uploads::file_name_problem(&file_name) {
|
||||
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.
|
||||
fn csv_given(
|
||||
&self,
|
||||
text: Option<&str>,
|
||||
upload_id: Option<&str>,
|
||||
upload_id: &str,
|
||||
) -> Result<(String, Option<ledger_store::uploads::Claim>), String> {
|
||||
match (text, upload_id) {
|
||||
(Some(_), Some(_)) => Err("give csv_text or upload_id, not both".into()),
|
||||
(Some(t), None) => Ok((t.to_string(), None)),
|
||||
(None, Some(id)) => {
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: Some(id),
|
||||
content_base64: None,
|
||||
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()),
|
||||
}
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id,
|
||||
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))
|
||||
}
|
||||
|
||||
/// Record a file read or written with full TINs (tin_files.rs
|
||||
@@ -3517,7 +3498,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.attach_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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3538,9 +3519,7 @@ impl BooksServer {
|
||||
let link_to = check_link_to(&store, p.entry_id, &p.link_to)?;
|
||||
// Taken once everything else is known good.
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: p.upload_id.as_deref(),
|
||||
content_base64: p.content_base64.as_deref(),
|
||||
sha256: p.sha256.as_deref(),
|
||||
upload_id: &p.upload_id,
|
||||
purposes: files::FILED,
|
||||
})?;
|
||||
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
|
||||
@@ -3572,7 +3551,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.file_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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3597,9 +3576,7 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let link_to = check_link_to(&store, None, &p.link_to)?;
|
||||
let file = self.take_file(files::Given {
|
||||
upload_id: p.upload_id.as_deref(),
|
||||
content_base64: p.content_base64.as_deref(),
|
||||
sha256: p.sha256.as_deref(),
|
||||
upload_id: &p.upload_id,
|
||||
purposes: files::FILED,
|
||||
})?;
|
||||
let (file_name, mime_type) = named(p.file_name, p.mime_type, &file)?;
|
||||
@@ -3890,7 +3867,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.get_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)
|
||||
)]
|
||||
async fn get_receipt(
|
||||
@@ -3910,33 +3887,17 @@ impl BooksServer {
|
||||
};
|
||||
let store = self.lock()?;
|
||||
let meta = find_attachment(&store, p.attachment_id)?;
|
||||
let book = store
|
||||
.data_dir()
|
||||
.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 {
|
||||
// Handed over, read hash-verified (D-46): never its bytes here.
|
||||
let file = if bytes_wanted {
|
||||
let bytes = store
|
||||
.attachment_bytes(p.attachment_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Some(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&bytes,
|
||||
))
|
||||
Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?)
|
||||
} else {
|
||||
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 {
|
||||
download,
|
||||
file,
|
||||
links: links_out(&store, meta.id)?,
|
||||
description: meta.description.clone(),
|
||||
attachment_id: meta.id,
|
||||
@@ -3946,7 +3907,6 @@ impl BooksServer {
|
||||
file_name: meta.file_name,
|
||||
mime_type: meta.mime_type,
|
||||
size_bytes: meta.size_bytes,
|
||||
content_base64,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -4512,7 +4472,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.import_bank_csv",
|
||||
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(
|
||||
read_only_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 mut store = self.lock()?;
|
||||
let bank = store
|
||||
@@ -5683,14 +5643,13 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.asset_labels",
|
||||
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)
|
||||
)]
|
||||
async fn asset_labels(
|
||||
&self,
|
||||
params: Parameters<AssetLabelsParams>,
|
||||
) -> Result<Json<AssetLabelsOut>, String> {
|
||||
use base64::Engine as _;
|
||||
let p = params.0;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
@@ -5729,9 +5688,14 @@ impl BooksServer {
|
||||
p.skip.unwrap_or(0) as usize,
|
||||
)
|
||||
.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 {
|
||||
file_name: format!("asset-labels-{}.pdf", today()),
|
||||
content_base64: base64::engine::general_purpose::STANDARD.encode(pdf),
|
||||
file,
|
||||
tags: labels.into_iter().map(|l| l.tag).collect(),
|
||||
}))
|
||||
}
|
||||
@@ -5866,7 +5830,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.import_chart_csv",
|
||||
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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -5878,7 +5842,7 @@ impl BooksServer {
|
||||
params: Parameters<ImportChartCsvParams>,
|
||||
) -> Result<Json<ImportChartCsvOut>, String> {
|
||||
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 mut store = self.lock()?;
|
||||
let parse_type = |t: &str| match t {
|
||||
@@ -5996,7 +5960,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.export",
|
||||
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)
|
||||
)]
|
||||
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 iif = self.export_iif(as_of, &p.cash_accounts)?;
|
||||
let warnings = iif.warnings();
|
||||
let file = self.hand_over_export(&report, &format, iif.text.into_bytes())?;
|
||||
return Ok(Json(ExportOut {
|
||||
format,
|
||||
report,
|
||||
content: iif.text,
|
||||
content_encoding: None,
|
||||
file,
|
||||
warnings,
|
||||
}));
|
||||
}
|
||||
@@ -6039,11 +6003,11 @@ impl BooksServer {
|
||||
} else {
|
||||
self.export_pdf(&report, &p)?
|
||||
};
|
||||
let file = self.hand_over_export(&report, &format, bytes)?;
|
||||
return Ok(Json(ExportOut {
|
||||
format,
|
||||
report,
|
||||
content: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes),
|
||||
content_encoding: Some("base64".to_string()),
|
||||
file,
|
||||
warnings: Vec::new(),
|
||||
}));
|
||||
}
|
||||
@@ -6066,15 +6030,40 @@ impl BooksServer {
|
||||
));
|
||||
}
|
||||
};
|
||||
let file = self.hand_over_export(&report, &format, content.into_bytes())?;
|
||||
Ok(Json(ExportOut {
|
||||
format,
|
||||
report,
|
||||
content,
|
||||
content_encoding: None,
|
||||
file,
|
||||
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.
|
||||
fn layout_statement(
|
||||
&self,
|
||||
@@ -7555,7 +7544,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_w9",
|
||||
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(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -7564,12 +7553,9 @@ impl BooksServer {
|
||||
)]
|
||||
async fn set_w9(&self, params: Parameters<SetW9Params>) -> Result<Json<PartyOut>, String> {
|
||||
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 {
|
||||
return Err(
|
||||
"w9_file_name names the W-9 given with it (w9_upload_id or w9_content_base64)"
|
||||
.into(),
|
||||
);
|
||||
return Err("w9_file_name names the W-9 given with it (w9_upload_id)".into());
|
||||
}
|
||||
let vault_input = p.tin.is_some() || p.tin_type.is_some() || w9_given;
|
||||
let tin = match (&p.tin, &p.tin_type) {
|
||||
@@ -7624,8 +7610,8 @@ impl BooksServer {
|
||||
vault_record: None,
|
||||
})
|
||||
};
|
||||
let (w9_file, mut w9_claim) = if w9_given {
|
||||
let (file, claim) = self.w9_file(&p)?;
|
||||
let (w9_file, mut w9_claim) = if let Some(upload_id) = &p.w9_upload_id {
|
||||
let (file, claim) = self.w9_file(&p, upload_id)?;
|
||||
(Some(file), claim)
|
||||
} else {
|
||||
(None, None)
|
||||
@@ -9879,7 +9865,14 @@ impl ServerHandler for BooksServer {
|
||||
.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 {
|
||||
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]
|
||||
fn the_file_limits_are_the_plans() {
|
||||
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.
|
||||
assert_eq!(ledger_store::uploads::UPLOAD_TTL, 900);
|
||||
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,
|
||||
/// 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]
|
||||
fn no_tool_takes_a_path() {
|
||||
fn names(v: &serde_json::Value, out: &mut Vec<String>) {
|
||||
@@ -11001,7 +10994,7 @@ mod tests {
|
||||
"read the file",
|
||||
"open the file",
|
||||
"cat ",
|
||||
// Gone in P2 slice 5: files come by upload or inline.
|
||||
// Gone in P2 slice 5: files come by upload.
|
||||
"import_local_file",
|
||||
"w9_file_path",
|
||||
"import folder",
|
||||
@@ -11009,6 +11002,11 @@ mod tests {
|
||||
"put_url",
|
||||
"human_url",
|
||||
"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.
|
||||
const NEVER_WORDS: &[&str] = &["git", "commit"];
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
//! Files in (plan 4.7, P2 slice 5, D-28): `books.begin_upload`, and the
|
||||
//! bytes a tool is given, as an upload or inline.
|
||||
//! Files in and out (plan 4.7, P2 slice 5, D-28, D-29, D-46):
|
||||
//! `books.begin_upload`, the upload a tool is given, and the files the
|
||||
//! books hand over.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent files a document of any size up to the limit without
|
||||
//! its bytes passing through its context, and a small one inline,
|
||||
//! both exactly as the agent declared them.
|
||||
//! SOURCE: plan 4.7 (begin_upload; "content_base64 (≤256 KiB + required
|
||||
//! sha256)"; guard base64_corrupt), 2.1 ("256 KiB with a mandatory
|
||||
//! matching sha256"), D-28 (the agent does it all).
|
||||
//! GOAL: an agent files a document of any size up to the limit, and takes
|
||||
//! away any file the books make, without the file's bytes passing
|
||||
//! through its context, in either direction (D-46).
|
||||
//! SOURCE: plan 4.7 (begin_upload), D-28 (the agent does it all), D-29
|
||||
//! (files the books make are handed over by `wanderbooks
|
||||
//! download`), D-46 ("files never pass through an agent's context,
|
||||
//! in either direction": uploads only, no inline bytes).
|
||||
//! INVARIANTS:
|
||||
//! 1. A tool's bytes are an upload made by the same token, or inline
|
||||
//! base64 of at most INLINE_MAX bytes whose SHA-256 is the one given;
|
||||
//! never both, never neither. An upload is used up only by the call
|
||||
//! that files it (TakenFile::filed); a call that fails leaves it for
|
||||
//! the next.
|
||||
//! 1. A tool's bytes are an upload made by the same token, named by its
|
||||
//! upload_id; nothing else. An upload is used up only by the call that
|
||||
//! files it (TakenFile::filed); a call that fails leaves it for the
|
||||
//! next.
|
||||
//! 2. begin_upload makes nothing for a receipt or document whose bytes
|
||||
//! are stored already: it names the attachment (not withdrawn) that
|
||||
//! holds them.
|
||||
@@ -23,8 +24,9 @@
|
||||
//! 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
|
||||
//! refusal leaves it for the right one.
|
||||
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
|
||||
//! before it is decoded.
|
||||
//! 5. A call that gives a file's bytes or text as an argument (one of
|
||||
//! 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
|
||||
//! export the caller's token fetches with `wanderbooks download`;
|
||||
//! never inline, never by a path.
|
||||
@@ -34,20 +36,41 @@ use super::*;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The most bytes given inline (plan 2.1: 256 KiB).
|
||||
pub(crate) const INLINE_MAX: usize = 256 * 1024;
|
||||
/// The arguments that once carried a file's bytes or text inline, before
|
||||
/// 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
|
||||
/// (INVARIANT 3).
|
||||
const NO_CALLER: &str =
|
||||
"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
|
||||
/// upload purposes the tool takes (INVARIANT 4).
|
||||
/// The upload a tool files (INVARIANT 1), and the upload purposes the
|
||||
/// tool takes (INVARIANT 4).
|
||||
pub(crate) struct Given<'a> {
|
||||
pub upload_id: Option<&'a str>,
|
||||
pub content_base64: Option<&'a str>,
|
||||
pub sha256: Option<&'a str>,
|
||||
pub upload_id: &'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.
|
||||
pub(crate) struct TakenFile {
|
||||
pub bytes: Vec<u8>,
|
||||
pub file_name: Option<String>,
|
||||
pub mime: Option<String>,
|
||||
pub file_name: String,
|
||||
pub mime: String,
|
||||
/// The upload's hold, if it came as one (INVARIANT 1).
|
||||
pub claim: Option<ledger_store::uploads::Claim>,
|
||||
}
|
||||
@@ -196,65 +219,32 @@ impl BooksServer {
|
||||
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> {
|
||||
match (given.upload_id, given.content_base64) {
|
||||
(Some(_), Some(_)) => Err("give upload_id or content_base64, not both".into()),
|
||||
(None, None) => Err(
|
||||
"the file's bytes are missing: upload it (books.begin_upload), or give content_base64 with its sha256 (at most 256 KiB)"
|
||||
.into(),
|
||||
),
|
||||
(Some(id), None) => {
|
||||
// INVARIANT 3.
|
||||
let token = self.caller_token().ok_or(NO_CALLER)?;
|
||||
let got = self
|
||||
.uploads
|
||||
.take(id.trim(), &token, chrono::Utc::now().timestamp())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// INVARIANT 4: dropped, the claim gives it back.
|
||||
if !given.purposes.contains(&got.grant.purpose.as_str()) {
|
||||
return Err(format!(
|
||||
"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,
|
||||
})
|
||||
}
|
||||
// INVARIANT 3.
|
||||
let token = self.caller_token().ok_or(NO_CALLER)?;
|
||||
let got = self
|
||||
.uploads
|
||||
.take(
|
||||
given.upload_id.trim(),
|
||||
&token,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// INVARIANT 4: dropped, the claim gives it back.
|
||||
if !given.purposes.contains(&got.grant.purpose.as_str()) {
|
||||
return Err(format!(
|
||||
"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: got.grant.file_name,
|
||||
mime: got.grant.mime,
|
||||
claim: Some(got.claim),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,7 +332,45 @@ impl BooksServer {
|
||||
|
||||
#[cfg(test)]
|
||||
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
|
||||
/// refused (a W-9 sealed earlier, say).
|
||||
|
||||
@@ -1271,16 +1271,16 @@ pub struct CashFlowOut {
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportBankCsvParams {
|
||||
/// The CSV text (QBO bank upload layout: Date,Description,Amount, or
|
||||
/// Date,Description, money out, money in)…
|
||||
#[serde(default)]
|
||||
pub csv_text: Option<String>,
|
||||
/// …or an upload of it made with books.begin_upload (purpose import).
|
||||
#[serde(default)]
|
||||
pub upload_id: Option<String>,
|
||||
/// The CSV (QBO bank upload layout: Date,Description,Amount, or
|
||||
/// Date,Description, money out, money in): its upload, made with
|
||||
/// books.begin_upload (purpose import) and `wanderbooks upload`. A
|
||||
/// file never passes through the agent (D-46).
|
||||
pub upload_id: String,
|
||||
/// Account code for the bank account (the offset side of each entry).
|
||||
pub bank_account_code: String,
|
||||
/// Account code for the default offset (e.g. an expense account).
|
||||
@@ -1331,15 +1331,15 @@ pub struct ImportBankCsvOut {
|
||||
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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportChartCsvParams {
|
||||
/// The CSV text: code,name,type,parent_code with a header row…
|
||||
#[serde(default)]
|
||||
pub csv_text: Option<String>,
|
||||
/// …or an upload of it made with books.begin_upload (purpose import).
|
||||
#[serde(default)]
|
||||
pub upload_id: Option<String>,
|
||||
/// The CSV (code,name,type,parent_code,tax_category with a header
|
||||
/// row): its upload, made with books.begin_upload (purpose import) and
|
||||
/// `wanderbooks upload`. A file never passes through the agent (D-46).
|
||||
pub upload_id: String,
|
||||
}
|
||||
|
||||
/// Result of `books.import_chart_csv`.
|
||||
@@ -1679,8 +1679,10 @@ pub struct ReverseEntryOut {
|
||||
// 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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttachReceiptParams {
|
||||
/// Entry id to attach to. Omit to file unlinked, then link later with
|
||||
/// `books.link_receipt`.
|
||||
@@ -1692,16 +1694,9 @@ pub struct AttachReceiptParams {
|
||||
/// MIME type, e.g. "image/png" (an upload's own, if not given).
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
/// The file's bytes: an upload made with books.begin_upload (its id)…
|
||||
#[serde(default)]
|
||||
pub upload_id: Option<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>,
|
||||
/// The file: its upload, made with books.begin_upload and `wanderbooks
|
||||
/// upload` (its id). A file never passes through the agent (D-46).
|
||||
pub upload_id: String,
|
||||
/// Optional description.
|
||||
pub description: Option<String>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct FileDocumentParams {
|
||||
/// One of: formation, legal, tax, accounting, statement, contract,
|
||||
/// photo, warranty, import_source, other.
|
||||
@@ -1736,16 +1733,9 @@ pub struct FileDocumentParams {
|
||||
/// MIME type (an upload's own, if not given).
|
||||
#[serde(default)]
|
||||
pub mime_type: Option<String>,
|
||||
/// The file's bytes: an upload made with books.begin_upload (its id)…
|
||||
#[serde(default)]
|
||||
pub upload_id: Option<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>,
|
||||
/// The file: its upload, made with books.begin_upload and `wanderbooks
|
||||
/// upload` (its id). A file never passes through the agent (D-46).
|
||||
pub upload_id: String,
|
||||
/// Optional description.
|
||||
pub description: Option<String>,
|
||||
/// Subjects to link it to in the same call (each journaled as its own
|
||||
@@ -1860,15 +1850,14 @@ pub struct GetReceiptParams {
|
||||
/// Attachment id.
|
||||
pub attachment_id: u64,
|
||||
/// 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
|
||||
/// 256 KiB; a larger one comes with `wanderbooks download`).
|
||||
/// to), or "bytes" (also the file, handed over: the command that
|
||||
/// saves a copy, never its bytes; D-46).
|
||||
#[serde(default)]
|
||||
pub view: Option<String>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// saves a copy.
|
||||
/// with view `bytes`, the file handed over (read hash-verified).
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct GetReceiptOut {
|
||||
/// Attachment id.
|
||||
@@ -1889,14 +1878,10 @@ pub struct GetReceiptOut {
|
||||
pub description: Option<String>,
|
||||
/// Everything it is linked to (the entry it is filed on first).
|
||||
pub links: Vec<SubjectOut>,
|
||||
/// Its bytes, base64-encoded: with view `bytes`, for a file of at most
|
||||
/// 256 KiB.
|
||||
/// With view `bytes`: the file, handed over: run its `download` to
|
||||
/// save a copy in a folder you name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_base64: Option<String>,
|
||||
/// 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>,
|
||||
pub file: Option<HandedFile>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2140,8 +2125,8 @@ pub struct StaleRule {
|
||||
/// Parameters for `books.export`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ExportParams {
|
||||
/// "csv", "json", "xlsx", "pdf", or "iif". XLSX and PDF are
|
||||
/// base64-encoded; PDF covers every report but general_ledger and
|
||||
/// "csv", "json", "xlsx", "pdf", or "iif": the file is handed over,
|
||||
/// 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" (alias "journal").
|
||||
pub format: String,
|
||||
@@ -2203,12 +2188,9 @@ pub struct ExportOut {
|
||||
pub format: String,
|
||||
/// The requested report.
|
||||
pub report: String,
|
||||
/// The exported content. Base64-encoded when `content_encoding` is
|
||||
/// "base64" (XLSX, PDF); otherwise raw text.
|
||||
pub content: String,
|
||||
/// "base64" for XLSX and PDF, otherwise absent.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_encoding: Option<String>,
|
||||
/// The exported file, handed over: run its `download` to save it in a
|
||||
/// folder you name. Never its bytes (D-46).
|
||||
pub file: HandedFile,
|
||||
/// IIF: what the file writes differently from the books to fit
|
||||
/// QuickBooks Desktop (plain ASCII; account names at most 31
|
||||
/// 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`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AssetLabelsOut {
|
||||
/// A file name for the sheet.
|
||||
pub file_name: String,
|
||||
/// The label sheet as PDF, base64. Avery 5160/8160 (US letter, 30
|
||||
/// labels of 1" x 2-5/8"); print at 100% scale, not "fit to page".
|
||||
pub content_base64: String,
|
||||
/// The label sheet, a PDF, handed over: run its `download` to save it.
|
||||
/// Avery 5160/8160 (US letter, 30 labels of 1" x 2-5/8"); print at
|
||||
/// 100% scale, not "fit to page".
|
||||
pub file: HandedFile,
|
||||
/// The tags printed, in order.
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
@@ -3752,8 +3733,10 @@ pub struct SetPartyRolesParams {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SetW9Params {
|
||||
/// The party: id or name. Needs a 1099 role (contractor or landlord).
|
||||
pub party: String,
|
||||
@@ -3784,19 +3767,13 @@ pub struct SetW9Params {
|
||||
/// The TIN's type: ssn, ein, or itin. Required with tin.
|
||||
pub tin_type: Option<String>,
|
||||
/// 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
|
||||
/// TIN), never with the receipts and documents.
|
||||
/// books.begin_upload purpose w9 and `wanderbooks upload` (its id; a
|
||||
/// 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)]
|
||||
pub w9_upload_id: Option<String>,
|
||||
/// …or, for one of at most 256 KiB, its bytes base64-encoded, with
|
||||
/// 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).
|
||||
/// The W-9 file's name (default: the upload's).
|
||||
#[serde(default)]
|
||||
pub w9_file_name: Option<String>,
|
||||
}
|
||||
|
||||
@@ -122,18 +122,14 @@ pub fn asserted(text: &str) -> String {
|
||||
/// 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
|
||||
/// 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:
|
||||
/// base64 never spells a TIN in the clear, and scanning megabytes of it
|
||||
/// costs more than it could protect.
|
||||
/// may start with a zero). No argument is a file's bytes (D-46: files
|
||||
/// come by upload), so every text is scanned.
|
||||
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`]).
|
||||
const FILE_BYTES: [&str; 2] = ["content_base64", "w9_content_base64"];
|
||||
|
||||
/// `v` with every TIN masked (see [`digest`]); `bytes`: a file's base64.
|
||||
fn masked(v: &Value, bytes: bool) -> Value {
|
||||
/// `v` with every TIN masked (see [`digest`]).
|
||||
fn masked(v: &Value) -> Value {
|
||||
const MASK: &str = "[TIN]";
|
||||
match v {
|
||||
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() {
|
||||
Value::from(MASK)
|
||||
} else {
|
||||
masked(v, FILE_BYTES.contains(&k_lower.as_str()))
|
||||
masked(v)
|
||||
};
|
||||
(ledger_core::parties::redact_full_tins(k).into_owned(), v)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
Value::Array(items) => Value::Array(items.iter().map(|v| masked(v, false)).collect()),
|
||||
Value::String(_) if bytes => v.clone(),
|
||||
Value::Array(items) => Value::Array(items.iter().map(masked).collect()),
|
||||
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),
|
||||
other => other.clone(),
|
||||
@@ -397,10 +392,10 @@ mod tests {
|
||||
digest(&serde_json::json!({"entry_id": 1234567})),
|
||||
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!(
|
||||
digest(&serde_json::json!({"content_base64": "MTIzNDU2Nzg5"})),
|
||||
digest(&serde_json::json!({"content_base64": "OTg3NjU0MzIx"}))
|
||||
digest(&serde_json::json!({"upload_id": "up_MTIzNDU2Nzg5"})),
|
||||
digest(&serde_json::json!({"upload_id": "up_OTg3NjU0MzIx"}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -442,21 +437,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_bytes_are_hashed_as_they_are_even_where_they_spell_digits() {
|
||||
// Base64 can hold a run of nine digits; it is still a file's bytes,
|
||||
// not a TIN, and two files differing there differ.
|
||||
for key in FILE_BYTES {
|
||||
assert_ne!(
|
||||
fn digits_inside_any_text_are_masked() {
|
||||
// No argument is a file's bytes any more (D-46): a run of nine
|
||||
// digits is masked wherever it is, under any key.
|
||||
for key in ["memo", "description", "upload_id"] {
|
||||
assert_eq!(
|
||||
digest(&serde_json::json!({ key: "QUJD123456789QUJD" })),
|
||||
digest(&serde_json::json!({ key: "QUJD987814321QUJD" })),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
// The same text anywhere else is masked.
|
||||
assert_eq!(
|
||||
digest(&serde_json::json!({"memo": "QUJD123456789QUJD"})),
|
||||
digest(&serde_json::json!({"memo": "QUJD987814321QUJD"}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Files on their way out (plan 4.7, D-29): a file the server makes for an
|
||||
//! agent (the CPA package, the 1099 worksheet, a W-9) is kept here a
|
||||
//! while, for the token that asked for it, until `wanderbooks download`
|
||||
//! fetches it.
|
||||
//! Files on their way out (plan 4.7, D-29, D-46): a file the server hands
|
||||
//! an agent (the CPA package, the 1099 worksheet, a W-9, a report export,
|
||||
//! a label sheet, a receipt's copy) is kept here a while, for the token
|
||||
//! that asked for it, until `wanderbooks download` fetches it.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent hands the human a file of any size the books make,
|
||||
@@ -9,7 +9,8 @@
|
||||
//! path on the server.
|
||||
//! 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
|
||||
//! command, `wanderbooks download`").
|
||||
//! command, `wanderbooks download`"); D-46 ("files never pass
|
||||
//! through an agent's context, in either direction").
|
||||
//! INVARIANTS:
|
||||
//! 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
|
||||
@@ -32,8 +33,10 @@ use std::path::{Path, PathBuf};
|
||||
pub const EXPORTS_DIR: &str = "exports";
|
||||
/// How long an export is kept, seconds.
|
||||
pub const EXPORT_TTL: i64 = 15 * 60;
|
||||
/// The most exports one token may have kept at once (INVARIANT 3).
|
||||
pub const MAX_LIVE: usize = 16;
|
||||
/// The most exports one token may have kept at once (INVARIANT 3): every
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 | — |
|
||||
|
||||
## Notes
|
||||
|
||||
+57
-53
@@ -247,14 +247,15 @@ reach through the bridge.
|
||||
action `download` with the attachment's id, a refusal at the door
|
||||
too.
|
||||
- `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
|
||||
TIN vault"), as `/a/` (the same door, the same headers,
|
||||
CPA package, a 1099 worksheet, a W-9, a report export, a label sheet,
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
- `wanderbooks download --book B [--token NAME] ID --to DIR` takes an
|
||||
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
|
||||
change, or with both `serial` and `clear_serial`. `asset` is the id or current tag. Journaled as `asset.tagged`,
|
||||
`asset.serial_set`.
|
||||
- `books.asset_labels { assets?, skip? }` → `{ file_name, content_base64,
|
||||
tags }`: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of
|
||||
the tag, description, serial. Default: every active tagged asset; `skip`
|
||||
- `books.asset_labels { assets?, skip? }` → `{ file, tags }`: an Avery
|
||||
5160/8160 label sheet (PDF), handed over (`file` a `HandedFile`, D-46):
|
||||
company, tag, QR code of the tag, description, serial. Default: every active tagged asset; `skip`
|
||||
reuses a partly used sheet.
|
||||
- `books.asset_schedule { asset_id | tag, through? }`
|
||||
→ `{ 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.
|
||||
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
|
||||
fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_upload_id?,
|
||||
w9_content_base64?, w9_sha256?, w9_file_name? }` or `{ party, clear:
|
||||
true }` → the party. Journaled as `party.w9_set`. A party with a
|
||||
w9_file_name? }` or `{ party, clear: 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
|
||||
(journaled as `vendor.w9_flag_set`, its 1099 decision and who made it
|
||||
kept), so `books.vendor_1099`'s `missing_w9` follows.
|
||||
`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
|
||||
"Tools — receipts & documents") goes there too (below); with a TIN,
|
||||
form (`w9_upload_id`, as in "Tools — receipts & documents") goes there
|
||||
too (below); with a TIN,
|
||||
`tin_last4` comes from it (a given one must agree); with a TIN or the
|
||||
form, `kept_at` defaults to `"wanderbooks vault"`.
|
||||
- `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
|
||||
its W-9: `books.set_w9`); one whose W-9 facts agree gets their
|
||||
`vault_record` updated too (`party.w9_set`).
|
||||
- `books.set_w9 { ..., tin, tin_type, w9_upload_id? | w9_content_base64? +
|
||||
w9_sha256?, w9_file_name? }` (above): validates the TIN against its type (nine digits,
|
||||
- `books.set_w9 { ..., tin, tin_type, w9_upload_id?, w9_file_name? }`
|
||||
(above): validates the TIN against its type (nine 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
|
||||
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
|
||||
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,
|
||||
whatever it is called; an upload of purpose `w9`, at most 25 MiB, or
|
||||
inline at most 256 KiB) goes to the vault only, never the attachment
|
||||
store. Its file name is `w9_file_name` if given, else the upload's,
|
||||
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
|
||||
whatever it is called; an upload of purpose `w9`, at most 25 MiB) goes
|
||||
to the vault only, never the attachment store. Its file name is
|
||||
`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
|
||||
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
|
||||
the facts (`clear: true`) is refused while the vault holds the party's
|
||||
TIN or form: purge first.
|
||||
- `books.get_w9 { party }` → `{ party_id, file }`: the party's form from
|
||||
the vault, handed over. Journals nothing.
|
||||
- **Handed files** (D-29): no tool takes a path. A file the books make
|
||||
for an agent (`books.get_w9`'s form, `books.vendor_1099`'s `csv`,
|
||||
`books.export_cpa_package`'s zip) comes back as a `HandedFile {
|
||||
- **Handed files** (D-29, D-46): no tool takes a path, and no tool gives
|
||||
a file's bytes back. Every file an agent takes away (`books.get_w9`'s
|
||||
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
|
||||
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
|
||||
it in a folder the human names: `wanderbooks download --book B
|
||||
[--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
|
||||
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
|
||||
@@ -1497,16 +1499,18 @@ the receipt policy counts it.
|
||||
|
||||
## 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.import_bank_csv`, `books.import_chart_csv`): `upload_id`, an
|
||||
upload made with `books.begin_upload` (any file up to 25 MiB; its bytes
|
||||
never pass through the agent), or `content_base64` with its `sha256`
|
||||
(at most 256 KiB decoded; longer text is refused before it is decoded).
|
||||
Never both, never neither. Inline bytes that aren't base64, or whose
|
||||
SHA-256 isn't the one given, are refused (guard `base64_corrupt`): send
|
||||
the file as an 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
|
||||
`books.import_bank_csv`, `books.import_chart_csv`): `upload_id` (for the
|
||||
W-9, `w9_upload_id`), an upload made with `books.begin_upload` (any file
|
||||
up to 25 MiB) and sent by `wanderbooks upload`. These tools take no other
|
||||
argument (their schemas say so): an argument that once carried a file's
|
||||
bytes or text inline (`content_base64`, `sha256`, `csv_text`,
|
||||
`w9_content_base64`, `w9_sha256`) is refused as unknown by every tool
|
||||
before it runs (a protocol error, recorded as refused `inline_file`),
|
||||
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
|
||||
name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`,
|
||||
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);
|
||||
for `import` and `w9` a grant is always made. Refusals never name a
|
||||
path.
|
||||
- `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id? |
|
||||
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id,
|
||||
sha256, deduplicated, links }`. `entry_id` is optional: a receipt can be
|
||||
filed before its entry exists. `file_name` and `mime_type` default to
|
||||
the upload's; inline they are required. The upload is taken only after
|
||||
- `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id,
|
||||
description?, link_to? }` → `{ attachment_id, sha256, deduplicated,
|
||||
links }`. `entry_id` is optional: a receipt can be filed before its
|
||||
entry exists. `file_name` and `mime_type` default to the upload's. The upload is taken only after
|
||||
the entry and every `link_to` subject check out. `link_to` as in "Tools
|
||||
— attachments".
|
||||
- `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.
|
||||
- `books.get_receipt { attachment_id, view?: "meta" | "bytes" }` → `{
|
||||
attachment_id, sha256, entry_id?, category, file_name, mime_type,
|
||||
size_bytes, description?, links, content_base64?, download? }`. `meta`
|
||||
(the default) returns no bytes. `bytes` adds `content_base64`
|
||||
(hash-verified) for a file of at most 256 KiB; a larger one gives
|
||||
`download`, the command `wanderbooks download --book B <id> --to <a
|
||||
folder>` (with `--token NAME` when the caller's token isn't the
|
||||
default `agent`; `GET /b/{book}/a/{attachment}`, "Transports"), which
|
||||
saves a copy as a new file in that folder.
|
||||
size_bytes, description?, links, file? }`. `meta` (the default) returns
|
||||
no file. `bytes` adds `file`, the file handed over (a `HandedFile`, read
|
||||
hash-verified, whatever its size: D-46), whose `download` saves a copy
|
||||
as a new file in a folder the human names. The stored file is also
|
||||
fetched by `wanderbooks download --book B <id> --to <a folder>` (`GET
|
||||
/b/{book}/a/{attachment}`, books:read, "Transports").
|
||||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||||
"accounting" | "statement" | "contract" | "photo" | "warranty" |
|
||||
"import_source" | "other", file_name?, mime_type?, upload_id? |
|
||||
content_base64? + sha256?, description?, link_to? }` → `{ attachment_id,
|
||||
"import_source" | "other", file_name?, mime_type?, upload_id,
|
||||
description?, link_to? }` → `{ attachment_id,
|
||||
sha256, deduplicated, links }`. The bytes, name and type as in
|
||||
`books.attach_receipt`. The Drive-folder replacement (today's 01
|
||||
Formation / 02 Legal / 03 Tax / 04 Accounting). Never a W-9: it shows
|
||||
@@ -1635,8 +1637,10 @@ posting.
|
||||
## Tools — export
|
||||
|
||||
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||||
financing_accounts?, investing_accounts? }` → `{ content,
|
||||
content_encoding?, warnings? }`.
|
||||
financing_accounts?, investing_accounts? }` → `{ format, report, file,
|
||||
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
|
||||
method; needs `cash_accounts`; json is `books.cash_flow`'s answer, csv
|
||||
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),
|
||||
`tax_summary` (see "Tools — tax categories"), `fixed_asset_register`
|
||||
(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`,
|
||||
`tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for
|
||||
every report (`general_ledger` and `general_journal` have no pdf);
|
||||
@@ -1668,7 +1672,7 @@ posting.
|
||||
refused); `balance_sheet`, `trial_balance`, `fixed_asset_register`
|
||||
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
|
||||
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
|
||||
(QuickBooks keeps none; imported, a close would zero its period's
|
||||
profit), each transaction's DOCNUM its entry id; its BANK accounts are
|
||||
@@ -1797,7 +1801,7 @@ posting.
|
||||
|
||||
## 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? }` →
|
||||
`{ imported: [{ entry_id, date, description, amount, offset_account }],
|
||||
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
|
||||
account (`source_linked`; false when it was already), never to the
|
||||
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
|
||||
`code,name,type,parent_code[,tax_category]` CSV. All-or-nothing; the
|
||||
name and tax category rules are `books.add_account`'s.
|
||||
- Each takes the CSV as `csv_text` or as an upload (`books.begin_upload`
|
||||
purpose `import`; UTF-8), never both. The CSV itself is not filed: file
|
||||
- Each takes the CSV only as an upload (`books.begin_upload` purpose
|
||||
`import`; UTF-8; D-46). The CSV itself is not filed: file
|
||||
it with `books.file_document` (category `import_source`) for
|
||||
`source_attachment_id`.
|
||||
|
||||
|
||||
+2
-2
@@ -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);
|
||||
local files
|
||||
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
|
||||
inline bytes); no file with a full TIN is filed into the books (B-26); the agent is
|
||||
key (P2 slice 5 removed local paths: files now come in only as uploads,
|
||||
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
|
||||
agent text points at the books' files, git or the repo.
|
||||
|
||||
|
||||
@@ -313,8 +313,7 @@ damn CPA package."
|
||||
TINs are missing (`tin_gaps`), and the package is still built.
|
||||
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
|
||||
`books.set_w9` takes `tin` + `tin_type` and the signed form as
|
||||
`w9_upload_id` (`books.begin_upload`) or `w9_content_base64` with
|
||||
`w9_sha256` (the form goes to the vault, never the attachment store) and journals the last
|
||||
`w9_upload_id` (`books.begin_upload`; never inline, D-46) (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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
attachment rows. `attach_receipt` / `file_document` return
|
||||
`deduplicated: true` when the blob already existed.
|
||||
- **Limits.** 25 MiB per file as an upload (`books.begin_upload`), 256
|
||||
KiB given inline. The name and MIME type are the client's, checked
|
||||
- **Limits.** 25 MiB per file, as an upload (`books.begin_upload`): the
|
||||
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
|
||||
`.` or `..`, no control or invisible characters; a `type/subtype` of
|
||||
visible ASCII, at most 127 bytes (`docs/mcp-interface.md`, "Tools —
|
||||
|
||||
+5
-2
@@ -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
|
||||
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`
|
||||
(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
|
||||
none could be filed back in as a receipt and read out again.) No file
|
||||
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
|
||||
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.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
|
||||
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
|
||||
|
||||
+84
-1
@@ -2301,7 +2301,8 @@ data root of its own, reached as an agent reaches it.
|
||||
lowered setting take effect on the next call
|
||||
(`a_changed_setting_is_read_on_the_next_call`,
|
||||
`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`,
|
||||
now true of the server itself).
|
||||
- **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
|
||||
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)
|
||||
|
||||
No budget, by decision (the owner, 2026-10-13: the books do little work
|
||||
|
||||
+21
-1
@@ -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
|
||||
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
|
||||
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
|
||||
tools; old books come in whole by `books adopt`; `books.chart_template`
|
||||
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
|
||||
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):**
|
||||
- Invoices and bills take a project now (above), behind the new journal
|
||||
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
|
||||
|
||||
- 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
|
||||
file outputs and its settings-file mode are gone; tests drive a real
|
||||
books server (plan/served.md D-44).
|
||||
|
||||
+11
-10
@@ -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. |
|
||||
| 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). |
|
||||
| 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)"`. |
|
||||
| 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. |
|
||||
@@ -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.
|
||||
{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".
|
||||
@@ -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}`.
|
||||
- `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).
|
||||
- `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.
|
||||
- `openai/fileParams` is declared on the attach tools (P9). An MCP App upload widget comes in P9.
|
||||
|
||||
**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`.
|
||||
- `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.
|
||||
|
||||
**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:**
|
||||
- **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.
|
||||
- **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).
|
||||
- **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).
|
||||
- **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}`: 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
|
||||
|
||||
@@ -850,7 +850,7 @@ The first company keeps working between phases.
|
||||
- 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`.
|
||||
- **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).
|
||||
- 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).
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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-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-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-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
|
||||
|
||||
|
||||
Reference in new issue
Block a user