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