From 94338b76cb1a60f7609d8ce1d3675bdb632294c0 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sat, 17 Oct 2026 20:47:44 -0600 Subject: [PATCH] K-1 shares print by value; filed documents may print the company's own EIN The first company's P1 cutover found the K-1 worksheet printing a member's share as typed: "50%" read from a settings file printed 0.50, the same share kept by the books as 0.5 printed 0.5. Shares now print at least two places whatever their spelling. Every company document now goes into the books (D-20, owner, 2026-10-17), and formation papers and plans print the company's EIN, which the filing scan refused as a printed TIN. It now passes the company's own EIN, as its settings hold it, printed 2-7, and nothing else; a vault TIN is still refused (rules.md B-26). Its refusal no longer suggests converting a file to get past it. plan/served.md: D-20; correcting a record's source without changing its words (P5). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- crates/ledger-mcp/src/server.rs | 42 +++++++++- crates/ledger-mcp/src/tin_files.rs | 108 ++++++++++++++++++++++---- crates/ledger-mcp/tests/spec_vault.rs | 5 ++ crates/ledger-mcp/tests/tin_files.rs | 39 ++++++++++ docs/spec/rules.md | 10 ++- docs/testing.md | 11 +++ plan/served.md | 3 + 7 files changed, 196 insertions(+), 22 deletions(-) diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 5afad1a..97060be 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -763,9 +763,20 @@ impl BooksServer { file_name, || known_tin_last4(store), || self.vault_tins(store), + self.company_ein(), ) } + /// The company's own EIN, as its settings hold it, as nine digits: + /// printed 2-7 in a filed document, it is no person's TIN + /// (tin_files.rs INVARIANT 3b). + fn company_ein(&self) -> Option { + let settings = self.settings(); + let (_, ein) = settings.company.profile.iter().find(|(k, _)| k == "ein")?; + let digits: String = ein.chars().filter(char::is_ascii_digit).collect(); + (digits.len() == 9).then(|| digits.parse().ok()).flatten() + } + /// Every full TIN the vault holds for a party of the books, for the /// filing scan only (tin_files.rs INVARIANT 3a): never returned or /// logged. None when the vault can't be opened (its key missing or @@ -2359,6 +2370,18 @@ fn money(n: Decimal) -> String { n.to_string() } +/// A member's share as the K-1 worksheet prints it: at least two places, +/// however it was written ("50%", "0.5" and "0.500" all print "0.50"; a +/// share of a third keeps its places). The printed text depends on the +/// share, never on its spelling. +fn share_text(n: Decimal) -> String { + let mut n = n.normalize(); + if n.scale() < 2 { + n.rescale(2); + } + n.to_string() +} + /// Preview lines with resulting balances for raw (account, side, amount) lines. fn preview_lines_for( store: &Store, @@ -8348,7 +8371,7 @@ impl BooksServer { let mut cells = vec![ csv_escape(&m.name), party.map(|id| id.to_string()).unwrap_or_default(), - m.share.to_string(), + share_text(m.share), m.capital_account.clone(), ]; match &vault { @@ -8420,7 +8443,7 @@ impl BooksServer { let mut cells = vec![ csv_escape("Total, all members (not a K-1)"), String::new(), - share.to_string(), + share_text(share), String::new(), String::new(), String::new(), @@ -9845,6 +9868,21 @@ mod tests { assert_eq!(m("12.3456"), "12.3456"); } + /// A share prints by its value, never its spelling: at least two + /// places, a third keeping its own. + #[test] + fn a_share_prints_the_same_however_it_was_written() { + let t = |s: &str| share_text(Decimal::from_str(s).unwrap()); + for half in ["0.5", "0.50", "0.500000"] { + assert_eq!(t(half), "0.50", "{half}"); + } + assert_eq!(t("1"), "1.00"); + assert_eq!(t("1.0"), "1.00"); + assert_eq!(t("0.25"), "0.25"); + assert_eq!(t("0.3333"), "0.3333"); + assert_eq!(t("0.125"), "0.125"); + } + /// Every tool says what it is and what it does to the books (plan /// 4.9, "Annotations (P0)"): a title and readOnlyHint on all; a /// mutating tool also says whether it is destructive. None asks the diff --git a/crates/ledger-mcp/src/tin_files.rs b/crates/ledger-mcp/src/tin_files.rs index 0b06bb2..4b633eb 100644 --- a/crates/ledger-mcp/src/tin_files.rs +++ b/crates/ledger-mcp/src/tin_files.rs @@ -33,6 +33,12 @@ //! 3-2-4 digits joined by hyphens (123-45-6789) or 2-7 digits //! joined by a hyphen (12-3456789), with no digit, and no hyphen //! or dot followed by a digit, on either side. +//! Except the company's own EIN, as the books' settings hold it, +//! printed 2-7 (DECISION, Stephen, 2026-10-17: every company +//! document goes into the books, and its formation papers, +//! plans and letters print its EIN; the settings already hold it +//! in full, rules.md B-29). It is the company's, no person's: (a) +//! still refuses it if the vault holds it for a party. //! Full-width digits and Unicode hyphens and spaces read as their //! ASCII kin. Refused bytes are never stored. //! 4. No probing (DECISION, P0 review, 2026-10-16): (a) answers for at @@ -86,7 +92,7 @@ pub const PROBE_BOUND: usize = 2; /// The refusal for text holding a number printed as an SSN or EIN is /// (INVARIANT 3b). -pub const TIN_PRINTED: &str = "that file's text holds a number printed the way an SSN or EIN is (three, two and four digits joined by hyphens, or two and seven), which reads as a full TIN: a file with a full TIN never goes into the books. If it is no TIN (a reference number printed that way), ask the human to save the file as a PDF and file that"; +pub const TIN_PRINTED: &str = "that file's text holds a number printed the way an SSN or EIN is (three, two and four digits joined by hyphens, or two and seven), which reads as a full TIN: a file with a full TIN never goes into the books. If the number is no TIN (a reference number printed that way), file the original document as its source issued it; never convert or retype a file to get it past this check"; /// Record that `bytes` carry full TINs (INVARIANT 2). pub fn record(vault_dir: &Path, bytes: &[u8]) -> Result<(), String> { @@ -135,7 +141,8 @@ fn holds_sha(vault_dir: &Path, sha: &str) -> Result { /// gives the last four digits of every TIN the books know of, from the /// books (INVARIANT 4), and `held`, asked for only when the text holds /// no more than [`PROBE_BOUND`] candidates, the vault's TINs (none when -/// the vault can't be opened). +/// the vault can't be opened). `own_ein` is the company's EIN, as its +/// settings hold it: printed 2-7, it passes (b). pub fn refuse( vault_dir: &Path, bytes: &[u8], @@ -143,6 +150,7 @@ pub fn refuse( file_name: &str, known_last4: impl FnOnce() -> Vec, held: impl FnOnce() -> Vec, + own_ein: Option, ) -> Result<(), String> { use zeroize::Zeroizing; if holds_sha(vault_dir, &ledger_store::journal::sha256_hex(bytes))? { @@ -172,7 +180,7 @@ pub fn refuse( if holds_held_tin(&text, &tins) { return Err(TIN_HELD.to_string()); } - if holds_printed_tin(&text) { + if holds_printed_tin(&text, own_ein) { return Err(TIN_PRINTED.to_string()); } Ok(()) @@ -249,8 +257,9 @@ fn holds_held_tin(text: &[char], tins: &[u32]) -> bool { /// Whether `text` holds a number printed as an SSN (3-2-4 digits joined /// by hyphens) or an EIN (2-7 joined by a hyphen) is, standing alone: no -/// digit beside it, and no hyphen or dot that a digit follows on (INVARIANT 3b). -fn holds_printed_tin(text: &[char]) -> bool { +/// digit beside it, and no hyphen or dot that a digit follows on; the +/// company's own EIN printed 2-7 aside (INVARIANT 3b). +fn holds_printed_tin(text: &[char], own_ein: Option) -> bool { let digit = |i: usize| text.get(i).is_some_and(|c| c.is_numeric()); let joins_on = |join: usize, digit_at: usize| { text.get(join).is_some_and(|c| matches!(c, '-' | '.')) && digit(digit_at) @@ -259,8 +268,10 @@ fn holds_printed_tin(text: &[char]) -> bool { let alone_before = at == 0 || !(digit(at - 1) || at >= 2 && joins_on(at - 1, at - 2)); alone_before && [&[3, 2, 4][..], &[2, 7][..]].iter().any(|groups| { - digits_at(text, at, groups, &['-']) - .is_some_and(|(_, end)| !(digit(end) || joins_on(end, end + 1))) + digits_at(text, at, groups, &['-']).is_some_and(|(value, end)| { + let own = groups.len() == 2 && Some(value) == own_ein; + !(own || digit(end) || joins_on(end, end + 1)) + }) }) }) } @@ -387,11 +398,11 @@ mod tests { fn a_recorded_tin_file_is_refused_by_every_reader_of_the_registry() { let vault = dir("registry"); let w9 = b"\x89PNG binary W-9 scan".to_vec(); - assert!(refuse(&vault, &w9, "image/png", "w9.png", Vec::new, Vec::new).is_ok()); + assert!(refuse(&vault, &w9, "image/png", "w9.png", Vec::new, Vec::new, None).is_ok()); record(&vault, &w9).unwrap(); record(&vault, &w9).unwrap(); assert_eq!( - refuse(&vault, &w9, "image/png", "w9.png", Vec::new, Vec::new), + refuse(&vault, &w9, "image/png", "w9.png", Vec::new, Vec::new, None), Err(TIN_FILE.into()) ); let text = std::fs::read_to_string(vault.join(FILE_NAME)).unwrap(); @@ -402,7 +413,7 @@ mod tests { /// The registry's folder for a check (it never exists) and no vault. fn check(bytes: &[u8], mime: &str, name: &str) -> Result<(), String> { - refuse(&dir("check"), bytes, mime, name, Vec::new, Vec::new) + refuse(&dir("check"), bytes, mime, name, Vec::new, Vec::new, None) } /// The vault's TINs for the checks: an SSN and an EIN. @@ -422,7 +433,7 @@ mod tests { /// The same, with the vault holding an SSN and an EIN the books know /// the last fours of. fn check_held(bytes: &[u8], mime: &str, name: &str) -> Result<(), String> { - refuse(&dir("check"), bytes, mime, name, known, vault) + refuse(&dir("check"), bytes, mime, name, known, vault, None) } /// `text` in each encoding and label a filed file can come as: UTF-8, @@ -528,7 +539,8 @@ mod tests { "application/pdf", "scan.pdf", || panic!("the last fours were asked for a binary file"), - || panic!("the vault was opened for a binary file") + || panic!("the vault was opened for a binary file"), + None, ) .is_ok() ); @@ -612,6 +624,65 @@ Date,Description,Amount,Running Bal.,Reference assert!(!dir("check").exists(), "a check never creates the registry"); } + /// INVARIANT 3b: the company's own EIN, printed 2-7, passes; printed + /// any other way, or any other EIN, or with no EIN in the settings, it + /// is refused; and (a) still refuses it when the vault holds it. + #[test] + fn the_company_s_own_ein_printed_as_an_ein_passes() { + const OWN: u32 = 123_456_789; + let own = |text: &str| { + refuse( + &dir("own-ein"), + text.as_bytes(), + "text/markdown", + "plan.md", + Vec::new, + Vec::new, + Some(OWN), + ) + }; + assert_eq!(own("# Plan\n\nEIN: 12-3456789. Formed in Utah.\n"), Ok(())); + assert_eq!(own("EIN 12-3456789 and 12-3456789 again"), Ok(())); + assert_eq!( + own("EIN\u{2011}12\u{2010}3456789"), + Ok(()), + "Unicode hyphens fold" + ); + assert_eq!( + own("EIN \u{FF11}\u{FF12}-3456789"), + Ok(()), + "full-width digits fold" + ); + for other in [ + "EIN 98-7654321", + "ours 12-3456789, theirs 98-7654321", + "as an SSN: 123-45-6789", + "one off: 12-3456788", + ] { + assert_eq!(own(other), Err(TIN_PRINTED.into()), "{other}"); + } + assert_eq!( + check(b"EIN: 12-3456789", "text/plain", "x.txt"), + Err(TIN_PRINTED.into()), + "no EIN in the settings: no exemption" + ); + // (a) first: the vault holding that number for a party refuses it. + let held = + || vec![ledger_vault::Tin::new("12-3456789", ledger_vault::TinType::Ein).unwrap()]; + assert_eq!( + refuse( + &dir("own-ein"), + b"EIN: 12-3456789", + "text/plain", + "x.txt", + Vec::new, + held, + Some(OWN), + ), + Err(TIN_HELD.into()) + ); + } + /// INVARIANT 3, failing closed: a registry that is there but can't be /// read refuses every file, saying so. #[test] @@ -625,6 +696,7 @@ Date,Description,Amount,Running Bal.,Reference "r.pdf", Vec::new, Vec::new, + None, ) .expect_err("a registry that can't be read let a file in"); assert!(why.contains("couldn't check the file"), "{why}"); @@ -674,10 +746,10 @@ Date,Description,Amount,Running Bal.,Reference for text in [with_tin, without] { for (bytes, mime, name) in encodings(text) { let refusals = [ - refuse(&dir("check"), &bytes, mime, name, known, vault), - refuse(&dir("check"), &bytes, mime, name, known, no_vault), + refuse(&dir("check"), &bytes, mime, name, known, vault, None), + refuse(&dir("check"), &bytes, mime, name, known, no_vault, None), // The vault locked. - refuse(&dir("check"), &bytes, mime, name, known, Vec::new), + refuse(&dir("check"), &bytes, mime, name, known, Vec::new, None), ]; for r in refusals { assert_eq!(r, Err(TIN_PROBE.into()), "{text:?} as {name}"); @@ -707,7 +779,8 @@ Date,Description,Amount,Running Bal.,Reference "text/csv", "x.csv", Vec::new, - vault + vault, + None, ), Err(TIN_PROBE.into()), "{text:?}" @@ -756,7 +829,8 @@ Date,Description,Amount,Running Bal.,Reference "text/csv", "x.csv", Vec::new, - vault + vault, + None, ), want, "{text:?}, the books knowing no last four" diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index d86d086..4bf5c27 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -1755,6 +1755,11 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { assert_eq!(k1[0]["tin_last4"], "4915"); assert!(!k1[0].contains_key("tin")); assert_eq!(k1[0]["ending_capital"], "950.00"); + // "50%" in the settings file prints by its value, at two places, as + // it would had the books kept it as "0.5". + assert_eq!(k1[0]["share"], "0.50"); + assert_eq!(k1[1]["share"], "0.50"); + assert_eq!(k1[2]["share"], "1.00", "the members' total"); for bytes in plain.values() { assert!(!holds_tin(bytes, MEMBER_SSN_DIGITS) && !holds_tin(bytes, EIN_DIGITS)); } diff --git a/crates/ledger-mcp/tests/tin_files.rs b/crates/ledger-mcp/tests/tin_files.rs index b45a527..d14e66f 100644 --- a/crates/ledger-mcp/tests/tin_files.rs +++ b/crates/ledger-mcp/tests/tin_files.rs @@ -550,6 +550,45 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_ accepted_everywhere(&c, &b, "members-0.csv", "member,tin\nKat,412705531\n").await; } +/// INVARIANT 3b through the tools: the company's own EIN, as its +/// settings hold it, goes in printed 2-7 (formation papers, a business +/// plan) by every filing tool; another EIN, or the same nine digits +/// printed as an SSN, is still refused. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_company_s_own_ein_goes_in_and_no_other() { + let b = books("own-ein"); + let toml = std::fs::read_to_string(&b.toml).unwrap(); + std::fs::write( + &b.toml, + toml.replacen( + "name = \"Tin Co\"\n", + "name = \"Tin Co\"\nein = \"12-3456789\"\n", + 1, + ), + ) + .unwrap(); + let (_server, url) = serve(&b); + let c = connect(&url).await; + accepted_everywhere( + &c, + &b, + "plan.md", + "# Tin Co business plan\n\nEIN 12-3456789, formed 2026-10-04.\n", + ) + .await; + for (n, text) in [ + "EIN 12-3456789; our vendor's is 47-3920615", + "as an SSN: 123-45-6789", + ] + .iter() + .enumerate() + { + let path = b.out.join(format!("other-{n}.md")); + std::fs::write(&path, text).unwrap(); + refused_everywhere(&c, &b, &path, &format!("other-{n}.md"), TIN_PRINTED).await; + } +} + /// INVARIANT 3 through the tools: UTF-16 text written without a /// byte-order mark (ASCII so written is valid UTF-8, each digit followed /// or led by a NUL) is read as the text it is, either way round: a vault diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 539f5e5..1d95b45 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -605,9 +605,13 @@ or a space (compared on the server against the decrypted vault, never returned or logged; a vault that can't be opened leaves (b) alone), or (b) a number printed as an SSN or EIN is, standing alone: 3-2-4 digits joined by hyphens (123-45-6789) or 2-7 joined by a hyphen (12-3456789), -with no digit, and no hyphen or dot followed by a digit, on either side. -Full-width digits and Unicode hyphens and spaces count as their ASCII -kin (`tin_files.rs`). The text check is narrow on purpose (DECISION, +with no digit, and no hyphen or dot followed by a digit, on either side, +except the company's own EIN as its settings hold it, printed 2-7: it is +the company's, no person's, the settings already hold it in full (B-29), +and every company document goes into the books, its formation papers and +plans printing it (DECISION, Stephen, 2026-10-17); (a) still refuses it +should the vault hold that number for a party. Full-width digits and +Unicode hyphens and spaces count as their ASCII kin (`tin_files.rs`). The text check is narrow on purpose (DECISION, Stephen, 2026-10-16): bank exports are filed for reconciliation, and their routing numbers, long references and dates next to amounts are not TINs; the broad `has_full_tin` reading stays on what the server says diff --git a/docs/testing.md b/docs/testing.md index 9d4d1ee..41f1503 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -354,6 +354,17 @@ whether a character is a digit), and `approval_gate`'s fast path (`||` made `&&`: with rules off or none, the probe's `approvals_missing` is empty either way, so only the cost differs). +**After the cutover (2026-10-17).** Two fixes found by the first +company's P1 cutover, mutation-tested on their diff (27 mutants: 22 +caught, 4 unviable, 1 equivalent): the K-1 worksheet prints a share by +its value, at least two places ("50%" and "0.5" alike print "0.50": +`a_share_prints_the_same_however_it_was_written`, and `spec_vault`'s +package test), and the filing scan passes the company's own EIN printed +2-7 and nothing else (`the_company_s_own_ein_printed_as_an_ein_passes`, +`tests/tin_files.rs` `the_company_s_own_ein_goes_in_and_no_other`). +Equivalent: `share_text`'s `scale() < 2` made `<= 2` (padding a +two-place share to two places changes nothing). + ## Benchmarks (baseline 2026-10-13) No budget, by decision (Stephen, 2026-10-13: the books do little work diff --git a/plan/served.md b/plan/served.md index 18241a4..bb5bb99 100644 --- a/plan/served.md +++ b/plan/served.md @@ -906,6 +906,7 @@ Wanderland keeps working between phases. - Full prompt set; resource templates. - Result envelope (`next_steps`, `guards`, `warnings`, `head`) in `call_tool`. - `books.journal_history`; `books.job`. +- **Correcting a record's source.** A convention's citation (or who decided it, or when) can't be corrected today unless its words change too: `set_convention` with only a new source is refused as "already says exactly that" (found at the Wanderland P1 cutover, 2026-10-17, correcting an unchecked citation). A source-only revision, journaled with its reason; the same for decisions and open items. - Tool descriptions ≤ 700 characters; handbook complete; URL-mode elicitation where the client declares it. - `docs/mcp-interface.md` rewrite (24-95, 580-648, 723-747, 909-931). - **Agent-eval harness** `ledger-mcp/tests/agent_evals/`: scripted, with no LLM in CI. A zero-context transcript that follows only `books.start` and `next_steps` must complete onboarding, a receipt-backed post, a draft awaiting a receipt, a reversal draft with inherited evidence, a reconciliation and a month-end close. @@ -940,6 +941,7 @@ Wanderland keeps working between phases. - otherwise a deterministic Typst render of headers and body, with fixed PDF metadata and inline images kept; - `attachment.rendered` is journaled; - the `.eml` itself is never filed. +- **Company records (D-20).** Categories for notices and correspondence, plans, and history beside formation, legal and the rest; the viewer's Documents page grouped by category and subject; the books dir's `company/` folder retired (Wanderland: its files are filed, attachments 73–80, two more after the EIN fix). With extraction, the TIN scan reads PDFs' text too: the own-EIN exemption applies there as well. - **Migration tools (defect 20):** `compare_reference` and `record_inventory`/`check_inventory` replace `scripts/verify-sheet.py` and `scripts/drive-manifest.py`. `eml2pdf.py` is retired. **Tests:** @@ -1141,6 +1143,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-16 | CPA access to TINs | (a) To start, the owner prepares and sends a single-use human link each time. | | D-18 | `dev-stdio` | (a) Kept permanently for tests and demo. It refuses inside the data root and is never offered by `connect`. | | D-19 | One app for Windows, Mac and Linux (owner, 2026-10-17) | Planned: backup and restore in P2, the app itself in P11. One installer and auto-updater per system. The server, the viewer and the CLI ship and update together but stay separate programs inside the app: the agent needs the server while the viewer is closed, and the viewer stays read-only by being a separate program. No separate launcher, which would be a third program and a second writer. The viewer is the app's face and gains menu items: "Back up now" and "Export for CPA" (read-only; the viewer does them), "Restore…" and "New books…" (they change books, so the viewer hands them to the server), and the CPA package with TINs through the human path (P2 `/h/`, P7 vault). The books live in each system's usual app-data folder (the data root of 3.4): hidden, not locked; a little more protected on macOS. **Backups must restore**, into an existing install or a new one, proven before use and never destructive (3.7). | +| D-20 | Company records (owner, 2026-10-17: "It's just too damn convenient to keep it all together in one place.") | Every company document goes into the books, not only what the CPA needs: formation papers, registrations, plans, notices, drafts, history. Done for Wanderland 2026-10-17 with the existing categories; the filing scan passes the company's own EIN printed 2-7 (rules.md B-26). P6 adds what makes it pleasant (below). | ### 10.2 Deferred