Files
wanderbooks/docs/parties-and-attachments.md
T
swaitsandClaude Opus 5.5 6e6f9d65b2
check / check (push) Canceled after 0s
Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md)
- store: asset.tax_basis_set journaled, replayed, projected (schema v10)
- mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset
- import: the register's XLSX and PDF gain the federal tax basis table with citations
- viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one
- just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp)
- docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md)
- tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:30:09 -06:00

11 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,                 // vendors and customers keep their ids (see Migration)
  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
  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
}
W9Facts { received: NaiveDate, entity_type: String, exempt_payee_code: Option<String>,
          fatca_code: Option<String>, tin_last4: String, kept_at: String }
W4Facts { received: NaiveDate, state: String, kept_at: String }

Rules (to vendor as B-26 with sources):

  • A full TIN or SSN is never in the journal: tin_last4 is 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.
  • Entries: entry.party_set {entry_id, party_id|null} generalizes entry.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, field, value} (one field per event: name, display_name, website, address, notes, archived), party.role_added {id, role}, party.role_removed {id, role}, 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).

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.

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.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.

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 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.
  • 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.
  • 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_1099 with include_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.

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).

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.

Import sources

books.import_bank_csv files the CSV (kind import_source), links it to every entry it created, and records the row each came from, so every imported line traces to its source row.

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.

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-start ask 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), in books.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_1099 for the year, or the register) and tells the human what changed in their books, if anything.