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

+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