wip: pause checkpoint — P6 feature complete, gate interrupted (not for merge)

This commit is contained in:
swaits committed 2026-10-27 14:41:34 -06:00
1 parent cc4f104f06
commit ce36640874
68 files changed
+4592 -374

No files matched your search

@@ -5,3 +5,8 @@
- Not receipt-checked yet: books.create_invoice, books.create_bill, books.receive_payment, books.pay_bill, books.run_payroll and books.remit_payroll_taxes post at once, with no preview and no receipt check. Attach their evidence (the invoice, the bill, the payment confirmation, the payroll register) right after, linked to the entry they return.
- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.
- File a receipt once and link it wherever it is evidence (the attachments section).
- **Reading a file.** Call books.get_receipt first: its default answer gives what the file is, the first 8 KiB of the text it was read as, and how many pages it has. Read the text (`view text` gives all of it). Open a page picture (`view page_image`, `page` 1 for the first) only when the text is empty or looks wrong on a page you must check, and never ask for `view bytes` into your context: a file goes to the human as a download, not through you (D-46). A picture-only receipt has no text by design (no OCR): read its pages.
- **When a file's text failed.** A PDF whose reading failed is listed in Needs attention; read its pages and, if you need the words, ask the human. Do not retype or re-save a file to get it past a check.
- **The same file twice.** Filing bytes the books already hold returns the existing attachment and links it to the new subject: one record, several subjects. Older books may hold two records of one file; `books.consolidate_duplicates` previews them (the oldest is kept, linked where the others were, and the rest withdrawn as duplicates, nothing deleted). It refuses whole rather than leave a posted entry the policy covers with no evidence. Annotations of a withdrawn duplicate are not moved: annotate the kept file.
- **Annotating a receipt.** Record what you read off it with books.annotate_attachment: the receipt number, payee, amount (a decimal string), date, instrument, category, source, who is to be reimbursed, and a note. It changes no amount and no file. Receipt numbers run from the highest one annotated: the answer's `next_receipt_number` is the one to give the next new receipt, so numbers are never reused.
- **Who paid, and who was paid back.** An expense a member paid personally is the company borrowing from them and spending it (the cash flow statement shows both). When a payment pays them back, record what it settles with books.set_entry_settles on the payment, naming the entries it pays back; Needs attention asks until you do, once per entry. Never guess which expense a payment covered (no oldest-first rule): ask the human.
@@ -5,3 +5,5 @@
- Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are.
- Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record.
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, and `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books.
+24
View File
@@ -165,6 +165,15 @@ pub enum Request {
/// The owner passphrase, checked by the server (INVARIANT 7).
passphrase: Passphrase,
},
/// Read one attachment's text now and journal it (plan P6 step 2):
/// the backfill, one attachment at a time, so a long books dir can be
/// resumed.
Extract {
/// The books.
id: String,
/// The attachment to read.
attachment_id: u64,
},
/// End a token.
TokenRevoke {
/// Its books.
@@ -477,6 +486,18 @@ pub enum Response {
},
/// A format upgrade made (or none needed).
FormatUpgraded(FormatUpgraded),
/// An attachment's text read and journaled (plan P6 step 2).
Extracted {
/// The attachment.
attachment_id: u64,
/// "ok", "none" or "failed".
status: String,
/// How many pages it has.
pages: u32,
/// The text blob's SHA-256, when there is text.
#[serde(default, skip_serializing_if = "Option::is_none")]
text_sha256: Option<String>,
},
/// Refused, with why (no path in the data root: INVARIANT 3).
Refused(String),
}
@@ -530,6 +551,9 @@ pub fn call_within(
Request::UpgradeFormat { .. } => {
Some("`wanderbooks books list` says whether the books still wait for it")
}
Request::Extract { .. } => Some(
"`wanderbooks books extract BOOK ATTACHMENT` reads it again; it journals once per extractor version",
),
};
let unknown = move |why: String| match look {
None => CallError::Unknown(why),
+28
View File
@@ -494,6 +494,34 @@ impl Books {
expect_head,
} => self.restore(id, source, replace, confirmed, expect_head),
Request::UpgradeFormat { id, passphrase } => self.upgrade_format(id, &passphrase),
Request::Extract { id, attachment_id } => {
let id = match BookId::parse(&id) {
Ok(id) => id,
Err(e) => return refused(e),
};
let store = match self.held().get(id.as_str()) {
Some(Book::Open { store, .. }) => store.clone(),
Some(Book::Failed(why)) => {
return Response::Refused(format!("those books didn't open: {why}"));
}
None => {
return Response::Refused(format!(
"there are no books called {:?}",
id.as_str()
));
}
};
match store.with_store_mut(|s| s.extract_attachment(attachment_id)) {
Ok(Ok(x)) => Response::Extracted {
attachment_id: x.attachment_id,
status: x.status.key().to_string(),
pages: x.pages,
text_sha256: x.text_sha256,
},
Ok(Err(e)) => Response::Refused(self.hide(e.to_string())),
Err(why) => Response::Refused(self.hide(why)),
}
}
Request::BackUpNow { id, new_disk } => {
let id = match BookId::parse(&id) {
Ok(id) => id,
+6 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 122] = [
pub const TABLE: [(&str, Needs); 126] = [
("books.handbook", R),
("books.start", R),
// Settings are committed at once: the settings commit of the plan.
@@ -186,6 +186,11 @@ pub const TABLE: [(&str, Needs); 122] = [
("books.access_log", A),
("books.backup", R),
("books.back_up_now", A),
// P6: evidence (plan/served.md P6).
("books.find_attachment", R),
("books.annotate_attachment", W),
("books.set_entry_settles", W),
("books.consolidate_duplicates", C),
];
/// What `tool` needs, given its arguments; None for a tool this server
+99 -30
View File
@@ -524,6 +524,7 @@ impl BooksServer {
+ Self::reconciliation_router()
+ Self::parties_router()
+ Self::attachments_router()
+ Self::evidence_router()
+ Self::settings_router()
+ Self::chart_router()
+ Self::payroll_router()
@@ -1354,6 +1355,8 @@ mod chart;
mod documents;
#[path = "tools/entries.rs"]
mod entries;
#[path = "tools/evidence.rs"]
mod evidence;
#[path = "tools/helpers.rs"]
mod helpers;
#[path = "tools/parties.rs"]
@@ -1383,44 +1386,58 @@ pub(crate) use helpers::*;
// ServerHandler
// ---------------------------------------------------------------------------
/// What a resource read gives: JSON/Markdown text, or a file's bytes (a
/// receipt's page picture, plan P6).
enum ResourceBody {
/// Text the agent reads.
Text(String),
/// Bytes, base64 in the MCP answer.
Blob { mime: String, bytes: Vec<u8> },
}
impl BooksServer {
/// Render a resource URI to its JSON text. Used by `read_resource`.
async fn resource_text(&self, uri: &str) -> Result<String, String> {
/// Render a resource URI to what it gives. Used by `read_resource`.
async fn resource_body(&self, uri: &str) -> Result<ResourceBody, String> {
// Split off the query string for URIs like books://digest?since=...
let (path, query) = match uri.split_once('?') {
Some((p, q)) => (p, q),
None => (uri, ""),
};
fn json<T: serde::Serialize>(v: &T) -> Result<ResourceBody, String> {
serde_json::to_string_pretty(v)
.map(ResourceBody::Text)
.map_err(|e| e.to_string())
}
match path {
"books://start" => {
let Json(out) = self.start(Parameters(kept::StartParams::default())).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://settings" => {
let Json(out) = self.settings_tool().await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://conventions" => {
let Json(out) = self
.conventions(Parameters(kept::ConventionsParams::default()))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://chart" => {
let Json(out) = self.chart().await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://reports/trial-balance" => {
let Json(out) = self
.trial_balance(Parameters(TrialBalanceParams::default()))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://reports/balance-sheet" => {
let Json(out) = self
.balance_sheet(Parameters(BalanceSheetParams { as_of: None }))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://reports/profit-and-loss" => {
let Json(out) = self
@@ -1430,16 +1447,16 @@ impl BooksServer {
class: None,
}))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
"books://digest" => {
let since = digest_since(query, today());
let Json(out) = self.digest(Parameters(DigestParams { since })).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
handbook::URI => {
let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
_ if path.starts_with(&format!("{}/", handbook::URI)) => {
let id = &path[handbook::URI.len() + 1..];
@@ -1449,21 +1466,59 @@ impl BooksServer {
self.currency(),
self.document_line_projects(),
)
.map(ResourceBody::Text)
.ok_or_else(|| format!("unknown handbook section: {id}"))
}
_ if path.starts_with("books://receipts/") => {
let id: u64 = path
.strip_prefix("books://receipts/")
.unwrap_or("")
_ if (path.starts_with("books://receipts/")
|| path.starts_with("books://attachments/")) =>
{
// `books://attachments/{id}/page/{n}`: one page as a PNG
// (plan P6; D-46: a file is handed over, but a resource
// read is the agent's own fetch of a picture, never a
// path).
let prefix = if path.starts_with("books://receipts/") {
"books://receipts/"
} else {
"books://attachments/"
};
let rest = &path[prefix.len()..];
if let Some((id, page)) = rest.split_once("/page/") {
let id: u64 = id
.parse()
.map_err(|_| format!("bad attachment resource URI: {uri}"))?;
let page: u32 = page
.parse()
.map_err(|_| format!("bad page in {uri}: give a page number from 1"))?;
if page == 0 {
return Err("page numbers start at 1".into());
}
let store = self.lock()?;
let meta = find_attachment(&store, id)?;
let bytes = store.attachment_bytes(id).map_err(|e| e.to_string())?;
let png = ledger_import::extract::page_png(
&bytes,
usize::try_from(page - 1).unwrap_or(usize::MAX),
1600,
)
.map_err(|e| e.to_string())?;
let _ = meta;
return Ok(ResourceBody::Blob {
mime: "image/png".into(),
bytes: png,
});
}
let id: u64 = rest
.parse()
.map_err(|_| format!("bad receipt resource URI: {uri}"))?;
.map_err(|_| format!("bad attachment resource URI: {uri}"))?;
let Json(out) = self
.get_receipt(Parameters(GetReceiptParams {
attachment_id: id,
view: None,
page: None,
max_px: None,
}))
.await?;
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
json(&out)
}
_ => Err(format!("unknown resource: {uri}")),
}
@@ -1783,29 +1838,43 @@ impl BooksServer {
let uri = request.uri.clone();
let server = self.clone();
let at = uri.clone();
let text = run_blocking(&self.store, move || {
Box::pin(async move { in_timezone_async(timezone, server.resource_text(&at)).await })
let body = run_blocking(&self.store, move || {
Box::pin(async move { in_timezone_async(timezone, server.resource_body(&at)).await })
})
.await?;
// A refusal quotes the URI it was given: masked like a tool's.
let text = text.map_err(|e| {
let body = body.map_err(|e| {
redact_error(rmcp::ErrorData::resource_not_found(
uri.clone(),
Some(serde_json::json!({"reason": e})),
))
})?;
let mime_type = if uri.starts_with(&format!("{}/", handbook::URI)) {
"text/markdown"
} else {
"application/json"
let contents = match body {
ResourceBody::Text(text) => {
let mime_type = if uri.starts_with(&format!("{}/", handbook::URI)) {
"text/markdown"
} else {
"application/json"
};
ResourceContents::TextResourceContents {
uri,
mime_type: Some(mime_type.to_string()),
text,
meta: None,
}
}
ResourceBody::Blob { mime, bytes } => {
use base64::Engine;
ResourceContents::BlobResourceContents {
uri,
mime_type: Some(mime),
blob: base64::engine::general_purpose::STANDARD.encode(bytes),
meta: None,
}
}
};
Ok(ReadResourceResponse::Complete(ReadResourceResult::new(
vec![ResourceContents::TextResourceContents {
uri,
mime_type: Some(mime_type.to_string()),
text,
meta: None,
}],
vec![contents],
)))
}
}
+13 -2
View File
@@ -42,7 +42,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), Some(true));
let table: [(&str, Hints); 122] = [
let table: [(&str, Hints); 126] = [
("books.handbook", R),
("books.start", R),
("books.set_setting", DI),
@@ -162,6 +162,11 @@ fn annotations_complete() {
("books.project", R),
("books.profit_and_loss_by_class", R),
("books.export_cpa_package", W),
// P6: evidence (plan/served.md P6).
("books.find_attachment", R),
("books.annotate_attachment", W),
("books.set_entry_settles", W),
("books.consolidate_duplicates", D),
("books.access_log", R),
("books.backup", R),
("books.back_up_now", WI),
@@ -205,7 +210,7 @@ fn annotations_complete() {
);
}
// docs/mcp-interface.md names the 29 destructive tools.
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 29);
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 30);
}
/// Every refusal an agent can be given whose text is fixed here, or
@@ -468,6 +473,12 @@ fn no_tool_takes_a_path() {
&mut props,
);
for p in props {
// An annotation's `source` (plan P6 step 5) is what a human
// says a receipt came from ("the owner's email"), kept as text
// and never read as a place on this computer.
if t.name == "books.annotate_attachment" && p == "source" {
continue;
}
let lower = p.to_ascii_lowercase();
// Names that hold a place to read or write: whole words,
// so `link_to`, `*_file_name`, `tin_on_file` and `date_to`
+2 -89
View File
@@ -157,7 +157,7 @@ pub fn refuse(
if holds_sha(vault_dir, &ledger_store::journal::sha256_hex(bytes))? {
return Err(TIN_FILE.to_string());
}
let Some(text) = text_of(bytes, mime_type, file_name) else {
let Some(text) = ledger_import::extract::text_of(bytes, mime_type, file_name) else {
return Ok(());
};
let text: Vec<char> = text.chars().map(fold).collect();
@@ -278,97 +278,10 @@ fn holds_printed_tin(text: &[char], own_ein: Option<u32>) -> bool {
})
}
/// Whether a file of this type or name is text: csv, tsv, txt, json, iif,
/// xml, html, md and their kin.
fn text_like(mime_type: &str, file_name: &str) -> bool {
let mime = mime_type.trim().to_ascii_lowercase();
let ext = Path::new(file_name)
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
mime.starts_with("text/")
|| [
"json",
"xml",
"csv",
"x-iif",
"javascript",
"x-yaml",
"yaml",
]
.iter()
.any(|t| mime.ends_with(&format!("/{t}")) || mime.ends_with(&format!("+{t}")))
|| [
"csv", "tsv", "txt", "text", "json", "iif", "xml", "html", "htm", "md", "markdown",
"yaml", "yml", "tab", "prn", "log", "ofx", "qfx", "qif",
]
.contains(&ext.as_str())
}
/// The bytes as text, if they are text (INVARIANT 3): UTF-16 with its
/// byte-order mark, UTF-8, or, for a text type or name, whatever they
/// decode to. Text with NUL bytes and no byte-order mark (UTF-16 written
/// without one is valid UTF-8, its digits split by NULs) is read every
/// way it can be (as it is, as UTF-16 both ways round, and with its NULs
/// taken out), each reading on a line of its own: so is bytes that aren't
/// UTF-8 but are NUL-interleaved as UTF-16 is.
fn text_of(bytes: &[u8], mime_type: &str, file_name: &str) -> Option<String> {
let utf16 = |body: &[u8], le: bool| {
let units: Vec<u16> = body
.as_chunks::<2>()
.0
.iter()
.map(|&c| {
if le {
u16::from_le_bytes(c)
} else {
u16::from_be_bytes(c)
}
})
.collect();
String::from_utf16_lossy(&units)
};
match bytes {
[0xFF, 0xFE, body @ ..] => Some(utf16(body, true)),
[0xFE, 0xFF, body @ ..] => Some(utf16(body, false)),
_ => {
let plain = match std::str::from_utf8(bytes) {
Ok(t) => Some(t.to_string()),
Err(_) if text_like(mime_type, file_name) => {
Some(String::from_utf8_lossy(bytes).into_owned())
}
Err(_) => None,
};
if !bytes.contains(&0) || plain.is_none() && !nul_interleaved(bytes) {
return plain;
}
let without_nuls: Vec<u8> = bytes.iter().copied().filter(|&b| b != 0).collect();
let readings = [
plain.unwrap_or_default(),
utf16(bytes, true),
utf16(bytes, false),
String::from_utf8_lossy(&without_nuls).into_owned(),
];
Some(readings.join("\n"))
}
}
}
/// Whether `bytes` look like UTF-16 without a byte-order mark: of their
/// byte pairs, at least half have a NUL on one side (the high byte of a
/// Latin character) and at most an eighth on the other.
fn nul_interleaved(bytes: &[u8]) -> bool {
let pairs = bytes.as_chunks::<2>().0;
let nuls = |side: usize| pairs.iter().filter(|p| p[side] == 0).count();
let (first, second) = (nuls(0), nuls(1));
let n = pairs.len();
n > 0 && (2 * first >= n && 8 * second <= n || 2 * second >= n && 8 * first <= n)
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_import::extract::nul_interleaved;
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
+83 -21
View File
@@ -138,7 +138,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, 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.",
description = "File a document: category formation, legal, tax, accounting, statement (then books.record_statement), contract, photo, warranty, import_source (a bank export), notices, correspondence, plans, history, or other. link_to links it to subjects in the same call (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). A file holding a full TIN is refused, as is text with one the vault keeps or an SSN or EIN printed 3-2-4 or 2-7.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -152,10 +152,11 @@ impl BooksServer {
let p = params.0;
match p.category.as_str() {
"formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo"
| "warranty" | "import_source" | "other" => {}
| "warranty" | "import_source" | "notices" | "correspondence" | "plans" | "history"
| "other" => {}
_ => {
return Err(format!(
"bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, or other",
"bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, notices, correspondence, plans, history, or other",
p.category
));
}
@@ -454,7 +455,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 the file, handed over: run its download (wanderbooks download) to save a copy in a folder you name; its bytes never come back here.",
description = "A receipt or document: what it is, what it is linked to, its first 8 KiB of text and its pages. view text gives the whole extracted text (past 64 KiB as a handed-over text file); view page_image with page (1 is the first) gives one page as a PNG, handed over (D-46, D-15: no OCR). view bytes hands over the file itself. Text first; a page picture only when the text is empty or wrong; never read a file's bytes into your context.",
annotations(read_only_hint = true, open_world_hint = false)
)]
pub(super) async fn get_receipt(
@@ -462,29 +463,90 @@ impl BooksServer {
params: Parameters<GetReceiptParams>,
) -> Result<Json<GetReceiptOut>, String> {
let p = params.0;
let bytes_wanted = match p.view.as_deref().unwrap_or("meta") {
"meta" => false,
"bytes" => true,
other => {
return Err(format!(
"view {:?}: meta (the default) or bytes",
other.chars().take(20).collect::<String>()
));
}
};
let view = p.view.as_deref().unwrap_or("meta");
if !matches!(view, "meta" | "text" | "page_image" | "bytes") {
return Err(format!(
"view {:?}: meta (the default), text, page_image or bytes",
view.chars().take(20).collect::<String>()
));
}
let store = self.lock()?;
let meta = find_attachment(&store, p.attachment_id)?;
let extraction = store.extraction(meta.id).map_err(|e| e.to_string())?;
let pages = extraction.as_ref().map_or(0, |x| x.pages);
let text_status = extraction
.as_ref()
.map_or("not_extracted", |x| x.status.key())
.to_string();
let annotation = store.ledger().annotation(meta.id).cloned();
let full_text = store.extraction_text(meta.id).map_err(|e| e.to_string())?;
let book = book_name(&store);
// Handed over, read hash-verified (D-46): never its bytes here.
let file = if bytes_wanted {
let bytes = store
.attachment_bytes(p.attachment_id)
let mut file = None;
// Every view carries the meta answer's first 8 KiB of text (view
// `text` replaces it with the whole text, or hands a file over).
let mut text = None;
let mut text_truncated = false;
if let Some(t) = &full_text {
let cut = floor_char_boundary(t, 8 * 1024);
text_truncated = cut < t.len();
text = Some(t[..cut].to_string());
}
match view {
"meta" => {}
"text" => match full_text {
None => {
return Err(format!(
"attachment {} has no extracted text (text_status {text_status}); view page_image to read its pages",
meta.id
));
}
Some(t) if t.len() <= 64 * 1024 => text = Some(t),
Some(t) => {
// Past 64 KiB the whole text is the handed file, not
// the preview.
text = None;
text_truncated = false;
let name = format!("{}-text.txt", stem(&meta.file_name));
file = Some(self.hand_over(&book, &name, "text/plain", t.into_bytes())?);
}
},
"page_image" => {
let page = p.page.ok_or("view page_image needs page: 1 is the first")?;
let max_px = p.max_px.unwrap_or(1600);
if !(1..=8192).contains(&max_px) {
return Err("max_px must be 1 to 8192".into());
}
let bytes = store.attachment_bytes(meta.id).map_err(|e| e.to_string())?;
if meta.mime_type != "application/pdf" && !bytes.starts_with(b"%PDF") {
return Err(format!(
"attachment {} isn't a PDF: view bytes for the file itself",
meta.id
));
}
let png = ledger_import::extract::page_png(
&bytes,
usize::try_from(page - 1).unwrap_or(usize::MAX),
max_px,
)
.map_err(|e| e.to_string())?;
Some(self.hand_over(&book_name(&store), &meta.file_name, &meta.mime_type, bytes)?)
} else {
None
};
let name = format!("{}-p{page}.png", stem(&meta.file_name));
file = Some(self.hand_over(&book, &name, "image/png", png)?);
}
"bytes" => {
let bytes = store.attachment_bytes(meta.id).map_err(|e| e.to_string())?;
file = Some(self.hand_over(&book, &meta.file_name, &meta.mime_type, bytes)?);
}
_ => unreachable!("view checked"),
}
Ok(Json(GetReceiptOut {
file,
text,
text_truncated,
text_status,
pages,
receipt_number: annotation.as_ref().and_then(|a| a.fields.receipt_number),
annotation: annotation.as_ref().map(annotation_out),
links: links_out(&store, meta.id)?,
description: meta.description.clone(),
attachment_id: meta.id,
+412
View File
@@ -0,0 +1,412 @@
//! Evidence beyond a file's bytes: finding it, annotating it, and
//! consolidating duplicates (plan/served.md P6 steps 4 and 5).
//!
//! ```text
//! GOAL: a receipt found by what it is or says, the facts a human read off
//! it kept on the record, and identical files held once, linked
//! wherever they are evidence.
//! SOURCE: plan/served.md P6 step 4 (defect 4) and step 5 (defect 10);
//! IZ-18 (no FIFO guess: `entry.settles` only); Rev. Proc. 98-25
//! (an audit trail); rules.md B-44.
//! ```
use super::*;
/// A date as the tool takes it.
fn date_of(text: &str) -> Result<NaiveDate, String> {
NaiveDate::parse_from_str(text.trim(), "%Y-%m-%d")
.map_err(|_| format!("{text:?} isn't a date: give YYYY-MM-DD"))
}
#[tool_router(router = evidence_router, vis = "pub(super)")]
impl BooksServer {
/// ```text
/// GOAL: find a filed file by its exact bytes, its receipt number, or
/// what its text says.
/// SOURCE: plan/served.md P6 step 4; B-44.
/// INVARIANTS:
/// 1. EXACTLY one of sha256, receipt_number and text is given.
/// 2. Withdrawn files are listed too, marked (a duplicate is found
/// before it is consolidated).
/// 3. A text search is case-insensitive over the extracted text.
/// SCENARIOS:
/// 1. IF none or more than one is given: SHOULD refuse.
/// 2. IF sha256 is not 64 hex digits: SHOULD refuse.
/// 3. IF nothing matches: SHOULD answer with an empty list.
/// ```
#[tool(
name = "books.find_attachment",
title = "Find a filed file",
description = "Find receipts and documents by exactly one of: sha256 (the file's exact hash), receipt_number (what books.annotate_attachment recorded), or text (words its extracted text says, case-insensitively). Withdrawn files are listed too, marked, so a duplicate is found before books.consolidate_duplicates. Each comes with all its links.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn find_attachment(
&self,
params: Parameters<FindAttachmentParams>,
) -> Result<Json<FindAttachmentOut>, String> {
let p = params.0;
let given = [
p.sha256.is_some(),
p.receipt_number.is_some(),
p.text.is_some(),
]
.into_iter()
.filter(|g| *g)
.count();
if given != 1 {
return Err(
"give exactly one of sha256, receipt_number or text: they find a file three ways"
.into(),
);
}
let store = self.lock()?;
let mut found = if let Some(sha) = p.sha256 {
let sha = sha.trim().to_lowercase();
if sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("sha256 is 64 hexadecimal digits".into());
}
store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.filter(|a| a.blob_sha.eq_ignore_ascii_case(&sha))
.collect()
} else if let Some(n) = p.receipt_number {
store
.attachments_with_receipt_number(n)
.map_err(|e| e.to_string())?
} else {
let text = p.text.unwrap_or_default();
let text = text.trim();
if text.chars().count() < 2 {
return Err("text is at least two characters".into());
}
store
.attachments_holding_text(text)
.map_err(|e| e.to_string())?
};
found.sort_by_key(|a| a.id);
let attachments = found
.into_iter()
.map(|a| receipt_out(&store, a))
.collect::<Result<_, _>>()?;
Ok(Json(FindAttachmentOut { attachments }))
}
/// ```text
/// GOAL: record what a human read off a file, without touching the file
/// or the ledger's amounts.
/// SOURCE: plan/served.md P6 step 5 (defect 10); B-44.
/// INVARIANTS:
/// 1. Journaled as `attachment.annotated`; a refusal journals nothing.
/// 2. The fields are the closed list of `AnnotationFieldsParam`; at
/// least one field or the note is given.
/// 3. A new annotation replaces the file's earlier one; the file
/// stays filed and its bytes unchanged.
/// 4. The answer names the next receipt number (one past the highest
/// annotated).
/// SCENARIOS:
/// 1. IF the attachment is unknown or withdrawn: SHOULD refuse.
/// 2. IF nothing is given, an amount is negative or malformed, or a
/// date is outside the calendar: SHOULD refuse.
/// ```
#[tool(
name = "books.annotate_attachment",
title = "Annotate a filed file",
description = "Record what a human read off a receipt or document: receipt_number, payee, amount (decimal string), date (YYYY-MM-DD), instrument, category, source, reimbursable_to, and a note. It changes no amount and no file; a later annotation replaces this one. The answer gives next_receipt_number (one past the highest annotated), so receipt numbers run without reuse. Amounts are exact decimal strings.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn annotate_attachment(
&self,
params: Parameters<AnnotateAttachmentParams>,
) -> Result<Json<AnnotateAttachmentOut>, String> {
let p = params.0;
let currency = self.currency();
let amount = match p.fields.amount.as_deref() {
None => None,
Some(a) => Some(a.trim().parse::<Decimal>().map_err(|_| {
format!("{a:?} isn't an amount: give a decimal string like \"12.34\"")
})?),
};
let date = match p.fields.date.as_deref() {
None => None,
Some(d) => Some(date_of(d)?),
};
let annotation = ledger_core::Annotation {
attachment_id: p.attachment_id,
fields: ledger_core::AnnotationFields {
receipt_number: p.fields.receipt_number,
payee: p.fields.payee,
amount,
date,
instrument: p.fields.instrument,
category: p.fields.category,
source: p.fields.source,
reimbursable_to: p.fields.reimbursable_to,
},
note: p.note,
};
let _ = currency;
let mut store = self.lock()?;
let stored = store
.annotate_attachment(annotation)
.map_err(|e| e.to_string())?;
let next = store.ledger().next_receipt_number();
Ok(Json(AnnotateAttachmentOut {
attachment_id: stored.attachment_id,
fields: annotation_out(&stored),
next_receipt_number: next,
}))
}
/// ```text
/// GOAL: record which entries a payment settles, so "the member was
/// paid back" is read from the books and never guessed.
/// SOURCE: plan/served.md P6 step 5 (IZ-18: no FIFO guess).
/// INVARIANTS:
/// 1. Journaled as `entry.settles`; a refusal journals nothing.
/// 2. Every named entry exists, none is the paying entry itself, none
/// is named twice.
/// 3. An empty list clears the record.
/// SCENARIOS:
/// 1. IF the paying entry or a settled entry is unknown, or the list
/// names the paying entry or one twice: SHOULD refuse.
/// ```
#[tool(
name = "books.set_entry_settles",
title = "Record what a payment settles",
description = "Record which entries an entry pays back, such as a reimbursement to a member settling the expenses they paid personally. Needs attention stops asking about a member-paid entry once a payment names it here. Empty clears the record. It changes no amount: it names what the payment settles.",
annotations(
read_only_hint = false,
destructive_hint = false,
open_world_hint = false
)
)]
async fn set_entry_settles(
&self,
params: Parameters<SetEntrySettlesParams>,
) -> Result<Json<SetEntrySettlesOut>, String> {
let p = params.0;
let mut store = self.lock()?;
store
.set_entry_settles(p.entry_id, &p.settles)
.map_err(|e| e.to_string())?;
Ok(Json(SetEntrySettlesOut {
ok: true,
entry_id: p.entry_id,
settles: p.settles,
}))
}
/// ```text
/// GOAL: hold identical files once, linked wherever they were evidence,
/// without ever leaving an entry the receipt policy covers with
/// none.
/// SOURCE: plan/served.md P6 step 4 (defect 4); rules.md B-44.
/// INVARIANTS:
/// 1. Records sharing one blob SHA are one set; the oldest is kept
/// and linked to every subject the others had.
/// 2. Every other record is unlinked, then withdrawn as a duplicate
/// (`duplicate_of`), journaled; nothing is deleted.
/// 3. Every touched entry's evidence is re-checked before the first
/// journal append: an entry that would be left with none and
/// needs one refuses the whole call, journaling nothing.
/// 4. Without `confirmed`, a preview that changes nothing.
/// SCENARIOS:
/// 1. IF the kept record is withdrawn and cannot stand in for a
/// duplicate that is a posted entry's only evidence: SHOULD
/// refuse (receipt_required).
/// 2. IF no two records share bytes: SHOULD answer with no groups.
/// ```
#[tool(
name = "books.consolidate_duplicates",
title = "Consolidate duplicate files",
description = "Find receipts and documents filed more than once with the same bytes. For each set it keeps the oldest, links it to every subject the others had, unlinks the others, and withdraws them as duplicates of it (nothing is deleted). Refused whole when it would leave a posted entry the receipt policy covers with no evidence. Without confirmed, a preview that changes nothing.",
annotations(
read_only_hint = false,
destructive_hint = true,
open_world_hint = false
)
)]
async fn consolidate_duplicates(
&self,
params: Parameters<ConsolidateDuplicatesParams>,
) -> Result<Json<ConsolidateDuplicatesOut>, String> {
use std::collections::{BTreeMap, BTreeSet};
let confirmed = params.0.confirmed.unwrap_or(false);
let mut store = self.lock()?;
let all = store.list_attachments(None).map_err(|e| e.to_string())?;
let mut by_sha: BTreeMap<String, Vec<ledger_store::Attachment>> = BTreeMap::new();
for a in all {
by_sha.entry(a.blob_sha.clone()).or_default().push(a);
}
// One plan per set of two or more records.
struct Group {
sha: String,
keep: ledger_store::Attachment,
dups: Vec<ledger_store::Attachment>,
link: BTreeSet<ledger_store::Subject>,
unlink: Vec<(u64, ledger_store::Subject)>,
}
let mut groups: Vec<Group> = Vec::new();
for (sha, mut records) in by_sha {
if records.len() < 2 {
continue;
}
records.sort_by_key(|a| a.id);
let keep = records.remove(0);
let mut link: BTreeSet<ledger_store::Subject> = BTreeSet::new();
let mut unlink = Vec::new();
let mut live_dups = Vec::new();
for d in records {
if d.withdrawn_at.is_some() {
continue;
}
for sub in subjects_of(&store, &d)? {
if !covered(&store, &keep, sub)? {
link.insert(sub);
}
if !matches!(sub, ledger_store::Subject::Entry(e) if Some(e) == d.entry_id) {
unlink.push((d.id, sub));
}
}
live_dups.push(d);
}
if live_dups.is_empty() {
continue;
}
groups.push(Group {
sha,
keep,
dups: live_dups,
link,
unlink,
});
}
// Consolidating writes `duplicate_of`: the books must read the
// feature before anything at all is journaled (plan P6, R-A).
if !groups.is_empty() {
store
.ensure_feature(ledger_store::format::ATTACHMENT_TEXT)
.map_err(|e| e.to_string())?;
}
// The evidence gate, before anything is journaled: an entry that
// would be left with none, and needs one, refuses the whole call.
for g in &groups {
let keep_stands = g.keep.withdrawn_at.is_none();
for sub in &g.link {
if let ledger_store::Subject::Entry(e) = sub {
let mut left = store
.list_attachments(Some(*e))
.map_err(|e| e.to_string())?
.into_iter()
.filter(|a| a.withdrawn_at.is_none())
.filter(|a| !g.dups.iter().any(|d| d.id == a.id))
.count();
if keep_stands && !covered(&store, &g.keep, *sub)? {
left += 1;
}
if left == 0 {
self.ensure_receipt_not_required(&store, *e)
.map_err(|why| {
format!(
"not consolidated: entry {e}'s only evidence is a duplicate, and the kept file can't stand in for it ({why})"
)
})?;
}
}
}
}
let out_groups: Vec<DuplicateGroupOut> = groups
.iter()
.map(|g| DuplicateGroupOut {
sha256: g.sha.clone(),
keep: g.keep.id,
withdraw: g.dups.iter().map(|d| d.id).collect(),
links: g.link.iter().map(|s| subject_out(&store, *s)).collect(),
})
.collect();
let withdrawn = out_groups.iter().map(|g| g.withdraw.len()).sum();
if !confirmed {
return Ok(Json(ConsolidateDuplicatesOut {
status: "preview".into(),
groups: out_groups,
withdrawn,
}));
}
for g in &groups {
for sub in &g.link {
store
.link_attachment(g.keep.id, *sub)
.map_err(|e| e.to_string())?;
}
let reason = format!("duplicate of #{}", g.keep.id);
for (id, sub) in &g.unlink {
// A link a previous step took off (it was on the kept file
// already) isn't there to take off again.
if store
.attachment_links(*id)
.map_err(|e| e.to_string())?
.contains(sub)
{
store
.unlink_attachment(*id, *sub, &reason)
.map_err(|e| e.to_string())?;
}
}
for d in &g.dups {
if d.entry_id.is_some() {
store
.unlink_receipt(d.id, &reason)
.map_err(|e| e.to_string())?;
}
store
.withdraw_attachment_duplicate(d.id, &reason, g.keep.id)
.map_err(|e| e.to_string())?;
}
}
Ok(Json(ConsolidateDuplicatesOut {
status: "consolidated".into(),
groups: out_groups,
withdrawn,
}))
}
}
/// Every subject a filing is evidence of: the entry it is filed on, and
/// every explicit link.
fn subjects_of(
store: &Store,
a: &ledger_store::Attachment,
) -> Result<Vec<ledger_store::Subject>, String> {
let mut out: Vec<ledger_store::Subject> = a
.entry_id
.map(ledger_store::Subject::Entry)
.into_iter()
.collect();
out.extend(store.attachment_links(a.id).map_err(|e| e.to_string())?);
out.sort();
out.dedup();
Ok(out)
}
/// Whether `a` is already evidence of `sub`.
fn covered(
store: &Store,
a: &ledger_store::Attachment,
sub: ledger_store::Subject,
) -> Result<bool, String> {
if let ledger_store::Subject::Entry(e) = sub
&& a.entry_id == Some(e)
{
return Ok(true);
}
Ok(store
.attachment_links(a.id)
.map_err(|e| e.to_string())?
.contains(&sub))
}
+8 -2
View File
@@ -48,13 +48,14 @@ pub(crate) const INLINE_ARGS: [&str; 5] = [
/// 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.
/// file's hash, not its bytes, and books.find_attachment's is what it
/// searches by (plan P6 step 4): neither file passes through the call.
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" {
if matches!(tool, "books.begin_upload" | "books.find_attachment") && given == "sha256" {
return None;
}
Some(format!(
@@ -368,6 +369,11 @@ mod tests {
inline_refusal("books.begin_upload", Some(&args("sha256"))),
None
);
// A search by a file's hash names no bytes (plan P6).
assert_eq!(
inline_refusal("books.find_attachment", 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);
}
+38
View File
@@ -708,3 +708,41 @@ pub(crate) fn resolve_instrument(store: &Store, r: &str) -> Result<InstrumentId,
)),
}
}
/// An annotation as a tool answers with it (plan P6 step 5).
pub(crate) fn annotation_out(a: &ledger_core::Annotation) -> AnnotationOut {
AnnotationOut {
receipt_number: a.fields.receipt_number,
payee: a.fields.payee.clone(),
amount: a.fields.amount.map(money),
date: a.fields.date.map(|d| d.to_string()),
instrument: a.fields.instrument.clone(),
category: a.fields.category.clone(),
source: a.fields.source.clone(),
reimbursable_to: a.fields.reimbursable_to.clone(),
note: a.note.clone(),
}
}
/// A file name's stem, for a page picture's or a text file's name: what is
/// before the last dot, or "attachment" when there is nothing.
pub(crate) fn stem(file_name: &str) -> String {
let stem = file_name
.rsplit_once('.')
.map_or(file_name, |(stem, _)| stem)
.trim();
if stem.is_empty() {
"attachment".to_string()
} else {
stem.chars().take(80).collect()
}
}
/// The largest character boundary at or below `at` in `text`.
pub(crate) fn floor_char_boundary(text: &str, at: usize) -> usize {
let mut cut = at.min(text.len());
while !text.is_char_boundary(cut) {
cut -= 1;
}
cut
}
+194 -5
View File
@@ -1849,11 +1849,21 @@ pub struct ListReceiptsOut {
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 the file, handed over: the command that
/// saves a copy, never its bytes; D-46).
/// What to return: "meta" (default: what it is, what it's linked to,
/// the first 8 KiB of its text and its page count), "text" (the whole
/// extracted text, or a handed-over text file past 64 KiB),
/// "page_image" (one page as a PNG, handed over: give `page`), or
/// "bytes" (the file itself, handed over: the command that saves a
/// copy, never its bytes; D-46).
#[serde(default)]
pub view: Option<String>,
/// For view "page_image": the page, 1 for the first.
#[serde(default)]
pub page: Option<u32>,
/// For view "page_image": the picture's longest side in pixels
/// (default 1600, at most 8192).
#[serde(default)]
pub max_px: Option<u32>,
}
/// Result of `books.get_receipt`: what it is and what it's linked to, and
@@ -1878,10 +1888,31 @@ pub struct GetReceiptOut {
pub description: Option<String>,
/// Everything it is linked to (the entry it is filed on first).
pub links: Vec<SubjectOut>,
/// With view `bytes`: the file, handed over: run its `download` to
/// save a copy in a folder you name.
/// With view `bytes` or `page_image`: the file (or the page picture),
/// handed over: run its `download` to save a copy in a folder you
/// name.
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<HandedFile>,
/// The first 8 KiB of the extracted text (view `meta`), or the whole
/// text (view `text`, when it fits in 64 KiB).
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
/// True when `text` is only the first 8 KiB of a longer extraction.
#[serde(default)]
pub text_truncated: bool,
/// What reading the file found: "ok", "none", "failed", or
/// "not_extracted" (books this version didn't read yet:
/// `wanderbooks books extract`).
pub text_status: String,
/// How many pages the file has (0 when it has none).
#[serde(default)]
pub pages: u32,
/// The receipt number annotated on it, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub receipt_number: Option<u32>,
/// Everything annotated about it, if anything.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotation: Option<AnnotationOut>,
}
// ---------------------------------------------------------------------------
@@ -4758,3 +4789,161 @@ pub struct HandbookOut {
/// The section asked for (null: none was).
pub section: Option<HandbookTextOut>,
}
// ---------------------------------------------------------------------------
// P6: evidence (plan/served.md P6: extraction, dedup, annotations)
// ---------------------------------------------------------------------------
/// One annotation's fields, as a human reads them off a file (plan P6 step
/// 5). Every field is optional; the amount is an exact decimal string in
/// the books' own currency.
#[derive(Debug, Default, Clone, Deserialize, JsonSchema)]
pub struct AnnotationFieldsParam {
/// The number printed on the receipt. Engine numbers run from the
/// highest one annotated (`next_receipt_number` in the answer).
pub receipt_number: Option<u32>,
/// Who was paid.
pub payee: Option<String>,
/// The amount printed on the receipt, a decimal string such as
/// "12.34".
pub amount: Option<String>,
/// The date printed on the receipt, YYYY-MM-DD.
pub date: Option<String>,
/// How it was paid: a payment method's id or label.
pub instrument: Option<String>,
/// What kind of record it is (the document categories).
pub category: Option<String>,
/// Where it came from.
pub source: Option<String>,
/// Who is to be reimbursed for it, if anyone.
pub reimbursable_to: Option<String>,
}
/// One recorded annotation, as read back.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct AnnotationOut {
/// The number printed on the receipt, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub receipt_number: Option<u32>,
/// Who was paid, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub payee: Option<String>,
/// The amount printed on the receipt, decimal string, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<String>,
/// The date printed on the receipt, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
/// How it was paid, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub instrument: Option<String>,
/// What kind of record it is, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
/// Where it came from, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// Who is to be reimbursed for it, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub reimbursable_to: Option<String>,
/// A free note, if recorded.
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// Parameters for `books.find_attachment`: exactly one of its three.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct FindAttachmentParams {
/// The exact SHA-256 of the file's bytes.
pub sha256: Option<String>,
/// The receipt number recorded on it (books.annotate_attachment).
pub receipt_number: Option<u32>,
/// Text it says, case-insensitively (its extracted text; see
/// books.get_receipt view text).
pub text: Option<String>,
}
/// Result of `books.find_attachment`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FindAttachmentOut {
/// Every match, withdrawn ones included (marked), oldest first.
pub attachments: Vec<ReceiptOut>,
}
/// Parameters for `books.annotate_attachment`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AnnotateAttachmentParams {
/// The attachment.
pub attachment_id: u64,
/// What was read off it (the closed field list).
pub fields: AnnotationFieldsParam,
/// A free note.
pub note: Option<String>,
}
/// Result of `books.annotate_attachment`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct AnnotateAttachmentOut {
/// The attachment.
pub attachment_id: u64,
/// The annotation as stored (it replaces an earlier one).
pub fields: AnnotationOut,
/// The next receipt number the books would give: one past the highest
/// annotated.
pub next_receipt_number: u32,
}
/// Parameters for `books.set_entry_settles`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SetEntrySettlesParams {
/// The entry that pays, such as a reimbursement to a member.
pub entry_id: u64,
/// The entries it settles (what it pays back). Empty clears the record.
pub settles: Vec<u64>,
}
/// Result of `books.set_entry_settles`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct SetEntrySettlesOut {
/// True when recorded.
pub ok: bool,
/// The entry that pays.
pub entry_id: u64,
/// The entries it settles now.
pub settles: Vec<u64>,
}
/// One set of attachments holding the same bytes (plan P6 step 4).
#[derive(Debug, Serialize, JsonSchema)]
pub struct DuplicateGroupOut {
/// The SHA-256 they share.
pub sha256: String,
/// The attachment kept (the oldest).
pub keep: u64,
/// The attachments withdrawn as duplicates of it.
pub withdraw: Vec<u64>,
/// The subjects the kept file is linked to on their behalf.
pub links: Vec<SubjectOut>,
}
/// Parameters for `books.consolidate_duplicates`.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ConsolidateDuplicatesParams {
/// True: consolidate. Without it, a preview that changes nothing.
pub confirmed: Option<bool>,
}
/// Result of `books.consolidate_duplicates`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConsolidateDuplicatesOut {
/// "preview" or "consolidated".
pub status: String,
/// Each set of attachments holding one file's bytes.
pub groups: Vec<DuplicateGroupOut>,
/// How many attachments were (or would be) withdrawn.
pub withdrawn: usize,
}