MCP: books.link_attachment, unlink_attachment, attachments; link_to on attach_receipt and file_document; record_statement, statements; books.reconcile previews and, confirmed against a recorded statement, records the reconciliation; void_reconciliation, reconciliations; import_bank_csv links its source file; digest lists unreconciled accounts. TIN vault: books.set_w9 takes the TIN and the form into the encrypted vault (last four in the books); books.vendor_1099 include_tins and save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak test scans every tool output, the journal, the database, and the blobs. [books] parties = off | suggest | require (default suggest): every preview names the entry's party or suggests likely ones; require refuses to post until it names one or is marked as having none (entry.no_party). Viewer: documents on every subject page; statements and reconciliation history per account; reconciliation pages; reconcile and no-counterparty attention items; party money by period (paid out, received in, net, what for, by month); Needs attention gets its own full-width section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
15 KiB
Parties and attachments
Design for two features built together (Stephen, 2026-10-15): a directory of the parties the company deals with, and attachments that link to any subject. Status: design; built after asset tax basis.
Why together
Both answer "what is this connected to?" A W-9 note, a contract, a bank statement, a photo of a laptop: each is a file about a party, an account, a reconciliation, an asset, a period. One subject model serves both.
Parties
A party is anyone the company deals with. Today's vendors and customers become parties; employees stop being name strings on payroll lines; the Apple, Google, Anthropic, IRS, and Utah that live only in entry descriptions get records.
Model (ledger-core, parties.rs)
Party {
id: PartyId, // its own id space (vendor and customer records keep theirs)
name: String, // legal or trade name, as on documents
display_name: Option<String>,
roles: BTreeSet<Role>, // set by hand: contractor, employee, member, payout_platform,
// tax_agency, financial_institution, landlord (vendor and
// customer follow the records; see all_roles)
website: Option<String>,
address: Option<String>, // business address; no personal PII beyond it
notes: Option<String>,
archived: bool, // no longer dealt with; history stays
w9: Option<W9Facts>, // contractor/landlord: the TIN itself is in the vault
w4: Option<W4Facts>, // employee: never the SSN
vendor: Option<VendorId>, // its vendor record (bills, payments, 1099 form)
customer: Option<CustomerId>,// its customer record (invoices)
}
W9Facts { received: NaiveDate, entity_type: String, exempt_payee_code: Option<String>,
fatca_code: Option<String>, tin_last4: String, kept_at: String,
vault_record: Option<String> /* SHA-256 of the vault record */ }
W4Facts { received: NaiveDate, state: String, kept_at: String }
The 1099 form stays on the vendor record (vendor.1099_set); a party
reaches it through vendor.
Rules (to vendor as B-26 with sources):
- A full TIN or SSN is never in the journal:
tin_last4is exactly four digits, and any nine-digit run in any party field is refused (the engine checks, not the agent). Full TINs live only in the vault (below). Source: IRS truncated-TIN rules (Treas. Reg. §301.6109-4, to vendor) for what may appear; DECISION (Stephen, 2026-10-15) for the vault. - Roles are a set; each role's facts are only allowed with the role (W-9 facts need contractor or landlord; W-4 facts need employee).
- Names are unique ignoring case and surrounding space among non-archived parties.
- A 1099 needs a contractor or landlord role; 1099 totals come from the entries tagged with the party, as today's vendor 1099 report does.
Links
- Entries:
entry.party_set {entry_id, party_id|null}generalizesentry.vendor_set; an entry has one counterparty. - Invoices and bills already name a customer or vendor id: those are party ids after migration.
- Payroll lines name an employee party id (old runs keep their name strings; new runs must name a party with the employee role).
Journal events
party.added {id, name, roles}, party.updated {id, change} (one
PartyField per event, {field, value}: name, display_name, website,
address, notes, archived), party.role_set {id, role, on},
party.w9_set {id, facts|null}, party.w4_set {id, facts|null},
entry.party_set {entry_id, party_id|null}. Replayed and projected like
the rest; every event gets viewer journal text (the GUI test enforces it).
Migration
Old journals replay unchanged. vendor.added and customer.added keep
their meaning and, on replay as live, link their record to a party: the
live party of that name (ignoring case and surrounding space) without
such a record yet, else a new party. So a vendor and a customer of the
same name share one party; a second vendor of a taken name gets a new
party named "Name (2)" (names stay unique). Vendor and customer names
obey the party text rules (no full TIN). entry.vendor_set also sets the
entry's party, and entry.party_set to a party with a vendor record sets
the vendor tag: one counterparty.
Tools
books.add_party, books.update_party, books.party_roles,
books.set_w9, books.set_w4, books.parties (list, filter by role),
books.party (one: facts, roles, totals this year, documents, links),
books.set_entry_party. (Merging two parties, books.merge_parties, is
not built yet: records of one name already share a party.) books.add_vendor and
books.add_customer stay as shortcuts.
Viewer
A Parties page (by role, searchable) and a page per party: facts, roles, year-to-date paid / owed / invoiced / 1099 amount, every entry, invoice, bill, payroll run, documents, and history. Members link to their capital accounts. Global search finds parties. Needs-attention: a contractor paid without W-9 facts; a 1099 party paid over the threshold.
Money with a party (Stephen, 2026-10-15: "how much have I spent on Apple
versus how much did I get back"): paid out, received in, and net, for a
chosen period (this year, last year, any year, all time), from
ledger_core::parties::activity; what it was for (the non-money lines by
account); a monthly out-vs-in chart; the 1099 amount for the calendar
year. The directory shows the same columns, sortable.
The TIN vault
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099 worksheet it hands the CPA is complete, but never in the journal.
- Where:
vault/in the books dir, listed in.gitignore. Never in the hash-chained journal, the SQLite projection, git, an export other than the 1099 worksheet, or the viewer. Nothing outside the vault holds more than the last four digits. - What: per party, the full TIN, its type (SSN, EIN, ITIN), and the W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random nonce per record; the file format carries a version and the party id as associated data, so a record can't be moved to another party).
- Key: a random 256-bit key in a key file outside the books dir
(default
~/.config/wanderbooks/vault.key, mode 0600;[vault] key_filein wanderbooks.toml overrides it), created on first use. No OS keyring or other system service (Stephen, 2026-10-15: no external dependencies), so the vault works anywhere wanderbooks runs. Recovery is a copy of that file in the human's password manager; without it the vault can't be read (the books still open and verify). - Journal:
party.w9_setrecords that a W-9 was received, its date, entity type, codes, and last four, plus a hash of the vault record, so the books prove a W-9 existed without holding it. - Purge:
books.purge_tin {party, reason}deletes the vault record (after the retention period, or on request) and journals that it was purged; the journal's facts stay. - Use: only
books.vendor_1099withinclude_tins: true(and the CPA package, when asked) decrypts them. The MCP server never returns a TIN in any other tool, log, or error. Tests check that no other output contains a nine-digit run. - Books without a vault (another machine, a restored backup) still open and verify; the 1099 worksheet then says which TINs are missing.
- Tools (built;
docs/mcp-interface.md"Tools — the TIN vault"):books.set_w9takestin+tin_typeandw9_file_path(the form goes to the vault, never the attachment store) and journals the last four withvault_record; facts that disagree with the vault's TIN are refused, and clearing facts with a vault record needs a purge first.books.get_w9 {party, save_to}writes the form to a new file outside the books dir and returns only the path.books.purge_tin {party, reason, confirmed?}previews, then deletes the records (no key needed) and journals the purge asparty.w9_setwithvault_recordcleared andkept_atsaying when and why.books.vendor_1099 {include_tins: true}is the one output with full TINs (with a warning;save_csv_towrites the CSV outside the books dir). A key file inside the books dir is refused.tests/spec_vault.rsscans every tool response, the server's log, and every file in the books dir outsidevault/for the TINs in any formatting.
Attachments
Subjects
An attachment links to any number of subjects:
Subject = Entry(id) | Account(id) | Reconciliation(id) | Asset(id) | Party(id)
| Invoice(id) | Bill(id) | Period(id) | PayrollRun(id) | Schedule(id)
| Company
attachment.linked {attachment_id, subject} and attachment.unlinked {attachment_id, subject, reason} replace the single entry_id link (old
receipt.attached with an entry and receipt.linked replay as links to
that entry). A receipt is an attachment linked to an entry; the receipt
policy counts links to the entry, as now. A file stored once can be
linked to many subjects (no more refiling).
Tools (built; docs/mcp-interface.md "Tools — attachments"):
books.link_attachment, books.unlink_attachment (refused for a posted
entry's last receipt under the receipt policy, and for a standing
reconciliation's statement), books.attachments {subject}, link_to on
books.attach_receipt and books.file_document (all or nothing), and
links on every listed attachment. books.file_document takes the kinds
below as its category (receipt is books.attach_receipt's).
Kinds
receipt, statement, contract, formation, legal, tax,
accounting, photo, warranty, import_source, other. A statement
carries {account_id, period_start, period_end, opening_balance, closing_balance}, checked against the account's currency.
Reconciliations
books.reconcile with confirmed: true records a reconciliation:
reconciliation.completed {id, account_id, statement_date, statement_balance, cleared: [(entry_id, line)], attachment_id}. It
refuses unless the difference is exactly zero and a statement for that
account and date is attached (the receipt rule's twin). A reconciliation
is never edited; a later one supersedes it; reconciliation.voided {id, reason} exists for mistakes. Viewer: per-account reconciliation
history with its statement; Needs-attention: an account with activity not
reconciled through last month's end.
Tools (built; docs/mcp-interface.md "Tools — bank reconciliation"):
books.record_statement, books.statements, books.reconcile (preview
by default; statement_attachment + confirmed: true records it),
books.reconciliations, books.void_reconciliation; books.show reconciliation; books.digest unreconciled_accounts (the [books] cash_accounts and liabilities with statements, not reconciled through
last month's end). The statement of a reconciliation not voided can't be
unlinked from it.
Import sources
Built: file the export (books.file_document category import_source)
and pass source_attachment_id to books.import_bank_csv; it is linked
to the bank account imported into. Not to the drafts it creates: a file
linked to an entry counts as its receipt, and a bank export is not one
(DECISION, 2026-10-15, building the MCP tools). Recording the row each
draft came from is not built yet.
Projection
SQLite: attachment_links(attachment_id, subject_kind, subject_id),
statements(attachment_id, account_id, period_start, period_end, opening, closing), reconciliations(...), reconciliation_lines(...); the old
attachments.entry_id column is kept and filled for entry links during
the migration so older readers still work.
Testing
Spec blocks on every command; spec-blind suites per crate; the refusal-leaves-books-unchanged property; replay and migration from v10 books; the GUI's every-event-has-text test; mutation on the new modules.
After this: the completeness engine
Decided (Stephen, 2026-10-15), built after parties and attachments: one
set of completeness rules in the core (asset: tag, serial, tax basis,
acquisition link, label; entry: receipt, 1099 vendor tag; account:
reconciled through last month; month: depreciation, amortization, close;
party: W-9 before paying a contractor; settings: no problems; and the
rules this design adds), next_steps on every tool response naming the
exact call that completes each, books.todo as the one punch list behind
the digest, the viewer's Needs attention, and the session-start hook, and
enforcement where a rule is a rule (tax basis at registration). The
standard: do it now, completely.
Counterparties
Decided (Stephen, 2026-10-15): the books should be easy to make complete
without forcing one style. [books] parties = "off" | "suggest" | "require", default "suggest":
off: never mention counterparties.suggest: a draft or preview without a party names the likely one (existing parties matched against the description, the receipt's text, the vendor), or offers to create one;next_stepscarries the exact call. Nothing is refused.require: posting is refused until the entry names a party or is marked as having none (transfers, depreciation, amortization, closes are exempt by kind).
Also: memorized matches (journaled rules like description contains "OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts, reviewable and removable); a backfill punch list grouping untagged entries by likely party for one confirmation per group; 1099 nudges (a party given contractor or landlord, or a payment that looks like services to an individual, asks for the role and the W-9).
After that: updates (check and seamless upgrade)
Decided (Stephen, 2026-10-15), after the completeness engine. New versions fix accounting, tax, and legal rules, so books running an old version may be wrong; staying current is part of keeping correct books.
- Check: the MCP server checks for a newer release when it starts and
once a day while running (and the viewer and
just session-startask it). Releases carry notes and a severity: a fix to an accounting, tax, or legal rule (a 1099 threshold, a depreciation rule, a refusal that was wrong) is marked so, and says which books it may affect.[updates] check = "daily" | "never"in wanderbooks.toml; the check contacts only the release host and sends nothing about the books. - Surface: the update is a completeness item (
books.todo), inbooks.digest, a banner in the viewer, and the session-start hook; the MCP instructions tell the agent to raise it with the human at once, with the release notes, and to treat a rules fix as urgent. - Upgrade: one step (
wanderbooks update, or the agent offers it): download the release, verify its signature against a key pinned in the installed build, then rehearse: open the books read-only with the new build and run the full verify (replay, projection, migrations) before switching. Swap binaries atomically, keep the previous build for rollback, restart the viewer, and have the MCP server exit cleanly so the client reconnects to the new one. Never downgrade; never touch the journal except through the new build's ordinary migrations. - After: the agent re-runs whatever the fix affects (for example,
books.vendor_1099for the year, or the register) and tells the human what changed in their books, if anything.