fix(tin): mask and refuse a comma-separated TIN (P7)

The scanner now finds a comma-grouped TIN, so the two places that hand
raw text out or let it in must follow: the access log's digest and its
64-character client texts masked hyphens but not commas, and a filed
file's printed-shape check refused 123-45-6789 but let 123,45,6789 in.

The access log uses the shared redactor, so it inherits the fix; the
filing scan keeps its own deliberately narrow shapes (a bank export's
routing numbers and amounts must still be filed, B-26) and gains the
comma renderings of an SSN's 3-2-4 and an EIN's 2-7. A comma-grouped
amount is still no TIN and still files.

No book, journal line or figure changes.
This commit is contained in:
swaits committed 2026-10-26 20:28:41 -06:00
1 parent c6527280e7
commit 9e67c8819d
2 files changed
+98 -21

No files matched your search

+56 -21
View File
@@ -26,13 +26,15 @@
//! without one) holding a full TIN, read as either:
//! a. a TIN the vault holds for a party of the books, anywhere in the
//! text, written as nine digits in a row, as 3-2-4 digits joined
//! by hyphens, spaces or dots, or as 2-7 digits joined by a hyphen
//! or a space (compared on the server; the vault's TINs are never
//! returned or logged). A vault that can't be opened leaves (b);
//! by hyphens, spaces, dots or commas, or as 2-7 digits joined by
//! a hyphen, a space or a comma (compared on the server; the
//! vault's TINs are never returned or logged). A vault that can't
//! be opened leaves (b);
//! b. a number printed the way an SSN or an EIN is, standing alone:
//! 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.
//! 3-2-4 digits joined by hyphens or commas (123-45-6789,
//! 123,45,6789) or 2-7 digits joined by a hyphen or a comma
//! (12-3456789, 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, owner, 2026-10-17: every company
//! document goes into the books, and its formation papers,
@@ -61,13 +63,12 @@
//! the server says).
//! 6. Not detected: a changed copy of a binary file (a re-saved or
//! re-scanned W-9 PDF), and a TIN the vault doesn't hold written
//! other than (b). plan/served.md P2 and P7 take the path tools and
//! full TINs out of the agent's reach. Open until then: (a) still
//! answers for up to 2 candidates a filing (4), so an agent that
//! files 50,000 refused files can find a TIN; P2's access log
//! records every one of those calls, and P7's human-only TIN channel
//! takes full TINs, and so this check, off the agent's path
//! (rules.md B-26).
//! other than (b). TINs cross the MCP on their way into the vault by
//! design (plan D-28), the vault stays v1 (D-48), and (a) still
//! answers for up to 2 candidates a filing (4): an agent that files
//! 50,000 refused files can find a TIN, so the access log records
//! every one of those calls and the probe bound keeps each answer
//! from telling more than two guesses apart (rules.md B-26).
//! ```
use std::path::Path;
@@ -222,13 +223,13 @@ fn digits_at(text: &[char], at: usize, groups: &[usize], joins: &[char]) -> Opti
}
/// Every TIN-shaped number in `text`, at every place, in every rendering
/// (a) reads: nine digits in a row, 3-2-4 joined by hyphens, spaces or
/// dots, 2-7 joined by a hyphen or a space (INVARIANT 3a).
/// (a) reads: nine digits in a row, 3-2-4 joined by hyphens, spaces, dots
/// or commas, 2-7 joined by a hyphen, a space or a comma (INVARIANT 3a).
fn tin_shaped(text: &[char]) -> impl Iterator<Item = u32> + '_ {
const RENDERINGS: [(&[usize], &[char]); 3] = [
(&[9], &[]),
(&[3, 2, 4], &['-', ' ', '.']),
(&[2, 7], &['-', ' ']),
(&[3, 2, 4], &['-', ' ', '.', ',']),
(&[2, 7], &['-', ' ', ',']),
];
(0..text.len()).flat_map(move |at| {
RENDERINGS
@@ -256,9 +257,10 @@ 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; the
/// company's own EIN printed 2-7 aside (INVARIANT 3b).
/// by hyphens or commas) or an EIN (2-7 joined by a hyphen or a comma)
/// is, standing alone: no 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<u32>) -> bool {
let digit = |i: usize| text.get(i).is_some_and(|c| c.is_numeric());
let joins_on = |join: usize, digit_at: usize| {
@@ -268,7 +270,7 @@ fn holds_printed_tin(text: &[char], own_ein: Option<u32>) -> 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(|(value, end)| {
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))
})
@@ -575,6 +577,39 @@ mod tests {
}
}
/// INVARIANT 3b (P7): a TIN written with commas between its groups
/// is refused like one written with hyphens, in every encoding, with
/// the vault locked or open.
#[test]
fn a_file_with_a_comma_separated_tin_is_refused() {
for tin in [
"187,05,1907", // 3-2-4
"12,3456789", // 2-7
] {
let text = format!("payee,tin\nDana,{tin}\n");
for (bytes, mime, name) in encodings(&text) {
assert_eq!(
check(&bytes, mime, name),
Err(TIN_PRINTED.into()),
"{tin:?} as {name}, vault locked"
);
assert!(
check_held(&bytes, mime, name).is_err(),
"{tin:?} as {name}, vault open"
);
}
}
// A comma-grouped amount is no TIN (1-3-3-2): it goes in.
assert!(
check(
b"date,amount\n2026-10-01,\"1,234,567.89\"\n",
"text/csv",
"x.csv"
)
.is_ok()
);
}
/// INVARIANT 3b: an SSN or EIN shape inside a longer number (a digit,
/// or a hyphen or dot and a digit, beside it) is no TIN.
#[test]
+42
View File
@@ -329,6 +329,48 @@ mod tests {
assert!(!ledger_core::parties::has_full_tin(&client.version));
}
/// INVARIANT 2 (P7): a TIN written with commas between its groups
/// never reaches a record or a digest, as one written with hyphens
/// never did: a client's texts are masked, and two calls that differ
/// only in such a TIN give one digest.
#[test]
fn access_log_masks_a_comma_separated_tin() {
let dir = tempfile::tempdir().unwrap();
let log = AccessLog::open(dir.path()).unwrap();
let mut a = access(1);
a.client = Some(Client {
name: "claude-code".into(),
version: "187,05,1907".into(),
});
a.request_id = Some("ssn 187,05,1907".into());
a.principal.label = "agent 187,05,1907".into();
a.subject = "books.187,05,1907".into();
a.args_digest = Some(digest(
&serde_json::json!({"note": "SSN 187,05,1907 on file"}),
));
log.record(&a).unwrap();
let text = std::fs::read_to_string(dir.path().join(ACCESS_FILE)).unwrap();
assert!(!text.contains("187,05,1907"), "{text}");
let (back, _) = read(dir.path()).unwrap();
let client = back[0].access.client.as_ref().unwrap();
assert_eq!(client.version, "•••,••,••••");
assert_eq!(back[0].access.subject, "books.•••,••,••••");
assert_eq!(
back[0].access.request_id.as_deref(),
Some("ssn •••,••,••••")
);
// The same call with another comma-separated TIN: the same digest,
// so no guess of the TIN can be checked against it.
assert_eq!(
digest(&serde_json::json!({"note": "SSN 187,05,1907 on file"})),
digest(&serde_json::json!({"note": "SSN 604,71,7433 on file"}))
);
assert_ne!(
digest(&serde_json::json!({"note": "SSN 187,05,1907 on file"})),
digest(&serde_json::json!({"note": "SSN 187,05,1907 is gone"}))
);
}
#[test]
fn a_digest_gives_no_tin_away() {
// The same call with another TIN: the same digest, so no guess