Parties, attachment links, statements, reconciliations (store), TIN vault crate
check / check (push) Canceled after 0s

Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 14:43:40 -06:00
1 parent 0a62353767
commit e265ed4339
54 files changed
+10961 -178

No files matched your search

+125 -14
View File
@@ -111,8 +111,10 @@ below.
- `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder,
balance }] }`. Balances included: one call answers "what do we have."
- `books.draft_entry { date: "2026-10-10", description: "..." }`
→ `{ entry_id, status: "draft" }`.
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
party? }` → `{ entry_id, status: "draft" }`. `party` tags the
counterparty (see "Tools — parties"); given with `vendor`, both must name
the same vendor record, else nothing is drafted.
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
class, bad amount) adds nothing.
@@ -123,7 +125,8 @@ below.
the receipt policy is enforced and `status` is `"posted"`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines.
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
its `vendor`, `party_id`, and `party` (the party's label) tags.
- `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }`
→ entries with lines, newest first. `limit` caps at 100.
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
@@ -222,12 +225,13 @@ through the policy.
## Tools — the viewer
- `books.show { entry_id | account | report | attachment_id | search |
page, start?, end? }` → `{ shown, note }`. Asks the human's viewer
(`ledger-gui`) to open an entry, an account register or a report (for
start/end dates if given), a stored file, a transactions search, or a
page. Exactly one target; entries, accounts, and attachments are checked
to exist. Never touches the books: the request is a small JSON file in
- `books.show { entry_id | account | report | attachment_id | asset |
party | search | page, start?, end? }` → `{ shown, note }`. Asks the
human's viewer (`ledger-gui`) to open an entry, an account register or a
report (for start/end dates if given), a stored file, an asset, a party
(by id or name, resolved as in "Tools — parties"), a transactions search,
or a page (`parties` among them). Exactly one target; entries, accounts,
attachments, assets, and parties are checked to exist. Never touches the books: the request is a small JSON file in
the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-<hash
of the data dir>.json`), written atomically, watched by the viewer. A
viewer started within two minutes of a request opens it; older requests
@@ -269,19 +273,116 @@ overridable, always for the CPA to confirm. TINs are never stored, only
whether a W-9 is on file.
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
tin_on_file }] }`.
tin_on_file, party_id }] }`.
- `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none",
tin_on_file? }` → the vendor. `vendor` is an id or a name.
- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as
`books.get_entry`, which now shows `vendor`). Omitting `vendor` clears
the tag. Metadata only: posted entries may be tagged. `books.draft_entry`
takes `vendor` too; bills and their payments are tagged automatically.
`books.set_entry_party` tags the vendor too when the party has a vendor
record, so tagging by party counts for 1099s the same way.
- `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors:
[{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }],
missing_w9, notes }`. For a year whose threshold wanderbooks doesn't know
(after 2026 until the indexed amount is added) it refuses with what to
do; pass `threshold` explicitly once the figure is confirmed.
## Tools — parties
The directory of everyone the company deals with (`ledger_core::parties`;
design in `docs/parties-and-attachments.md`): vendors, contractors,
landlords, customers, payout platforms, tax agencies, banks and card
issuers, members, employees. Vendor and customer *records* (bills,
invoices, aging, 1099 marks) stay where they are; each links to exactly one
party. `books.add_vendor` and `books.add_customer` link their record to the
party of the same name (ignoring case and surrounding space, not archived)
that has no such record yet, or make a new party.
**References.** A `party` argument is an id or a name: the name ignoring
case and surrounding space, among parties not archived (an archived party
goes by id). A number that is one party's id and another party's name is
refused, never guessed.
**Roles:** `vendor`, `contractor`, `customer`, `employee`, `member`,
`payout_platform`, `tax_agency`, `financial_institution`, `landlord`.
Vendor and customer follow the records and are never set by hand.
**Rules the engine enforces** (each refusal changes nothing, the journal
byte for byte): no party field holds a full TIN (a run of nine digits,
single spaces or hyphens allowed between them); a W-9's TIN is its last
four digits only; W-9 facts need a 1099 role (contractor or landlord), W-4
facts the employee role; names are unique among parties not archived;
texts are at most 500 characters, no control characters.
- `books.add_party { name, roles? }` → the party (below). Journaled as
`party.added`.
- `books.update_party { party, name?, display_name?, website?, address?,
notes?, archived? }` → the party. Give at least one field; `""` clears
`display_name`, `website`, `address`, or `notes`. Every field is checked
(in order, on a rehearsal) before any is written; each is journaled as
its own `party.updated`, in the order name, display_name, website,
address, notes, archived. Un-archiving is refused while another party
has the name.
- `books.set_party_roles { party, add?, remove? }` → the party. Adds apply
before removes (so a contractor's W-9 survives a swap to landlord); roles
it already has, or lacks, are skipped; each change is journaled as
`party.role_set`. Removing the last 1099 role with W-9 facts on file, or
employee with W-4 facts, is refused: clear the facts first.
- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?,
fatca_code?, tin_last4, kept_at }` or `{ party, clear: true }` → the
party. Journaled as `party.w9_set`. A party with a vendor record also
gets the record's `tin_on_file` set to match (journaled as
`vendor.1099_set`), so `books.vendor_1099`'s `missing_w9` follows.
- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear:
true }` → the party. Journaled as `party.w4_set`.
- `books.set_entry_party { entry_id, party? }` → the entry (as
`books.get_entry`). `party` null or omitted clears the tag. A party with
a vendor record also tags the entry's vendor; one without, or clearing,
clears the vendor tag. Metadata only; posted entries may be tagged.
Journaled as `entry.party_set`.
- `books.parties { role?, include_archived?, year? }` → `{ year, parties:
[{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file,
tin_last4?, w4_on_file, archived, paid, received }] }`, by id.
- `books.party { party, year? }` → `{ party, year, totals, entries,
invoices, bills, payroll }`: the facts; the calendar year's totals
(default this year); every entry it is on (drafts included, newest
first); its invoices and bills (`{ id, date, due_date, total, paid,
balance, status, entry_id }`); and the payroll lines naming it
(`{ run_id, date, entry_id, gross_pay, net_pay }`).
The party (every mutating party tool returns it): `{ party_id, name,
label, display_name?, roles, website?, address?, notes?, archived,
vendor_id?, customer_id?, w9?: { received_on, entity_type,
exempt_payee_code?, fatca_code?, tin_last4, kept_at }, w4?: { received_on,
state, kept_at } }`. `label` is the display name, else the name; `roles`
include vendor and customer when it has those records.
**Totals** (`books.party` `totals`; `paid` and `received` in
`books.parties`), from the journal:
- *The party's entries:* tagged with the party; tagged with its vendor
record (bills and their payments); its customer record's invoices and
the payments received on them; and reversals of any of these. Only
posted entries dated in the year count.
- *Money accounts:* the `[books] cash_accounts` (every asset account but
receivables when none are set), and every liability account except the
payables and payroll withholding roles (`books.roles`). A liability
counts as how the company paid or was paid: a card, a member paying for
it, a loan.
- `paid` / `received`: each entry's money lines, credits minus debits; a
positive net was paid to the party, a negative one received from it; a
reversal counts against its original's side. A bill isn't paid until its
payment is; an entry only moving money between money accounts nets to
zero.
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
`owed` / `receivable`: its open bills' and invoices' balances now (every
year).
- `amount_1099` (vendor parties only): what `books.vendor_1099` counts as
paid to its vendor record in the year (asset accounts only, so card
payments drop out); `form_1099` is the record's mark.
- `gross_pay` / `net_pay`: payroll lines naming it, in the year.
## Tools — receipts & documents
- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }`
@@ -457,6 +558,11 @@ in the server's tool descriptions, so the model reads it at use time):
wrong posting is a reversal. Never guess-post.
5. **The digest is the receipt for your work.** After a batch of bookkeeping,
offer the digest; the human reviews, you fix.
6. **Keep the party directory current.** Every counterparty gets a party;
every entry is tagged with its party; contractors and landlords get W-9
facts before they are paid, employees W-4 facts before their first
payroll, and every payroll line names its employee party. Never a full
TIN or SSN: the engine refuses nine-digit numbers.
## Out of scope for this phase
@@ -492,8 +598,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
## Tools — A/R and A/P
- `books.add_customer { name }` → `{ id }`.
- `books.add_vendor { name }` → `{ id }`.
- `books.add_customer { name }` → `{ id, party_id }`.
- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to
a party ("Tools — parties").
- `books.create_invoice { party_id, date, due_date, lines: [{ description,
amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts
Receivable / Cr `4000` Revenue.
@@ -522,13 +629,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer.
No tax tables: the caller supplies every number; the engine cuts correct
entries.
- `books.run_payroll { date, cash_account, lines: [{ employee_name,
gross_pay, federal_withholding, state_withholding, ss_employee,
- `books.run_payroll { date, cash_account, lines: [{ employee?,
employee_name?, gross_pay, federal_withholding, state_withholding, ss_employee,
medicare_employee, ss_employer, medicare_employer, other_deductions }] }`
→ `{ run_id, entry_id, total_gross, total_net }`. One balanced entry:
Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense
(employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash
(net). Auto-creates the 61xx/21xx accounts. Respects closed periods.
`employee` names the employee's party (id or name; it must have the
employee role, else nothing is written); new runs should name it, and
`employee_name` then defaults to the party's name. A line needs one or
the other.
- `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`.
Clears every payroll payable to cash in one entry.
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
+35 -24
View File
@@ -21,25 +21,30 @@ descriptions get records.
```text
Party {
id: PartyId, // vendors and customers keep their ids (see Migration)
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>, // vendor, contractor, customer, employee, member,
// payout_platform, tax_agency, financial_institution, landlord
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
w9: Option<W9Facts>, // contractor/landlord: the TIN itself is in the vault
w4: Option<W4Facts>, // employee: never the SSN
form_1099: Option<Form1099>, // carried over from Vendor
member: Option<String>, // a member's name in wanderbooks.toml, when role member
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 }
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_last4` is exactly four
@@ -65,29 +70,32 @@ Rules (to vendor as B-26 with sources):
### Journal events
`party.added {id, name, roles}`, `party.updated {id, field, value}` (one
field per event: name, display_name, website, address, notes, archived),
`party.role_added {id, role}`, `party.role_removed {id, role}`,
`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`. Replayed and projected like the rest; every event gets
viewer journal text (the GUI test enforces it).
`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 create parties with the vendor or customer role; the
party id space starts above the highest vendor and customer ids, and a
vendor and a customer with the same name stay two parties until the
human merges them (`books.merge_parties`, journaled, re-pointing links).
`entry.vendor_set` replays as `entry.party_set`. `vendor.1099_set` sets
the party's `form_1099` and the contractor role.
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.merge_parties`, `books.set_entry_party`. `books.add_vendor` and
`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
@@ -111,10 +119,13 @@ worksheet it hands the CPA is complete, but never in the journal.
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 the OS keyring (Secret Service on Linux,
Keychain on macOS), created on first use; never on disk in the books dir.
A recovery copy is the human's job: `just vault-export` writes the key
and records as one passphrase-encrypted file for a password manager.
- **Key:** a random 256-bit key in a key file outside the books dir
(default `~/.config/wanderbooks/vault.key`, mode 0600; `[vault] key_file`
in 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_set` records 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.
+32
View File
@@ -378,6 +378,15 @@ years after 2026, indexed to inflation and rounded to the nearest $100
(§6041(h)). Card payments are reported on Form 1099-K, not 1099-NEC/MISC.
Attorneys' gross proceeds stay at $600 (not modeled).
What counts as paid: payments made in the calendar year (§6041(a)), so a
prepayment counts when made. The engine counts credits minus debits to the
payment sources on a vendor's tagged entries: `[books] cash_accounts` and
`financing_accounts` (a member or lender paying on the company's behalf;
the company is still the payer). A card charge leaves neither (1099-K); a
bill is not paid until it is (`form1099::vendor_1099_report_with`,
checked by `payments_come_from_cash_and_financing_only`). A W-9 is on
file by the vendor's flag or its party's W-9 facts.
**Enforced:** `form1099::default_threshold`, a dated rule
(`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from
2026-11-01). For a later year the engine refuses with `RuleNotKnown`
@@ -494,3 +503,26 @@ non-negative, in cents), `Asset::tax_basis`, `Asset::built_in_gain`,
`missing_tax_basis`). **Checked:** `ledger-core` `tests/spec_tax_basis.rs`;
`ledger-store` `tests/asset_tags.rs` (`asset.tax_basis_set` journaling,
replay, v9 migration, corrupt payloads); `ledger-mcp` `tests/spec_assets.rs`.
## B-26 Parties: one directory, no full TIN in the books
**Status:** AUTHORITY (truncated TINs: `sources/irs-truncated-tin.md`);
DECISIONS (Stephen, 2026-10-15)
Everyone the company deals with is a party, with roles (vendor,
contractor, customer, employee, member, payout platform, tax agency,
financial institution, landlord). Vendor and customer records link to
their party; entries are tagged with theirs. Names are unique among parties
not archived, ignoring case and surrounding space. A W-9 is recorded as its
facts and the TIN's last four digits (the part a truncated TIN shows,
Treas. Reg. §301.6109-4(a)); no party field may hold a nine-digit number,
and the full TIN lives only in the encrypted vault, outside the journal
(DECISION). W-9 facts need a 1099 role (contractor, landlord); W-4 facts
need the employee role; payroll lines that name their employee name a
party with that role.
**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check,
W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`,
`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`.
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
`tests/parties.rs`; `ledger-mcp` `tests/spec_parties.rs`.
+16
View File
@@ -0,0 +1,16 @@
# TIN formats
Governs `ledger_vault::Tin::new`: a TIN is nine digits; an ITIN begins with 9 and its fourth and fifth digits fall in the ranges below; an SSN or EIN doesn't begin with 9 (a nine-digit number beginning with 9 is in the ITIN series).
## IRS Publication 1915 (Rev. 11-2024), "Understanding Your IRS Individual Taxpayer Identification Number", General Information
Source: <https://www.irs.gov/pub/irs-pdf/p1915.pdf>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`p1915.pdf`): `63a3cf3e79c2e73ed15738d5a0875ffb7496c2309e8c3e085836ade16b2e0920`
> An ITIN is a tax processing number, issued by the Internal Revenue Service, for certain resident and nonresident aliens, their spouses, and their dependents. It is a nine-digit number beginning with the number “9”, has a range of numbers from "50" to "65", "70" to "88", “90” to “92” and “94” to “99” for the fourth and fifth digits and is formatted like a SSN (i.e. 9XX-7X-XXXX).
## Instructions for Form W-7 (Rev. December 2024), What's an ITIN
Source: <https://www.irs.gov/instructions/iw7>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`irs-iw7.html`): `1d674a26a63c34b59a2cb6e34d050a95f744c53ae35ea50172a137298b37a000`
> An ITIN is a nine-digit number issued by the IRS to individuals who are required for federal tax purposes to have a U.S. taxpayer identification number but who don’t have and aren’t eligible to get a social security number (SSN).
+11
View File
@@ -0,0 +1,11 @@
# Truncated taxpayer identification numbers
Governs what of a TIN the party directory may hold (crates/ledger-core/src/parties.rs): the last four digits, the same part a truncated TIN shows. The full number lives only in the encrypted vault (docs/parties-and-attachments.md), never in the journal.
## Treas. Reg. §301.6109-4(a) and (b)(1)
Source: <https://www.ecfr.gov/current/title-26/part-301/section-301.6109-4>
Retrieved: 2026-10-15
SHA-256 of the retrieved file (`ecfr301-6109-4.html`): `45702c366f7f185e41452a32acf685bb5371e00b91bfb6214174ffd153eba949`
> (a) In general — Definition. An IRS truncated taxpayer identification number (TTIN) is an individual's social security number (SSN), IRS individual taxpayer identification number (ITIN), IRS adoption taxpayer identification number (ATIN), or IRS employer identification number (EIN) in which the first five digits of the nine-digit number are replaced with Xs or asterisks. The TTIN takes the same format of the identifying number it replaces, for example XXX-XX-1234 when replacing an SSN, or XX-XXX1234 when replacing an EIN.
> (b) Use of a TTIN. (1) In general. Except as provided in paragraph (b)(2) of this section, a TTIN may be used to identify any person on any statement or other document that the internal revenue laws require to be furnished to another person. Use of a TTIN is permissive and not mandatory.