Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
check / check (push) Canceled after 0s

Entries may name several parties, each with an exact amount, a percent,
or a share derived from the members' capital lines or [[company.members]]
shares (entry.parties_set); shares always sum exactly. The 1099
worksheet counts a vendor's share. A member's party page is their
partner capital statement, from the same ledger_core::k1 source as the
CPA package.

books.set_tin stores any party's TIN (members' SSNs for K-1s) in the
vault (party.tin_set, last four in the books). The CPA package carries a
K-1 member worksheet (k1-members.csv, with a members' total that ties to
Schedule L) and the 1099 worksheet with full TINs, saved outside the
books only. files_jointly_with notes spouses' two K-1s land on one 1040.

Adversarial review fixes: reversed contributions no longer inflate K-1
contributions and distributions; a K-1 row never takes another party's
TIN; TIN detection covers dot/slash/dash separators; refusals redact
TINs; vendor tagging can't silently replace shared parties; reversals
follow re-apportioning.

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 18:20:18 -06:00
1 parent df3fc3f4e4
commit 4c74169d18
32 files changed
+7232 -375

No files matched your search

+133 -26
View File
@@ -33,7 +33,10 @@ to 100%) drives the statement of members' equity, `[books]
accounting_basis` ("accrual" or "cash", default accrual) labels report
footers, and `[books] receipt_threshold` sets the receipt policy unless
`config.json` sets `receipt_policy`. Members may name a
`distribution_account`; single-owner businesses may set `[books] close_into`
`distribution_account`, and `files_jointly_with` (spouses filing one joint
return; the CPA package's README notes their two K-1s go on one Form 1040,
and k1-members.csv ends with a members' total, not a K-1, that ties to
Schedule L); single-owner businesses may set `[books] close_into`
(the equity account a close puts net income in; default Retained Earnings)
and `close_sweep` (draw, distribution, or dividend accounts closed into it).
The cash flow statement's account roles are `[books] cash_accounts` (what
@@ -112,21 +115,31 @@ 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: "...", 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.
party?, parties?, no_party_reason? }` → `{ 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. `parties` names several, each with a share (as
`books.set_entry_party`), in place of `party` and `vendor`.
- `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.
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
description, total, lines (with resulting balances), receipts,
receipt_required, receipt_missing, split_detected }`. Without
`confirmed: true`, `status` is `"preview"` and nothing changes; with it,
the receipt policy is enforced and `status` is `"posted"`.
receipt_required, receipt_missing, split_detected, party_policy,
party_id?, party?, parties, parties_error?, no_party_reason?,
party_missing, party_suggestions }`. Without `confirmed: true`, `status`
is `"preview"` and nothing changes; with it, the receipt policy is
enforced and `status` is `"posted"`. `parties` lists every party the
entry names with its share resolved to the cent (`{ party_id, party,
share: "amount" | "percent" | "derived", amount?, percent?, resolved }`);
when the shares don't add up to what the entry moved, `parties_error`
says why and confirming fails with code `party_shares`.
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
preview/confirm pattern as post.
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
its `vendor`, `party_id`, and `party` (the party's label) tags.
its `vendor`, `party_id`, and `party` (the party's label, when it names
exactly one) tags, and `parties` / `parties_error` as in the
`post_entry` preview.
- `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`
@@ -290,14 +303,22 @@ is on file and the TIN's last four; full TINs live only in the TIN vault
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.
record, so tagging by party counts for 1099s the same way. On an entry
naming several parties the parties are `books.set_entry_party`'s:
tagging the vendor of a party already named keeps every share as it
is; tagging a vendor whose party isn't named, or clearing the tag while
its party is named, is refused (drop or add the party with `parties`
instead). An entry and its reversal always carry the same tags.
- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` →
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file,
paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }],
missing_w9, notes, warning?, saved_csv? }`. 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. `tin_last4` comes from the party's W-9 facts. With
is confirmed. On an entry naming several parties, `paid` counts only
the vendor's resolved share of the payment (rules.md B-19); an entry
whose shares don't resolve counts in full and `notes` names it.
`tin_last4` comes from the party's W-9 facts. With
`include_tins: true` (default false) each line carries `tin`, the full
TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an
EIN), and `tin_type`, read from the vault, or `tin_note` saying why not
@@ -359,11 +380,44 @@ texts are at most 500 characters, no control characters.
`"wanderbooks vault"`.
- `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.set_entry_party { entry_id, party?, parties?, no_party_reason? }`
→ 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. Tagging an entry that was reversed, or a
reversal, tags the other too (the whole reversal chain), so each
party's part of the pair still nets to zero. Journaled as
`entry.party_set`.
`no_party_reason` (with no party) marks an entry as having no
counterparty (`entry.no_party`).
- **Several parties:** `parties: [{ party, amount? | percent? }]`, in
place of `party`, names every counterparty with its share; `[]` clears.
Journaled as `entry.parties_set { entry_id, parties: [{ party_id, share
}] }`, `share` being `{"amount": "30.00"}`, `{"percent": "60"}`, or
`"derived"`. Each share, of what the entry moved (its `entry_flow` net;
for an entry moving no money, like property contributed, its total):
- `amount`: exactly that, positive, in cents; the amounts must sum to
what it moved.
- `percent`: that percent; the percents must sum to exactly 100. Each
part is rounded down to the cent and the leftover cents go to the
first party listed.
- neither (derived): a sole party gets all of it. Members named
together (parties with the member role named in `[[company.members]]`)
get what their own capital and distributions lines on the entry say,
when it has any (these must account for all it moved); else their
`[[company.members]]` shares, rescaled to the members named, rounded as
percents are. A derived share for a non-member among several is
refused.
Every party gives the same kind of share. Refused, journaling nothing: a
party named twice, an unknown party, mixed kinds, percents not summing
to 100, an amount finer than a cent, two parties with vendor records (a
1099 counts a payment toward one vendor; the one vendor party's record
is tagged and counts the whole payment), and, once the entry has
balanced lines, shares that don't resolve. A draft's shares are checked
against its lines again at `post_entry`. Under `[books] parties =
"require"`, any party (or the no-party mark) satisfies the check.
Reversals carry every share. There is no "joint" party: a joint member
transaction names both members and lets the shares derive.
- `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.
@@ -397,7 +451,8 @@ include vendor and customer when it has those records.
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.
zero. An entry naming several parties counts this party's resolved
share only (an entry whose shares don't resolve counts for none).
- `invoiced` / `billed`: its invoices' and bills' totals dated in the year.
`owed` / `receivable`: its open bills' and invoices' balances now (every
year).
@@ -408,9 +463,12 @@ include vendor and customer when it has those records.
## Tools — the TIN vault
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099
worksheet is complete, never in the books (`ledger-vault`; design in
`docs/parties-and-attachments.md` "The TIN vault"; B-26).
Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so what the CPA
gets is complete (every member's TIN for their K-1, every payee's for
their 1099), never in the books (`ledger-vault`; design in
`docs/parties-and-attachments.md` "The TIN vault"; B-26). DECISION
(Stephen, 2026-10-15): the vault holds everyone's TIN, members included,
and the CPA package carries them.
- **Where:** `vault/` in the books dir (the parent of the data dir, beside
`wanderbooks.toml`; git-ignored), one file per record, each encrypted
@@ -424,14 +482,31 @@ worksheet is complete, never in the books (`ledger-vault`; design in
locked: the books open and verify; the worksheet says the TINs are
missing.
- **Never out:** no tool output, error, or log holds more than a TIN's
last four, except `books.vendor_1099 { include_tins: true }`. The tool
arguments that take a TIN never echo it, even when refused.
last four, except `books.vendor_1099 { include_tins: true }`; the CPA
package carries them only in its zip, written outside the books dir.
The tool arguments that take a TIN never echo it, even when refused.
- **TIN facts:** any party (any role) may have `tin` facts:
`{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`,
journaled as `party.tin_set {id, facts|null}` and shown in every party
output as `tin: { tin_type, last4, in_vault, purged? }`. They never
disagree with the party's W-9 facts: both on file means the same last
four (the engine refuses otherwise).
- `books.set_tin { party, tin, tin_type }` → the party. Validates the TIN
as `books.set_w9` does, checks the facts on a copy of the ledger, writes
the TIN to the vault (the same key and git-ignore checks), and journals
`party.tin_set` with the record's hash. Any role. A party whose W-9
facts name another last four is refused (a payee's new TIN comes with
its W-9: `books.set_w9`); one whose W-9 facts agree gets their
`vault_record` updated too (`party.w9_set`).
- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates
the TIN against its type (nine digits, hyphens allowed; an ITIN starts
with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start
with 9), checks the facts on a copy of the ledger, then writes the vault
and journals the facts with `vault_record`, the SHA-256 of the TIN
record (of the form's record when only a form is given). The form (PDF,
record (of the form's record when only a form is given). With a TIN it
also journals the party's TIN facts (`party.tin_set`, as
`books.set_tin`); a TIN with a new last four clears the old TIN facts
first (`party.tin_set` null), so the two never disagree. The form (PDF,
PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment
store. Facts given later without a TIN keep the vault record when their
last four agree with the vault's TIN, and are refused when they don't
@@ -447,8 +522,10 @@ worksheet is complete, never in the books (`ledger-vault`; design in
journaled, note }`. Without `confirmed`, a preview that changes nothing.
Confirmed, deletes the party's vault records (needs no key) and, when the
party has W-9 facts, journals `party.w9_set` with `vault_record` cleared,
the last four kept, and `kept_at` saying when and why it was purged.
Refused when the reason is blank or there is nothing to purge.
the last four kept, and `kept_at` saying when and why it was purged;
when it has TIN facts, `party.tin_set` likewise, with `purged` saying
when and why. Refused when the reason is blank or there is nothing to
purge.
## Tools — attachments
@@ -794,8 +871,38 @@ entries.
## Tools — CPA handoff
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts? }`
→ `{ file_name, content_base64 }`. A zip with the full set, each
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
investing_accounts?, include_receipts?, include_tins?, save_to? }` →
`{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`.
**TINs** (`include_tins`, default true; DECISION, Stephen, 2026-10-15):
the K-1 worksheet and the 1099 worksheet carry every member's and
payee's full TIN from the vault, so the zip is written to `save_to` (a
new `.zip` path, or an existing folder it goes in as
`cpa-package-<as_of>.zip`; outside the books dir, `exports/` included;
mode 0600) and the output is its path, never its bytes; without
`save_to` it is refused. A locked vault or a missing TIN never fails the
package: the row says why ("TIN not in the vault", "the TIN vault is
locked", a record that disagrees with the books), the README lists it,
and `tin_gaps` names each member and each reportable payee without one.
`include_tins: false`: last fours only, returned as `content_base64`
unless `save_to` is given. **K-1 worksheet** (`k1-members.csv`, with
`[[company.members]]`): one row per member, in order: `member,
party_id, share, capital_account, tin, tin_type, tin_note` (without
TINs: `tin_last4, tin_type`), then `beginning_capital,
contributions_cash, contributions_property, net_income, distributions,
other, ending_capital` for the fiscal year to date: the statement of
members' equity's figures (`ledger_core::k1`), contributions split into
property (what a contribution debits to asset accounts other than cash,
receivables, and prepaid accounts: a prepaid schedule's account, or one
named "prepaid") and cash (the rest, including a service the members
paid ahead). Property in an entry crediting several members is shared
in proportion to their credits. An entry and its reversal both in the
year cancel: neither is a contribution or a distribution. A member's
party is the party with the member's name (not a display name) and the
member role, as the viewer's capital statement finds it; with none, the
member's TIN is a gap.
**1099 worksheet** with TINs adds `party_id, tin_last4, tin, tin_type,
tin_note`. A zip with the full set, each
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
`cash-flow` (reconciled to `cash_accounts`), `members-equity`
(multi-member LLCs), `trial-balance`; plus `general-ledger.xlsx` and
+127 -15
View File
@@ -62,7 +62,8 @@ Rules (to vendor as B-26 with sources):
### Links
- Entries: `entry.party_set {entry_id, party_id|null}` generalizes
`entry.vendor_set`; an entry has one counterparty.
`entry.vendor_set` (one counterparty); `entry.parties_set` names
several, each with a share (below).
- 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
@@ -74,9 +75,94 @@ Rules (to vendor as B-26 with sources):
`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
`party.tin_set {id, facts|null}` (TIN facts: type, last four, vault
record, when purged), `entry.party_set {entry_id, party_id|null}`,
`entry.parties_set {entry_id, parties: [{party_id, share}]}` (a share is
`{"amount": "30.00"}`, `{"percent": "60"}`, or `"derived"`; `[]`
clears). Replayed and projected like
the rest; every event gets viewer journal text (the GUI test enforces it).
### Shares: an entry with several parties
DECISION (Stephen, 2026-10-15): there is no "joint" party. A joint
transaction is with both members and shows on both members' pages, each
with their share; apportioning is explicit and exact when it isn't
derived. Spec: `ledger_core::shares`.
- An entry names a list of `PartyShare {party, share}`; `Share` is
`Amount(d)`, `Percent(p)`, or `Derived`. Tagging one party
(`set_entry_party`) is one derived share: all of it.
`Ledger::entry_party` means "the sole party"; `entry_parties` lists
them all.
- **What a share is of:** the entry's `entry_flow` net (what it moved
with its counterparties), or its total debits when the net is zero (a
member contributing property moves no money but is still theirs).
- **Amount:** exactly that, positive, in cents; the amounts must sum to
what the entry moved.
- **Percent:** more than 0, at most 100, summing to exactly 100. Each part
is rounded toward zero to the cent and the leftover cents (fewer than
one per party) go to the first party in order.
- **Derived:** a sole party gets all of it. Several parties must all be
members (`[[company.members]]` matched by name to a party with the
member role; core is handed them as `PartyMember`s). If the entry has
lines on a named member's capital or distributions account, each
member gets what their own lines say (and those must account for all
it moved); otherwise each gets their income share, rescaled to the
members named, rounded as percents are.
- **Refused** (journaling nothing): a party named twice, an unknown
party, mixed kinds of share, percents not summing to 100, an amount
finer than a cent, and two parties with vendor records. A 1099 counts
a payment toward a single vendor: the vendor tag follows the one party
with a vendor record, and the 1099 worksheet counts the whole payment
toward it, so name a vendor among others only when the whole payment
was its (otherwise record its part as its own entry). Whether shares
add up depends on the settings (cash accounts, members), so core
checks only their form; the MCP server checks they resolve once the
entry has balanced lines, and `post_entry` refuses (`party_shares`)
until they do. Replay never consults settings.
- **Money:** `parties::activity_with` counts each party's resolved share;
an entry moving no money counts nothing; an entry whose shares don't
resolve counts for no one (listed as unresolved). Reversals carry
every share, so a reversal subtracts each party's part; re-tagging
either one (parties, shares, or vendor) re-tags the other, so the pair
always nets to zero.
- **For the agent:** a joint member transaction (a reimbursement on Due
to Shareholders, a contribution or distribution from joint funds)
names both members with no shares and lets them derive; an uneven
split gives amounts.
### A member's capital statement
A member's party page (role member, named in `[[company.members]]`,
whose capital account is in the chart) is their capital account
statement over the page's period (`Books::capital_statement`). Every
figure is the CPA package's K-1 worksheet row for that member and span
(`ledger_core::k1::k1_detail`, itself the statement of changes in
members' equity split into cash and property), so the page and the
K-1 never disagree:
- capital at the start of the period (including their share of earnings
not yet closed into capital);
- add contributions of cash (deposits, and what they paid for the
company) and of property (debits to asset accounts that are neither
cash nor receivables, at the value booked: fair market value);
- add their share of the period's net income, closed or not;
- less distributions;
- any other change in their capital, when there is one;
- capital at the end of the period (always opening plus the movements,
exactly);
- their share of profit, loss, and capital; and their part of what the
company owes the members jointly, split by the members' shares: the
balance of the `[books] financing_accounts` liabilities (the
liabilities members advance), or, only when that is not set, of the
liability accounts named "due to" / "owed to" (such as 2100 Due to
Shareholders).
Cash and property are told apart by `[books] cash_accounts`. Without it
the statement reads money as the engine does (every asset account but
receivables), so every contribution reads as cash; set `cash_accounts`
to see property apart. (The Cash page keeps its own name-based guess.)
### Migration
Old journals replay unchanged. `vendor.added` and `customer.added` keep
@@ -87,7 +173,13 @@ 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.
the vendor tag: one counterparty. On an entry naming several parties the
vendor tag follows the parties, never the other way: `entry.vendor_set`
for the vendor of a party already named keeps every share; for any other
vendor, or clearing the tag while its party is named, it is refused. A
vendor record can't link to a party named beside another party with a
vendor record (refused: the entry would name two vendors); linking one
tags the entries naming the party that have no vendor tag yet.
### Tools
@@ -115,13 +207,22 @@ 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.
Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so what it
hands the CPA is complete, but never in the journal. The vault holds
everyone's, members included, and the CPA package carries them: "In the
vault, that's the WHOLE reason we built it. And it needs to be in the
damn CPA package."
- **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.
than the 1099 worksheet and the CPA package (saved outside the books
dir), or the viewer. Nothing outside the vault holds more than the last
four digits.
- **Who:** any party, whatever its roles: every member (their K-1 needs
it) and every 1099 payee. The books keep the party's TIN facts (type,
last four, the vault record's hash; `party.tin_set`), which never
disagree with its W-9 facts' last four. The viewer shows "TIN on file
••••1234 (SSN)" on the party's page, never more.
- **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
@@ -139,24 +240,35 @@ worksheet it hands the CPA is complete, but never in the journal.
- **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.
- **Use:** `books.vendor_1099` with `include_tins: true` and
`books.export_cpa_package` (its K-1 worksheet, `k1-members.csv`, and its
1099 worksheet; `include_tins` defaults to true there) decrypt them.
The package is written only to `save_to`, outside the books dir, and
the tool returns its path; it is refused without one. 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.
open and verify; the 1099 worksheet and the CPA package then say which
TINs are missing (`tin_gaps`), and the package is still built.
- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"):
`books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form
goes to the vault, never the attachment store) and journals the last
four with `vault_record`; facts that disagree with the vault's TIN are
refused, and clearing facts with a vault record needs a purge first.
With a TIN it also records the party's TIN facts. `books.set_tin
{party, tin, tin_type}` stores any party's TIN (a member's SSN, say) the
same way and journals `party.tin_set`; a party whose W-9 facts name
another last four is refused (a payee's new TIN comes with its W-9).
`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 as `party.w9_set` with `vault_record` cleared and
`kept_at` saying when and why. `books.vendor_1099 {include_tins: true}`
is the one output with full TINs (with a warning; `save_csv_to` writes
the CSV outside the books dir). A key file inside the books dir is
`kept_at` saying when and why, and `party.tin_set` with `vault_record`
cleared and `purged` saying when and why. `books.vendor_1099 {include_tins: true}`
is the one tool output with full TINs (with a warning; `save_csv_to`
writes the CSV outside the books dir); `books.export_cpa_package
{save_to}` writes them in its zip, outside the books dir, and returns
only the path. A key file inside the books dir is
refused. `tests/spec_vault.rs` scans every tool response, the server's
log, and every file in the books dir outside `vault/` for the TINs in
any formatting.
+66 -11
View File
@@ -234,6 +234,14 @@ closed period).
period at a time: a member's ending on one statement is their beginning
on the next, to the cent, and a member's period income is the
roll-forward's remainder (their share within a rounding cent).
- A correction undoes what it corrects (B-9): an entry and its reversal
both in a statement's span are neither contributions nor
distributions (`reports::cancelled_in_span`; the K-1 worksheet reads
the same). The K-1 worksheet's contributed property (Item L) is what
the member owned and handed over, with its own basis (§723): never a
prepaid service they paid for the company (cash), and shared among
members credited in one entry in proportion to their credits
(`k1` INVARIANTS 3, 4, 6).
**Enforced:** `CloseTo::Members`, `periods::allocate`,
`kernel::apportion`, `members_equity`. **Checked:** Kani
@@ -387,6 +395,38 @@ 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.
An entry naming several parties (a payment shared between a contractor
and a member; `ledger_core::shares`) counts only the vendor's share
toward its 1099: what was paid to the payee (§6041(a)), not what the
entry moved for others. The share is resolved (`shares::resolve`)
against the entry's flow, the same target the MCP server checks shares
against, then scaled to the 1099 payment: the share unchanged when the
payment equals the target (the usual case, an expense paid from cash or
by a member), else payment × share ÷ target, rounded to the nearest cent,
half away from zero (so a part charged to a card scales out, 1099-K). A
percent split's leftover cent goes to the party named first (shares
INVARIANT 8). A reversal carries the shares and subtracts the same part,
and re-apportioning either one re-apportions the other
(`Ledger::set_entry_parties` INVARIANT 5), so the pair nets to zero. The
vendor tag follows the parties: on an entry naming several,
`set_entry_vendor` never replaces their shares (refused unless the
vendor's party is named), and a vendor record never links to a party
named beside another vendor (`add_vendor`), checked by
`tagging_the_vendor_keeps_a_shared_entrys_parties`,
`clearing_the_vendor_on_a_shared_entry_is_refused`,
`re_apportioning_a_reversed_entry_re_apportions_its_reversal`,
`a_named_party_gaining_a_vendor_record_keeps_one_vendor_per_entry`.
Shares that don't resolve count the whole payment toward the vendor (the
conservative answer) and the entry is listed (`Vendor1099Report::
unresolved`; a `books.vendor_1099` note). An entry naming one party
counts as before (`form1099::vendor_1099_report_with_members`, checked
by `a_shared_payment_counts_the_vendors_share`,
`a_percent_split_gives_the_leftover_cent_to_the_first`,
`a_reversed_shared_payment_nets_to_zero`,
`a_shared_payment_partly_on_a_card_scales_to_the_payment`,
`unresolved_shares_count_in_full_and_are_listed`). DECISION (Stephen,
2026-10-15: shares).
**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`
@@ -525,20 +565,35 @@ The full TIN, when the books need it for the 1099 worksheet, lives in the
TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key
file outside the books dir): `books.set_w9` `tin` writes it there and
journals only the last four and the vault record's hash; the signed W-9
goes there too, never to the attachment store. No tool output, error, or
log holds a full TIN except `books.vendor_1099` with `include_tins`
(DECISION, Stephen, 2026-10-15: for the CPA, when asked); files holding
one (`books.get_w9`, `save_csv_to`) are written only outside the books
dir. `books.purge_tin` deletes a party's vault records and journals that
it did.
goes there too, never to the attachment store. Any party may have its
TIN in the vault, whatever its roles: every member's (for their K-1) and
every 1099 payee's belongs there (DECISION, Stephen, 2026-10-15: "In the
vault, that's the WHOLE reason we built it. And it needs to be in the
damn CPA package."). `books.set_tin` stores one for any party; the books
journal its TIN facts (type, last four, the record's hash:
`party.tin_set`), which never disagree with its W-9 facts' last four. No
tool output, error, or log holds a full TIN except `books.vendor_1099`
with `include_tins` (DECISION, Stephen, 2026-10-15: for the CPA, when
asked): a refusal that quotes its input masks any TIN in it
(`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`),
and a party text field refuses nine digits however they are separated
(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA
package, whose K-1 and 1099 worksheets carry every member's and payee's
TIN by default) are written only outside the books dir, and the package
comes back only as its path. A locked vault or a missing TIN never fails
the package: it says which TINs are missing. `books.purge_tin` deletes a
party's vault records and journals that it did.
**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`;
`ledger-vault` (Tin::new, the encrypted records, records_held,
purge_records); `ledger-mcp` `books.set_w9`, `books.get_w9`,
`books.purge_tin`, `books.vendor_1099` (open_vault refuses a key file in
the books dir; outside_books for files with a TIN).
`set_party_w9`, `set_party_w4`, `set_party_tin` (TinFacts::check; TIN and
W-9 facts agree), `set_entry_party`, `validate_payroll`; `ledger-core`
`k1` (the K-1 worksheet); `ledger-vault` (Tin::new, the encrypted
records, records_held, purge_records); `ledger-mcp` `books.set_w9`,
`books.set_tin`, `books.get_w9`, `books.purge_tin`, `books.vendor_1099`,
`books.export_cpa_package` (open_vault refuses a key file in the books
dir; outside_books for files with a TIN; vault_tin checks the vault
against the books).
**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store`
`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp`
`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log,