Agent reason: one name for one thing. S4 and S6 kept the old spellings "until P5" so an agent that learned them did not break; P5 removes them, so the schema an agent reads has one parameter for each fact and a call cannot name the same thing twice with different answers. books.create_invoice takes customer and books.create_bill vendor (a record's id, its party's id or its name; required), no party_id. books.receive_payment and books.pay_bill take invoice or bill and deposit_to or paid_from, no document_id or cash_account; a payment names its document and its account or is refused saying which to pass. A number that is one record's id and another's name is still refused, never guessed (INVARIANT 6). The over-the-wire tests that used the old spellings now use the new (document_id becomes invoice or bill, cash_account becomes deposit_to or paid_from, party_id becomes customer or vendor given as the id's text); the one test that named the alias, party_id_alias_still_works, is now a_customer_by_id_and_by_name_is_one_record and checks the same two spellings of a customer, by id and by name, name one record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2725 lines
169 KiB
Markdown
2725 lines
169 KiB
Markdown
# MCP interface spec — `ledger-mcp`
|
||
|
||
## Goal
|
||
|
||
The primary interface to the books is an AI agent driving MCP tools. The
|
||
engine (`ledger-core`) stays pure, `ledger-store` persists, and `ledger-mcp`
|
||
exposes both over the Model Context Protocol — the open standard, so the
|
||
books are drivable by any MCP client (Muse, Claude Code, IDE agents), not
|
||
locked to one assistant.
|
||
|
||
## Crates
|
||
|
||
| Crate | Role | Spec |
|
||
|---|---|---|
|
||
| `ledger-store` | the hash-chained journal (the source of truth), the SQLite projection, the blob store | `docs/receipt-store.md` |
|
||
| `ledger-mcp` | the books server: the tools, resources and handbook over store + core | this document |
|
||
| `ledger-host` | the agent bridge (`wanderbooks mcp`), the host and its leases, tokens | `plan/served.md` 3.3, 3.9 |
|
||
|
||
(The overview sections of this document date from the first MCP phase; P5
|
||
rewrites them. The per-tool sections are held to the code by a test.)
|
||
|
||
Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting
|
||
logic: it translates tool calls into `Ledger` commands and formats results.
|
||
If a behavior can't be expressed through `ledger-core`'s public API, the
|
||
core is missing something — fix the core, don't work around it in the MCP
|
||
layer.
|
||
|
||
## Company settings
|
||
|
||
The books keep their own settings (journal format 2, plan/served.md P1):
|
||
each change is a `settings.set` line with who decided it and when, replayed
|
||
into `ledger_core::settings::Settings` by the server and the viewer alike
|
||
(rules.md B-29). Every key is in `ledger_core::settings::KEYS`, with what it
|
||
takes and means: the company's facts (`company.name`, `.entity_type`,
|
||
`.tax_classification`, `.tax_form`, `.fiscal_year_start` ("MM-01"; the
|
||
year's end is derived, never stored), `.timezone` (what "today" means for
|
||
the books), `.formation_state`, `.formed`, `.state_entity_number`, `.ein`
|
||
(the one identity number a setting keeps, shown masked: "**-***5893"),
|
||
`.irs_name_control`, `.duns`, `.address`, `.registered_agent`,
|
||
`.partnership_representative`, `.cpa`, `.members` (name, share as "50%" or
|
||
"0.5", capital account, optional distributions account and
|
||
`files_jointly_with`; shares sum to exactly 1)); the books'
|
||
(`books.accounting_basis`, `.cash_accounts`, `.financing_accounts` (loans,
|
||
and money the owners advanced: ASC 230-10-45-14(b)), `.investing_accounts`
|
||
(ASC 230-10-20), `.close_into` (single owner only), `.close_sweep`,
|
||
`.asset_tag_prefix`, `.schedule_convention` (daily, the default, or
|
||
monthly_even)); backups (`backup.git_remote`, `backup.folder`); and policy (`policy.evidence.threshold` (receipts
|
||
strictly above it; "0.00": every entry), `.required_accounts`,
|
||
`.exempt_accounts`, `policy.parties` (off, suggest, require), `policy.require_tax_category`
|
||
(warn, the default, or refuse),
|
||
`policy.approval_rules` (off, the default, or enforce)). A value is checked
|
||
by its key and kept in one spelling (a time zone as the database spells it,
|
||
an amount without leading zeros, a return as `schedule_c`, `1065`, `1120s`
|
||
or `1120`); a change is all or nothing, and the settings after it must hold
|
||
together (members and `close_into` never both, a classification that names
|
||
one return, a fiscal year that doesn't move under a closed period). No
|
||
setting holds nine digits that read as an SSN or EIN (bare, or printed
|
||
3-2-4 or 2-7) except `company.ein` and `company.duns`.
|
||
|
||
No settings file is ever read (plan D-44): books keep their own settings,
|
||
the defaults until the first is set. Books from before P1, which kept
|
||
theirs in a `wanderbooks.toml`, bring it in as they are adopted into the
|
||
data root (`wanderbooks books adopt`, plan 5.5). A vault key file is never
|
||
a books setting: a book in the data root keeps its own (`keys/legacy.key`
|
||
in its folder).
|
||
|
||
- `books.settings {}` → `{ timezone, today,
|
||
approval_rules, keys: [{ key, doc, identity, value, set_by, seq }],
|
||
company, fiscal_year_start, tax_form, basis, receipt_rule,
|
||
receipt_required_accounts, receipt_exempt_accounts, parties, members,
|
||
cash_accounts, financing_accounts, investing_accounts, close_into,
|
||
close_sweep, problems }`: the settings in force, every key the books know
|
||
with its value (null: unset, its default applies) and who set it, and
|
||
`problems`, settings that disagree with the chart, each saying what to
|
||
change (conventions naming an account no longer in the chart among
|
||
them); `books.digest` repeats them as `settings_problems`. Settings are
|
||
named by key.
|
||
- `books.set_setting { changes: [{ key, value }], decided_by, decided_on?,
|
||
citation?, note? }` → `{ changed: [{ key, was, value }], problems }`: one
|
||
or more changes, together or not at all (`value` null clears a key back to
|
||
its default; a value already set changes nothing, idempotent;
|
||
destructive, since it can clear or switch off a setting).
|
||
- `books.export_settings {}` → `{ toml_text }`: the settings as a settings
|
||
file, each with who decided it as a comment, the EIN masked; a copy:
|
||
editing it changes nothing (`ledger_config::codec`). No tool reads a
|
||
settings file: old books bring theirs in once, whole, when the owner
|
||
adopts them (`wanderbooks books adopt`), and only `books.set_setting`
|
||
changes the books' settings after that.
|
||
|
||
The accounts invoices, bills, payroll, and closes post to are not settings:
|
||
they are control roles in the journal (rules.md B-23).
|
||
|
||
- `books.roles {}` → `{ roles: [{ role, purpose, account_type,
|
||
default_code, assigned, account_code?, account_name? }] }`: every control
|
||
role and the account filling it.
|
||
- `books.assign_role { role, account? }` → `{ roles }`: assign a role one
|
||
of the chart's accounts (`account` null: back to the default code).
|
||
Refuses the wrong type, a placeholder, an account already filling
|
||
another role, and moving a balance sheet role while its account holds a
|
||
balance. Journaled as `role.assigned`.
|
||
|
||
## Tools — how the books are kept
|
||
|
||
Conventions, decisions, open items and approvals live in the books too
|
||
(`ledger_core::conventions`, rules.md B-29), each with who decided it and
|
||
when; `books.start` lists them, and the handbook's
|
||
settings-and-conventions section says how to use them.
|
||
|
||
- `books.start { focus?, agent? }` (read-only, at most 8 KB; `_meta`
|
||
`anthropic/alwaysLoad: true`, so a client that defers tools loads it up
|
||
front) → `{ book: { id, url, name,
|
||
timezone, today, fiscal_year, base_currency, basis }, you?: { principal,
|
||
kind, scopes, client_id? }, head: { seq, hash },
|
||
state ("needs_onboarding" or "active"), rules: {
|
||
evidence, parties, approval_rules }, settings_summary: [{ key, value,
|
||
set_by, seq }], conventions: [{ id, kind, title, summary }], open_items:
|
||
[{ id, title, due }], projects: [{ id, name, status, kind? }],
|
||
drafts: { building, awaiting_evidence: [{ entry_id, date, amount, note
|
||
}] }, reconciliation: { tracked, never_reconciled, behind } (account
|
||
codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args
|
||
}], needs_attention: { items: [{ id, severity, kind, title, tool?, args?
|
||
}], counts: { kind: n } }, truncated?, handbook_sections,
|
||
format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool |
|
||
prompt, args?, why, required }], isolation }`. The connect text names it
|
||
as the first call. `book.url` is the book's path on this server
|
||
(`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts
|
||
(its name, the kind `agent`, its scopes) and the client program's name; it is
|
||
absent on the `books://start` resource, which no token's call made.
|
||
`agent: { model?, reasoning_effort?, harness? }` is what the agent says
|
||
about itself: self-reported, never checked, written to the call's access
|
||
record (`books.access_log` shows it as `agent`) and used for nothing
|
||
else. `drafts` lists the first ten drafts waiting on their evidence;
|
||
`reconciliation` the accounts reconciled to statements and which are
|
||
behind or never reconciled; `needs_attention` the twelve most urgent
|
||
items (problems first) with every item counted by kind: the same list
|
||
the viewer draws, from one function (`ledger_package::attention`). If
|
||
the answer would pass 8 KiB it shortens, in this order: convention
|
||
summaries, attention items (to four), waiting drafts (to three), then
|
||
the lists below, each saying how many more in `truncated`.
|
||
`format_upgrade` is there only for books waiting for a format upgrade
|
||
(below); its next step comes first, before anything else, at every
|
||
start until it is done: `books.handbook {section: "format-upgrade"}`,
|
||
"Tell the human first: …".
|
||
`isolation` is `same_user (advisory)` locally (D-32): the books are
|
||
hidden from agents in the owner's own account, not locked against them.
|
||
On the owner's own machine, where the server runs as a separate account
|
||
and agents have none, the unit passes `--isolation enforced` and it is
|
||
`service_user (enforced)`; the server never guesses (P9a, 3.8). Lists
|
||
cut to fit say so in `truncated` ("conventions: 4 more (books.conventions)").
|
||
`projects` lists the active and paused projects by name, at most 20
|
||
("projects: N more (books.projects)" past that).
|
||
`next_steps`, in order: the format upgrade's (when waiting); the
|
||
onboarding steps (the `onboard` prompt, `books.set_setting`,
|
||
`books.chart_template` and `books.handbook {section: "contract"}`, when
|
||
the chart is empty or `focus` is `onboarding`); `books.open_items` (an item due
|
||
within 30 days or overdue); `books.backup` (none set up, or one stale,
|
||
failing, paused or failing its drill); then Needs attention, by
|
||
severity, at most 12, each one `{tool, args?, why}` whose `why` is the
|
||
item's title (the same list the viewer draws: `ledger_package::
|
||
attention`, so the two can't differ; `books.verify` items are the
|
||
server's own damage check and are not repeated); then the focus's step
|
||
(`close`:
|
||
`books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then
|
||
`books.draft_entry` (the books are set up), `books.handbook {section:
|
||
"contract"}` and `books.digest`. Attention steps are `required` when the
|
||
item is a problem.
|
||
`focus` is `daily` (the default), `onboarding`, `close` or `audit`;
|
||
anything else is refused. Also `books://start`.
|
||
**The format upgrade** (plan 3.9, D-39, rules.md B-27). Books an older
|
||
wanderbooks wrote are in an older journal format: they are served on it,
|
||
and every tool that needs nothing new works as before. A tool whose
|
||
change needs the newer format (on main: settings, conventions, decisions,
|
||
open items and approvals need journal format 2; later formats add
|
||
features) is refused before anything is journaled, its error beginning
|
||
`format_upgrade_pending:` and saying that only the owner can give the
|
||
go-ahead, with the owner passphrase, in the app or with `wanderbooks
|
||
books upgrade ID` at a terminal, and that no tool can. No tool consents:
|
||
none is offered, and the server takes the go-ahead only on its admin
|
||
socket, from the host's lease intent `consent_format_upgrade` (the app's
|
||
dialog, `books upgrade`), with the passphrase it checks. Before the
|
||
upgrade the server commits the books to their own backup repository and
|
||
proves the copy holds them exactly (journal, stored files, vault, keys);
|
||
then one `journal.format_set` declares the whole format, and the same
|
||
session's next call can make the change. Books this version creates are
|
||
in its format from their second line, and an adoption's copy moves to it
|
||
as it is made.
|
||
|
||
- `books.conventions { kind?, include_retired? }` → every convention with
|
||
its full text, structure, revision and source (`books://conventions`).
|
||
`books.set_convention { id, kind, title, body_md, structured?,
|
||
decided_by, ... }` sets or revises one (kinds: `posting_template`,
|
||
structured `{ description?, lines: [{ account, side, share }] }`, each
|
||
side's shares summing to 1, on accounts in the chart that take postings
|
||
or on slots (`"{expense}"`) each draft names;
|
||
`approval_rule`, structured `{ approver, accounts?, amount_over? }`; and
|
||
`policy`, `close_step`, `calendar`, `mapping`, `note`, in words). An id is
|
||
a slug, revised in place (same kind), never reused once retired:
|
||
`books.retire_convention { id, reason, decided_by, ... }` (destructive).
|
||
**Correcting a source** (P5, journal feature `source_revisions`):
|
||
`books.set_convention` with `reason` and the text exactly as it stands
|
||
changes only who decided it, when and on what (`decided_by`,
|
||
`decided_on`, `citation`, `note`), journaled as `convention.source_revised
|
||
{id, source, reason}`; the revision number and the words do not move.
|
||
Without `reason` the same call is refused ("already says exactly that;
|
||
to correct only who decided it, when or on what, add reason"), and with
|
||
`reason` and different words it is refused ("changes more than its
|
||
source"). Books that have not had the owner's upgrade refuse it with
|
||
`format_upgrade_pending`.
|
||
- `books.draft_from_template { template_id, date, amount, accounts?,
|
||
description?, party?, no_party_reason? }` → `{ entry_id, description,
|
||
lines }`: a draft whose lines split the amount (at most a line's) by the
|
||
template's shares, each side summing to it exactly, odd cents by largest
|
||
remainder; `accounts` names each slot's account (`{"expense": "6300"}`),
|
||
every slot and nothing else. A refused draft journals nothing (a disk
|
||
failing midway could leave a partial draft, as for any draft built line
|
||
by line).
|
||
- `books.record_decision { subject, decision_md, decided_by: [..],
|
||
decided_on, evidence_attachment?, supersedes?, citation?, projects? }` →
|
||
`{ id }`; `books.decisions { include_superseded?, project? }`. A decision
|
||
supersedes at most one live decision; its evidence is a document still
|
||
in the books. `projects` (ids or names, each once, resolved before
|
||
anything is written) names the projects it is about (rules.md B-37); a
|
||
decision without them journals the bytes it always did, and one with
|
||
them needs the journal feature `projects`. `books.decisions` gives each
|
||
decision's `projects: [{ id, name }]` (left out when none); `project`
|
||
filters to the decisions naming one. `books.record_decision { revises:
|
||
ID, reason, decided_by: [..], decided_on, citation? }` corrects only
|
||
that decision's source (who decided it, when, on what; its words stay,
|
||
and `subject` and `decision_md`, if given, must be the ones it has):
|
||
`decision.source_revised {id, source, reason}`, returning `{ id }`.
|
||
- `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`
|
||
(`books.raise_item { revises: ID, reason, decided_by, ... }` corrects
|
||
only that item's source: `open_item.source_revised`),
|
||
`books.resolve_item { id, resolution, decided_by, ... }`,
|
||
`books.open_items { include_resolved? }`.
|
||
- `books.approve_entry { entry_id, approver, note?, approved_on? }`: the
|
||
approver approved the draft as it stands (its fingerprint: date,
|
||
description, every line and class; a line added or a class changed
|
||
afterwards voids it). Under `policy.approval_rules = enforce`,
|
||
`books.post_entry`'s preview lists `approvals_missing` and a confirmed
|
||
post is refused with `approval_required` until each covering rule's
|
||
approver approved (the store refuses it too), and a tool that posts at
|
||
once (an invoice, a bill, a payment, payroll, a remittance, a disposal)
|
||
is refused when a rule covers the entry it would post: draft it instead.
|
||
Reversals, closes, depreciation and amortization are exempt. A rule
|
||
names accounts that take postings; an account a rule names can't be
|
||
renumbered until the rule is revised, and `books.renumber_account` lists
|
||
the conventions that name the old code. Under `off` (the default) rules
|
||
block nothing.
|
||
|
||
## Transports
|
||
|
||
Books are served one way (plan D-44): by the books server, which agents
|
||
reach through the bridge.
|
||
|
||
- **Served books** (`wanderbooks server`, started by the host; an agent
|
||
connects with `wanderbooks mcp --book ID`, the bridge: stdio to the
|
||
agent, the server's socket upstream): every book
|
||
in the data root, on a socket in the root's private `run/` folder that
|
||
only this account can connect to. Every request carries a bearer token
|
||
live for that book (refused 401 without one, 403 with another book's).
|
||
Refusals at the door, tool calls, uploads, downloads and exports are
|
||
recorded in the book's access log; when a record can't be written the
|
||
book stops taking requests (an upload, download or export is then
|
||
refused, 503).
|
||
- `/b/{book}/mcp`: the book's MCP service. A request is at most 1 MiB.
|
||
- `PUT /b/{book}/u/{upload}`: an upload's bytes (`books.begin_upload`),
|
||
from the token that began it; what `wanderbooks upload --book B
|
||
[--token NAME] UPLOAD FILE` sends. Streamed to the disk, kept only if
|
||
exactly the size and SHA-256 declared (so at most 25 MiB), once. The
|
||
receive and its access record are one blocking task: bytes are never
|
||
kept without their record (if it can't be written they are thrown
|
||
away, 503 `not-recorded`, and the books stop). 200 with `{ received,
|
||
size, sha256 }`; else, with nothing kept and the code in
|
||
`x-wanderbooks-refused`: 404 `no_such_upload` (unknown or lapsed,
|
||
also if it lapsed while being sent), 403 `not_yours`, 409
|
||
`already_sent` (its bytes came, or a call holds them), 409 `in_use`
|
||
(another request is sending them), 400 `not_as_declared` (short, more than declared, or
|
||
the wrong hash), 503 `stopped` (the books stopped after an internal
|
||
error), 500 `failed`. A refused request's body is read and dropped
|
||
(up to 25 MiB), so the client gets the answer, not a cut connection.
|
||
Logged as action `upload` with the upload's id, a refusal at the
|
||
door (no token, another book's) too. An upload's id is `up_` and 32
|
||
letters `a`-`p`: no digits, so the access log's TIN masking never
|
||
hides it.
|
||
- `GET /b/{book}/a/{attachment}`: a stored file's bytes, for a token
|
||
with `books:read` (403 `insufficient_scope` without it; 404
|
||
`no_such_attachment`); the read and its access record are one
|
||
blocking task. Sent with `Content-Type` (the stored MIME type, or
|
||
`application/octet-stream` if it can't be a header),
|
||
`Content-Disposition: attachment; filename="…"; filename*=UTF-8''…`
|
||
(the plain name: anything but visible ASCII and spaces, and `"` and
|
||
`\`, made `_`; then the whole name, RFC 8187 percent-encoded),
|
||
`X-Content-Type-Options: nosniff`, and `Repr-Digest:
|
||
sha-256=:<base64>:` (RFC 9530). A stored file that can't be read
|
||
whole is 500 `failed`; books that stopped, 503 `stopped`. GET only
|
||
(a HEAD is 405, unrecorded). Logged as
|
||
action `download` with the attachment's id, a refusal at the door
|
||
too.
|
||
- `GET /b/{book}/x/{export}`: a file the books made for an agent (the
|
||
CPA package, a 1099 worksheet, a W-9, a report export, a label sheet,
|
||
a receipt's copy: a `HandedFile`, "Tools — the TIN vault"), as `/a/` (the same door, the same headers,
|
||
`Repr-Digest` included, GET only), but only for the token it was made
|
||
for (403 `not_yours`), with no scope of its own to check (the tool
|
||
that made it checked its scope); 404 `no_such_export` (unknown or
|
||
lapsed), 503 `stopped`. An export's id is `ex_` and 32 letters
|
||
`a`-`p`; it is kept 15 minutes, fetched as often as wanted until
|
||
then, at most 64 per token (every file an agent takes away is one,
|
||
D-46), and swept like uploads. Logged as action
|
||
`export` with the export's id.
|
||
- `wanderbooks download --book B [--token NAME] ID --to DIR` takes an
|
||
attachment's id (a number, `/a/`) or an export's (`ex_…`, `/x/`),
|
||
checks the digest before saving, and saves it under the
|
||
server's name (`filename*` first) made plain: separators and control
|
||
characters made `_`, invisible (Unicode format) characters and
|
||
leading dots and spaces dropped, at most 200 bytes with the extension
|
||
kept. Beside a file of that name it is `name (1).ext`, and so on;
|
||
`attachment-<id>` or `export-<id>` if the folder takes no such name.
|
||
It is written to a
|
||
hidden file and hard-linked in: never a partial file under the real
|
||
name, never over another file.
|
||
|
||
## Server instructions
|
||
|
||
The MCP `initialize` response carries an `instructions` string, the
|
||
**connect text** (`ledger-mcp/src/handbook.rs`), composed in `get_info`
|
||
from the settings in force when the client connects (the books' settings
|
||
are taken first, so a client connecting after a change is told the new
|
||
rule).
|
||
It is short on purpose: clients cut long instructions (one cut the old
|
||
text at 4,096 characters), so it is at most **1,400 characters**, and its
|
||
first paragraph, at most **512**, stands alone: the entry point
|
||
(`books.start` first each session, and `books.handbook` for every
|
||
procedure), the rule that these tools are the only way to the books (never
|
||
local files), decimal-string amounts, and the draft → lines → preview →
|
||
confirmed post flow. The second paragraph adds
|
||
that every entry balances and uses real accounts from `books.chart`; the
|
||
parties rule in force (`[books] parties`: under "require" every entry
|
||
the agent posts names its party or a no-party reason, since closes,
|
||
depreciation and amortization post with none; under "suggest" name it
|
||
where there is one; under "off" nothing); corrections by reversal; the receipt rule in force
|
||
(`ReceiptPolicy::rule`: the exact threshold in the books' base currency,
|
||
never rounded, the accounts that always need a receipt and those exempt,
|
||
and that reversals need none); the exemptions (depreciation and
|
||
amortization, whose schedules are the evidence, and closes, derived from
|
||
the books); that the invoice, bill, payment and payroll tools post at
|
||
once, unchecked (in those words, naming no tool); TINs
|
||
only into the vault; show the numbers, and after posting check
|
||
`books.digest` and offer it to the human; and where settings live: in the
|
||
books, changed only through the tools. When a long rule (many
|
||
accounts, a huge threshold, a long name) would push it past 1,400
|
||
characters, it drops the tracing hint first (the handbook's
|
||
tracing-figures section has it), then the reasons in parentheses, then the
|
||
tracing hint for good and lists fewer account codes (at last only how many
|
||
there are; `books.settings` lists every one, uncut, in
|
||
`receipt_required_accounts` and `receipt_exempt_accounts`), then words
|
||
that only help; never the rule itself. A company with no name yet (the
|
||
settings' default) is "a new company". The company name in it is one line of plain text (control characters and
|
||
line breaks become spaces, Unicode format characters such as bidi
|
||
overrides and zero-width spaces are dropped), cut to 56 characters.
|
||
`instructions_budget` (the longest rule, every parties setting) and
|
||
`the_company_name_is_one_plain_line` check it.
|
||
|
||
The long guidance is the **handbook**, compiled in from
|
||
`ledger-mcp/handbook/*.md`, with the receipt rule in force rendered in:
|
||
|
||
- `books.handbook { section? }` (read-only) → `{ read_first, sections:
|
||
[{ id, title, uri, chars }], section: { id, title, text } | null }`.
|
||
Without `section`, the index (`read_first` says to read `contract`
|
||
first); with a section id, that section's Markdown; with `"all"`, the
|
||
whole handbook in reading order. The input schema lists the ids:
|
||
contract, entries, drafts-and-reversals, evidence-and-receipts,
|
||
attachments, files-in-out, reconciliation, fixed-assets,
|
||
prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099,
|
||
cpa-handoff, settings-and-conventions, viewer, backups, self-host,
|
||
format-upgrade, rules-that-change, control-roles, tracing-figures.
|
||
- The same as resources: `books://handbook` (the index, JSON) and
|
||
`books://handbook/{section}` (Markdown, `text/markdown`), one listed per
|
||
section.
|
||
|
||
No text the agent is given (tool JSON with its schemas, resources, connect
|
||
text, handbook, and every refusal fixed in code: each error variant's
|
||
template in the crates under the server, scanned from source, built error
|
||
texts, and the server's refusal constants) names a helper command, a home
|
||
path, the data dir or its flag, the old import-root flag, a file of the books
|
||
(`.jsonl`, the database, `vault.key`, `.gitignore`), git, GitHub, a pull
|
||
request, a repo doc (`docs/`, `.md`), or tells the agent to read the
|
||
books' files; no tool title or description, connect text or handbook
|
||
section names `wanderbooks.toml` (`tool_text_lint`). A rule set by law that the books
|
||
don't know for a year names its official source (the statute or
|
||
publication, with its address) and says to tell the human.
|
||
Tool descriptions are at most 700 characters; eight longer ones are listed
|
||
in `tool_descriptions_fit_700_characters`, to be shortened in P5.
|
||
|
||
**Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool
|
||
that changes anything also has a `destructiveHint`, true for these 31:
|
||
`books.post_entry`, `books.reverse_entry`, `books.set_setting`, `books.retire_convention`, `books.unlink_receipt`,
|
||
`books.unlink_attachment`, `books.withdraw_attachment`,
|
||
`books.correct_asset`, `books.dispose_asset`, `books.run_depreciation`,
|
||
`books.run_schedules`, `books.run_recurring`, `books.renumber_account`,
|
||
`books.rename_account` (the label every past report prints),
|
||
`books.assign_role`, `books.close_period`, `books.reconcile`,
|
||
`books.void_statement`, `books.void_reconciliation`, `books.set_w9`,
|
||
`books.set_tin` (both overwrite a vault record), `books.purge_tin`,
|
||
`books.create_invoice`, `books.create_bill`, `books.receive_payment`,
|
||
`books.pay_bill`, `books.void_document`, `books.write_off_invoice`,
|
||
`books.run_payroll`, `books.remit_payroll_taxes`,
|
||
`books.consolidate_duplicates`, `books.discard_draft`; and an
|
||
`idempotentHint` on the ten that a repeat with the same arguments leaves
|
||
the journal and the vault as they were: `books.verify`,
|
||
`books.run_depreciation`, `books.run_schedules`, `books.run_recurring`,
|
||
`books.set_party_roles`, `books.set_setting`, `books.set_vendor_1099`, `books.set_entry_instrument`,
|
||
`books.update_project` and
|
||
`books.back_up_now` (a setter such as `books.update_party`,
|
||
`books.set_fx_rate` or `books.set_tin` journals a line, and `books.set_tin`
|
||
seals a new vault record, every time, so none carries it;
|
||
`idempotent_tools_change_nothing_when_repeated` calls each candidate twice
|
||
to check). Every tool's
|
||
`openWorldHint` is false: the books are a closed world, and no tool
|
||
reaches beyond them, but `books.back_up_now`, which pushes the books to
|
||
the owner's backups (true). `books.export_cpa_package`, `books.vendor_1099`
|
||
and `books.get_w9` keep a file for the agent (an export) and record the
|
||
ones with TINs, so they are not read-only. No tool carries
|
||
`requiresUserInteraction` (owner decision
|
||
2026-10-16: no added prompts). `annotations_complete` pins all of this.
|
||
The read-only hint is true in behaviour: `spec_mcp` checks, on every call,
|
||
that a read-only tool leaves the journal and the contents of every file of
|
||
the books (their folder, its vault, the database by every row of every
|
||
table; SQLite's -wal and -shm files, which move pages and hold read marks
|
||
on their own, aside, and what every call writes by design: the access
|
||
log, the viewer's side channel, the books' own backup, files handed over)
|
||
as they were (`read_only_tools_change_nothing`). `books.show` is read-only though it
|
||
writes the viewer's request into the books' `ops/` folder: that folder
|
||
is a side channel, never journaled, and moves only the view.
|
||
|
||
**Failing closed.** Tool bodies run on blocking threads, one at a time on
|
||
the store. If a request panics (an internal error), the journal may hold a
|
||
line the memory doesn't, so the server stops taking requests: every later
|
||
call, on every session sharing the store, is refused with "the books
|
||
server stopped taking requests after an internal error, to protect the
|
||
books: ask the human to restart the books server", never the panic's own
|
||
text. A restart re-verifies the journal
|
||
(`a_request_that_panics_stops_the_server`). So a panic an input can reach
|
||
would be a denial of service, and none can be: the dates a tool takes
|
||
are four-digit years and real days, its amounts at most a journal
|
||
line's, and a recurring run bounded; books an earlier version wrote past
|
||
those bounds still open (replay accepts what that version accepted), so
|
||
every figure the server derives from them (a draft's total, a schedule's
|
||
plan, aging, a party's totals, the asset register) is computed or
|
||
refused ("too large to compute exactly", or a date outside the calendar
|
||
the books keep), never a panic (rules.md B-28). `tests/hostile_args.rs`
|
||
drives every tool with generated hostile arguments and every resource
|
||
with generated URIs, checks after each call that the server still
|
||
serves, restarts it after them to check the books reopen, and drives
|
||
every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||
|
||
## Tool design principles
|
||
|
||
- **Narrow tools, explicit arguments.** No "do what I mean" tools. The agent
|
||
supplies dates, accounts, sides, amounts; the server validates.
|
||
- **Amounts are decimal strings** in requests and responses. Never floats,
|
||
never locale-formatted numbers.
|
||
- **Every mutating tool echoes the exact resulting state.** The "show the
|
||
numbers" rule from AGENTS.md applies over the wire too.
|
||
- **Preview-before-post.** `post_entry` and `reverse_entry` take
|
||
`confirmed: bool`. Without confirmation they return a preview (full lines
|
||
+ resulting account balances) and post nothing. The agent must present the
|
||
preview to the human before confirming. This encodes the standing
|
||
fact-check rule in the protocol itself.
|
||
- **The preview digest (P4, D-50).** Every tool called without
|
||
`confirmed` returns `preview`, the SHA-256 of what it shows: for a draft
|
||
entry, its canonical state (lines, classes, party, vendor, instrument,
|
||
the evidence linked) and the settings seq; for any other operation, the
|
||
tool, its arguments, the settings seq and the journal head seq. Every
|
||
call with `confirmed: true` takes that `preview` and is refused, before
|
||
anything is written, with the guard `{ code: "stale_preview", message,
|
||
remedy_tool }` when it is missing or no longer matches: an argument
|
||
changed, or another session wrote in between (a post by another session
|
||
forces a re-preview, by design). No key is involved: it detects drift;
|
||
it does not prove a human looked. The tools that take `preview` are all
|
||
those with `confirmed`: `books.post_entry`, `books.reverse_entry`,
|
||
`books.run_schedules`, `books.dispose_asset`, `books.reconcile`,
|
||
`books.close_period`, `books.create_invoice`, `books.create_bill`,
|
||
`books.receive_payment`, `books.pay_bill`, `books.write_off_invoice`,
|
||
`books.purge_tin`, `books.consolidate_duplicates` and
|
||
`books.discard_draft`. (`books.void_document`, `books.run_depreciation`
|
||
and `books.run_recurring` post at once and have no `confirmed`.)
|
||
- **The result envelope (P5, plan 4.6).** Every tool result, whatever the
|
||
tool, is given the same four fields in one place (`call_tool`,
|
||
`next_steps.rs::apply`), after the tool has answered; a tool's own field of
|
||
the same name is kept, never overwritten:
|
||
`next_steps: [{ tool | prompt | command, args?, why, required }]` (what to
|
||
do next: after a preview, the same call with `confirmed: true` and the
|
||
returned `preview`; after a post, `books.show` of the entry, plus the
|
||
Needs-attention items about that entry or invoice, the viewer's own,
|
||
`required` when they are problems; after a close, `books.verify`; after
|
||
`books.export_cpa_package`, a `command` step carrying the download
|
||
command; every other tool that can change the books ends with
|
||
`books.digest`), `guards: [{ code, message, remedy_tool, remedy_args? }]`
|
||
(empty on success; on a refusal that is a guard, the refusal's
|
||
`structuredContent` is `{ guards: [..] }` and its text is unchanged),
|
||
`warnings: [..]` (a tool's own `warnings` is that field), and, on every
|
||
tool that is not read-only, `head: { seq, hash }`, the journal's head
|
||
after the call's last write (or as it stands, when the call wrote
|
||
nothing). The guard codes are the enum in `guards.rs`, each with its
|
||
remedy tool: `stale_preview` (the tool again, previewed),
|
||
`format_upgrade_pending` (`books.start`), `receipt_required`
|
||
(`books.attach_receipt`), `approval_required` (`books.approve_entry`),
|
||
`party_shares` and `party_required` (`books.set_entry_party`),
|
||
`open_drafts` (`books.list_drafts`), `untagged_control_line`
|
||
(`books.control_check`), `duplicate_vendor_ref` (`books.search_entries`)
|
||
and `first_number_locked` (`books.settings`). Tests:
|
||
`every_mutating_result_has_head_and_next_steps`,
|
||
`the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`,
|
||
`every_guard_names_an_existing_tool`.
|
||
- **Errors are typed strings** mirroring `LedgerError` variants, with the
|
||
offending values included (e.g. unbalanced totals).
|
||
|
||
## Tools — books
|
||
|
||
- `books.handbook { section? }`: the guide (see "Server instructions").
|
||
- `books.chart` → `{ accounts: [{ id, code, name, account_type, parent,
|
||
placeholder, balance, tax_category, deductible_percent }], base_currency
|
||
}`. Balances included: one call answers "what do we have."
|
||
- `books.add_account { code, name, account_type, parent?, placeholder?,
|
||
tax_category?, deductible_percent? }` → `{ account_id, warnings? }`.
|
||
Codes are unique; a name another account under the same parent has
|
||
(top-level accounts are siblings too; ignoring capitals and surrounding
|
||
space), one holding a run of nine digits, or one that isn't one line of
|
||
at most 200 characters, is refused (rules.md B-31); the name is kept
|
||
trimmed. A revenue or expense account (not a header) without `tax_category` is
|
||
created with a warning ("6150 Software has no tax category: the tax
|
||
summary will list it as uncategorized. Propose one from
|
||
books.tax_categories and set it with books.set_tax_category once the
|
||
human agrees."), or, under `policy.require_tax_category = refuse`,
|
||
refused (rules.md B-32). The category is checked against the account's
|
||
type and the return, as `books.set_tax_category` checks it; journaled as
|
||
`account.added` then `account.tax_set`.
|
||
- `books.draft_entry { date: "2026-10-10", description: "...", vendor?,
|
||
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?, preview?, expected_total? }` → `{ status, preview, entry_id, date,
|
||
description, total, lines (with resulting balances), receipts,
|
||
receipt_required, receipt_missing, split_detected, party_policy,
|
||
party_id?, party?, parties, parties_error?, no_party_reason?,
|
||
party_missing, party_suggestions, approvals_missing? }`. `expected_total`,
|
||
when given, must be the entry's total debits, else nothing posts.
|
||
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, preview? }` → same
|
||
preview/confirm pattern as post. A reversed depreciation run or schedule
|
||
month is un-posted (its month is due again); refused, at the preview
|
||
already (P3): an asset's disposal entry (it would stay disposed), a
|
||
depreciation run of a disposed asset (its disposal took it as final),
|
||
and a reversal whose reversal would post a depreciation period or a
|
||
schedule month a second time (naming the entry that posts it now), or
|
||
re-instate a run a later run caught up (carrying the books past the
|
||
schedule); and a reversal of a depreciation run or a schedule month's
|
||
posting (or of a reversal in its chain) dated before that entry's own
|
||
date (a correction comes after what it corrects, B-9).
|
||
- `books.get_entry { entry_id }` → full entry with lines (each `{
|
||
account_id, code, name, side, amount, class_id?, project? }`: a tagged
|
||
line's project, its id and name), receipts, and
|
||
its `vendor`, `party_id`, and `party` (the party's label, when it names
|
||
exactly one) tags, `parties` / `parties_error` as in the `post_entry`
|
||
preview, and `no_party_reason` when it is marked as having no
|
||
counterparty. `total` (its debits) is null, with `total_error` saying
|
||
why, for a draft an earlier version journaled whose lines exact
|
||
arithmetic can't sum; a draft line on an account the chart doesn't have
|
||
(older books hold such drafts) shows an empty code and "(no account N)".
|
||
- `books.search_entries { account_id?, date_from?, date_to?, text?, limit?,
|
||
offset? }` → `{ entries: [{ entry_id, date, description, total,
|
||
reversal_of, receipt_count }] }`, posted entries, newest first (no
|
||
lines: `books.get_entry` has them). `limit` defaults to 50 and caps at
|
||
500.
|
||
- **Omitted dates (rules.md B-30).** One rule everywhere
|
||
(`ledger_core::calendar::PeriodDefaults`), stated in every report
|
||
tool's description and date parameters in the same words: a period
|
||
report with no dates covers the fiscal year to date
|
||
(`company.fiscal_year_start`): `end` defaults to today (the company's
|
||
time zone), `start` to the first day of the fiscal year that holds
|
||
`end`; a `start` after the `end`, given or derived (`start` after today
|
||
with no `end`), is refused, never swapped. An `as_of` defaults to
|
||
today; a party's `year` to this calendar year. Every result names the
|
||
dates it covers.
|
||
- `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced`
|
||
(the trial balance as of the date, default today).
|
||
- `books.balance_sheet { as_of? }` → totals (`assets`, `liabilities`,
|
||
`equity`, `current_earnings`, `balanced`) plus `asset_accounts`,
|
||
`liability_accounts`, `equity_accounts` (`{code, name, amount}`), as of
|
||
the date (default today).
|
||
- `books.profit_and_loss { start?, end?, class? }` → totals plus
|
||
`revenue_accounts` and `expense_accounts` for the range (omitted dates:
|
||
the fiscal year to date). Closing entries are excluded, so a closed
|
||
period still shows what it earned.
|
||
- `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to the books' settings, `books.cash_accounts` and so on; cash is required one way or the other)
|
||
→ the statement of cash flows, direct method (rules.md B-14, P3 defect
|
||
1; omitted dates: the fiscal year to date): `{ method: "direct", start,
|
||
end, operating_by_class, operating, cash_from_operations,
|
||
investing_by_kind, investing, cash_from_investing,
|
||
financing_by_kind, financing, cash_from_financing, net_change_in_cash,
|
||
cash_beginning, cash_ending, noncash_disclosures,
|
||
operating_reconciliation, reconciles, warnings }`.
|
||
- Lines (`operating`, `investing`, `financing`) are one per entry, section
|
||
and kind: `{ entry, date, label, kind, amount, on_behalf?, accounts,
|
||
reverses?, class? }`; every transaction is listed (D-35), each under its
|
||
kind. An entry's interest is its own operating class, (b) or (e), apart
|
||
from the rest of its operating cash. A payment to a party with the
|
||
`government_agency` or `tax_agency` role is `other_payments`, (g),
|
||
on any account (rules.md B-14 rule 5; payroll remittances excepted);
|
||
one shared between such a party and a supplier gives two operating
|
||
lines for the one entry, `other_payments` and
|
||
`to_suppliers_and_employees`, split by the parties' shares and
|
||
summing to the payment. What a member or a lender paid or
|
||
received for the company with no cash account moving is cash borrowed
|
||
(or contributed) and spent, on the entry's date (D-42, rules.md B-14
|
||
rule 11): an expense a member paid personally through Due to Members
|
||
is a "Proceeds from borrowing — <account>" line and an operating
|
||
line, each with `on_behalf`, the part of its amount so handled (absent
|
||
when none; an entry's `on_behalf` parts net to zero).
|
||
Labels never come from a reversal's sign: "Reversal of #N — …",
|
||
"Reinstatement of #A (reverses #B)". An entry and its reversal in the
|
||
same period drop out together.
|
||
- `operating_by_class`, `investing_by_kind`, `financing_by_kind`:
|
||
`{ kind, class?, amount, entries }`, how the printed statement shows the
|
||
sections: operating by ASC 230-10-45-25 class (`from_customers`,
|
||
`interest_and_dividends_received`, `other_receipts`,
|
||
`to_suppliers_and_employees`, `interest_paid`, `income_taxes_paid`,
|
||
`other_payments`), investing and financing by kind ("Contributions from
|
||
members — <member>", "Repayments of borrowing — <account>", "Purchases
|
||
of property and equipment", …).
|
||
- `noncash_disclosures`: `{ entry, date, label, amount, operating,
|
||
investing, financing, exchanged, reverses? }`, the investing and
|
||
financing activities no cash settled (ASC 230-10-50-3): property
|
||
contributed, debt converted to equity, a purchase on the seller's
|
||
credit, interest charged into a loan, the noncash part of a part-cash
|
||
purchase.
|
||
- `operating_reconciliation`: `{ net_income, lines: [{ kind, label,
|
||
amount, account?, entries }], net_cash_from_operating }`, net income
|
||
reconciled to operating cash (ASC 230-10-45-30), kinds `depreciation`,
|
||
`investing_gain_loss`, `noncash_through`, `other_noncash`,
|
||
`operating_balance_change`.
|
||
- `reconciles`: `{ sections_sum, net_change, difference }`; `difference`
|
||
is always `"0.00"`: a statement that wouldn't foot is an engine error,
|
||
never output.
|
||
- `warnings`: `{ entry, kind, message }` for an entry whose later payment
|
||
or collection the books can't classify (`asset_on_operating_payable`,
|
||
`asset_sold_on_operating_receivable`), with the fix.
|
||
- Refused, naming the setting, when the roles contradict the chart or
|
||
each other (cash and investing accounts must be assets, financing
|
||
accounts liabilities or equity; no account in two lists or twice in
|
||
one; no registered asset's account as cash).
|
||
- Changed in P3 (breaking): `net_income`, `depreciation` and
|
||
`working_capital` are gone (net income is in `operating_reconciliation`).
|
||
- `books.digest { since: "2026-10-01" | since_seq: 412, as_of? }`
|
||
→ `{ since, since_seq?, head_seq, entries: [...], totals_by_account: [...],
|
||
zero_net_accounts: [...], drafts_by_state: { building?, awaiting_evidence? },
|
||
reconciliation_tracked: [...], never_reconciled: [...], stale_rules: [...],
|
||
journal_repairs: [...], settings_problems: [...], unreconciled_accounts:
|
||
[...], uncategorized_accounts: [...] }`. `since` selects entries by their
|
||
date; `since_seq` selects the entries posted after a journal line (the
|
||
`head_seq` of an earlier answer), backdated entries included, which a
|
||
date would miss (P4, defect 6). `as_of` (default today) bounds the
|
||
movement. An account the entries touched whose net movement is zero is
|
||
listed under `zero_net_accounts` rather than dropped silently.
|
||
`drafts_by_state` counts the open drafts; `reconciliation_tracked` and
|
||
`never_reconciled` split the cash and card accounts (`books.cash_accounts`)
|
||
by whether a statement was ever recorded for them. Each entry's
|
||
`receipt_required` is the engine's one decision
|
||
(`ledger_core::evidence::requirement`): a document's entry, a close, a
|
||
depreciation run and a reversal need none of their own.
|
||
The human review surface: "what did the AI do with my books lately." The
|
||
digest is the answer to "trust, but verify." `stale_rules` lists legal
|
||
amounts wanderbooks may no longer know as of today (`{ name,
|
||
known_through, source, why }`): research the current value or
|
||
ask the human, then update it upstream (pull request or issue at
|
||
github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash
|
||
and card accounts due for reconciliation (`{ code, name,
|
||
reconciled_through?, due_through, next_step }`): the cash accounts
|
||
(`[books] cash_accounts`, else asset accounts named for cash: cash,
|
||
bank, checking, savings, money market), the card accounts (the
|
||
business-card payment methods' liabilities, else liabilities named
|
||
"card", not "gift", before their first statement:
|
||
`ledger_core::parties::card_accounts`), and every other liability
|
||
with a recorded statement, that have posted activity through the end of
|
||
last month but no reconciliation (not voided) through it. The viewer's
|
||
Needs attention uses the same rule (`ledger_store::reconcilable`).
|
||
`uncategorized_accounts` lists every revenue and expense account (not a
|
||
header) with no tax category, active or not, `"CODE Name"`: the list
|
||
Needs attention shows (rules.md B-32).
|
||
|
||
## Tools — fixed assets
|
||
|
||
Straight-line **book** depreciation only; tax depreciation stays with the
|
||
CPA. Depreciation accrues by the day (rules.md B-15): a partial first
|
||
month, the life's final month (which takes what the others leave, evenly
|
||
over its days), and any month a schedule stops in (through a date, or a
|
||
disposed asset's disposal day, held) are prorated by days, never plugged;
|
||
the lifetime total is exactly (cost − salvage), rounded to the currency's
|
||
minor unit. A new asset's cost and salvage are in the minor unit, and its
|
||
life's last day is 9999-12-31 at the latest.
|
||
|
||
- `books.register_asset { description, in_service_date, cost,
|
||
salvage_value?, useful_life_months, cost_account, accum_depr_account,
|
||
expense_account, tag?, serial?, acquisition_entry?, basis_rule?,
|
||
contributor_adjusted_basis?, fmv_at_contribution?, sec704c_method?,
|
||
basis_source? }` → `{ asset_id, tag, in_service_date, acquired_date,
|
||
basis?, notes? }`. Accounts resolved by id or code. Without `tag`, the
|
||
next free `PREFIX-0001` (`[books] asset_tag_prefix`, else the company
|
||
name's initials without LLC/Inc; "FA" when none). `in_service_date` (the
|
||
day it is ready and available for its use) starts depreciation (P3,
|
||
defect 13; B-15); `purchase_date` is its alias for one release (both
|
||
given and meaning different days: refused; the alias noted in `notes`). The
|
||
`acquisition_entry` (posted, debiting the cost account, neither a
|
||
reversal nor reversed, dated on or before the in-service date) gives
|
||
`acquired_date`, which puts the asset
|
||
on the register; with none, the acquired date is the in-service date.
|
||
The basis facts are `books.set_asset_tax_basis`'s (`tax_basis_rule` and
|
||
`contributor_basis`, with the old rule names, are aliases for one
|
||
release); a bad one registers nothing, and so do basis facts given
|
||
without `basis_rule`.
|
||
- `books.label_asset { asset, tag?, serial?, clear_serial?, description?,
|
||
acquisition_entry? }` → `{ asset_id, tag, serial, acquisition_entry,
|
||
description }`: set or change the tag (unique ignoring case, never reused
|
||
by another asset even after a retag or disposal), the manufacturer's
|
||
serial, the description, or the linked acquisition entry (posted,
|
||
debiting the cost account, not reversed). Refused with nothing to
|
||
change, or with both `serial` and `clear_serial`. `asset` is the id or current tag. Journaled as `asset.tagged`,
|
||
`asset.serial_set`.
|
||
- `books.asset_labels { assets?, skip? }` → `{ file, tags }`: an Avery
|
||
5160/8160 label sheet (PDF), handed over (`file` a `HandedFile`, D-46):
|
||
company, tag, QR code of the tag, description, serial. Default: every active tagged asset; `skip`
|
||
reuses a partly used sheet.
|
||
- `books.asset_schedule { asset_id | tag, through? }`
|
||
→ `{ asset_id, description, cost, salvage_value, useful_life_months,
|
||
status, schedule: [{ period, amount, accumulated, book_value }] }`;
|
||
a disposed asset's ends on its disposal day. A schedule an earlier
|
||
version's asset can't compute is a tool error saying why, never a
|
||
panic.
|
||
- `books.list_assets { as_of? }` → `{ as_of, assets }` (default today, the
|
||
company's time zone): per asset `in_service_date`, `acquired_date`,
|
||
`cost`, `status`, `held` (acquired by `as_of` and not disposed),
|
||
`accumulated_posted` (what the books carry: its depreciation runs, less
|
||
reversals, a reversal of a reversal reinstating),
|
||
`accumulated_scheduled_through` (what the schedule says is due by
|
||
`as_of`), `unposted_depreciation` (the difference for this asset, its
|
||
schedule less its runs: run `books.run_depreciation`; the register's
|
||
total `unposted_depreciation` compares the schedules with the general
|
||
ledger instead, so a hand entry in accumulated depreciation shows there,
|
||
as `unattributed_accumulated`, not here), `book_value` (cost less
|
||
posted: what the books carry), and `basis` (as
|
||
`books.set_asset_tax_basis` reports it). `cost` is always the
|
||
capitalized cost; `held` says whether the books carry it. Disposed by
|
||
`as_of`: every other figure 0.00; acquired after `as_of`: those figures
|
||
null. The rows are the register's (`fixed_asset_register`). An
|
||
asset whose figures can't be computed (one an earlier version took)
|
||
lists with them null and `schedule_error` saying why: one bad asset
|
||
never fails the list.
|
||
Before P3, `accumulated` was the schedule's figure and `book_value`
|
||
cost less it.
|
||
- `books.set_asset_tax_basis { asset, rule, contributor_adjusted_basis?,
|
||
fmv_at_contribution?, sec704c_method?, basis_source? }` → `{ asset_id, basis:
|
||
{ rule, contributor_adjusted_basis, fmv_at_contribution, fmv_recorded,
|
||
tax_basis_723, depreciable_basis, built_in_gain_704c, sec704c_1c_note?,
|
||
sec704c_method, source?, sources }, notes? }`. `rule`: `purchase` (the
|
||
company bought it: depreciable basis is its cost, IRC §1012),
|
||
`carryover_723` (a member contributed business property: the company's
|
||
§723 basis is the member's adjusted basis, depreciated from), or
|
||
`personal_use_conversion` (a member contributed personal-use property:
|
||
the §723 basis as for carryover, depreciable basis the lesser of fair
|
||
market value and that basis, Treas. Reg. §1.168(i)-4(b); D-41). A
|
||
contribution needs `contributor_adjusted_basis` (cost plus improvements,
|
||
less depreciation allowed or allowable, IRC §1016(a)) and
|
||
`fmv_at_contribution` (a fact the human gives: never assumed from the
|
||
cost); `built_in_gain_704c` = value − §723 basis at contribution (Treas.
|
||
Reg. §1.704-3(a)(3)(ii)), with the IRC §704(c)(1)(C) note when it is a
|
||
loss. `sec704c_method`: `traditional`, `traditional_with_curative`,
|
||
`remedial` (Treas. Reg. §1.704-3(b)-(d)). `basis_source` `{ decided_by,
|
||
decided_on?, citation?, note? }`: who gave the facts. `sources` names
|
||
each figure's authority. The figures are at contribution or purchase;
|
||
tax depreciation and what remains of a built-in gain later are the
|
||
CPA's. The old names (`cost`, `carryover`, `contributor_basis`) are
|
||
aliases for one release, noted. Journaled as `asset.basis_set` (the
|
||
journal feature `asset_basis`: books an older version wrote refuse it,
|
||
`format_upgrade_pending`, until the owner's format upgrade); a record
|
||
written before P3 (`asset.tax_basis_set`) reads its value as the book
|
||
cost (`fmv_recorded: false`).
|
||
- `books.correct_asset { asset, cost?, salvage_value?, useful_life_months?,
|
||
in_service_date?, reason }` → `{ asset_id, tag, cost, salvage_value,
|
||
useful_life_months, in_service_date, acquired_date, basis, notes? }`.
|
||
Refused while the books carry depreciation for it (a run posted and not
|
||
reversed), for a disposed asset, for a blank `reason`, and for an
|
||
in-service date before its acquisition entry's date; `in_service` is the
|
||
alias of `in_service_date` for one release.
|
||
A recorded value at contribution, and so the built-in gain, never
|
||
moves; a basis recorded before P3 has none and reads it as the book
|
||
cost, so a cost correction moves its built-in gain, and `notes` says so.
|
||
- `books.run_depreciation { period: "YYYY-MM", asset_id? }`
|
||
→ `runs: [{ asset_id, description, period, amount, caught_up, entry_id,
|
||
already_posted }]` and `skipped: [{ asset_id, description, reason }]`.
|
||
A run posts what the schedule says is due through the month's end, so a
|
||
month skipped or reversed is caught up in the month run (`caught_up`:
|
||
the part beyond the month's own; a material miss is a restatement for
|
||
the CPA, ASC 250), and never more than the schedule at a later month
|
||
end. What the books carry is read entry by entry, each in its own
|
||
month, so a month whose run was reversed after it ended posts nothing
|
||
(it carried the month then; the run of the reversal's month catches it
|
||
up). Idempotent per asset and period. Nothing due (the
|
||
month rounds to nothing, a later run caught it up, the life is over and
|
||
posted) posts nothing and is listed under `skipped` with why, as are a
|
||
run-all's assets not yet in service and any whose schedule can't be
|
||
computed. A run before the in-service date is refused.
|
||
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
|
||
gain_loss_account, buyer?, confirmed?, preview?, receipt_attachment_ids? }` (the
|
||
date never before the acquired date) →
|
||
without confirmation: `{ status: "preview", asset_id, entry_id (0 in a
|
||
preview), accumulated, book_value,
|
||
proceeds, gain_loss, final_depreciation, excess_depreciation,
|
||
catch_up_depreciation, lines, receipt_required }` and posts nothing;
|
||
with `confirmed: true`: auto-posts the final depreciation (through the
|
||
disposal day, held), then posts the balanced disposal entry. Book value
|
||
is cost less the schedule through the disposal day, whatever the runs
|
||
posted: depreciation a run posted past it (the disposal month's run is
|
||
dated its end) is credited back to depreciation expense in the disposal
|
||
entry (`excess_depreciation`); depreciation still due when the disposal
|
||
month already has a run is debited there (`catch_up_depreciation`); the
|
||
entry clears everything the runs carry. Refused, at the preview already,
|
||
unless every run carries at the end of the disposal's month what it
|
||
carries now: a run of a later month that stands (reverse it first), or
|
||
a reversal (or re-instatement) of an earlier run dated after the
|
||
disposal's month that a later one hasn't undone (undo it, on any open
|
||
day). Subject to receipt policy (bill
|
||
of sale); monthly depreciation is exempt as an allocation. `buyer` (a
|
||
party) is the disposal entry's party; under `[books] parties =
|
||
"require"` a sale with proceeds without one is refused before anything
|
||
posts (`party_required`); a retirement with no proceeds and no buyer is
|
||
marked as having no counterparty.
|
||
- `books.export { report: "fixed_asset_register", format: json | csv,
|
||
as_of? }`: every asset acquired by the date (tag, serial, acquired and
|
||
in-service dates, cost, accumulated depreciation scheduled and posted,
|
||
book value as cost less posted, disposal, the basis facts and figures:
|
||
rule, contributor's adjusted basis, value at contribution and whether
|
||
it was recorded, §723 basis, depreciable basis, §704(c) built-in gain,
|
||
method, who gave the facts), totals (`total_accumulated_scheduled`,
|
||
`total_accumulated_posted`, `total_book_value`, `total_tax_basis_723`,
|
||
`total_depreciable_basis`, `missing_basis`), and the general ledger's
|
||
balances of the assets' cost and accumulated depreciation accounts, with
|
||
`unposted_depreciation` (scheduled less books), `unattributed_accumulated`
|
||
(books less the runs: an entry made by hand) and `unregistered_cost`
|
||
(zero when register and books agree; `ledger_core::fixed_asset_register`).
|
||
CSV columns: `tag, description, serial, acquired, in_service, cost,
|
||
salvage, useful_life_months, accumulated_scheduled, accumulated_posted,
|
||
book_value, status, proceeds, basis_rule, contributor_adjusted_basis,
|
||
fmv_at_contribution, fmv_recorded, tax_basis_723, depreciable_basis,
|
||
built_in_gain_704c, sec704c_1c, sec704c_method, basis_source`, then a
|
||
TOTAL row; `sec704c_1c` is `true` for a built-in loss (IRC
|
||
§704(c)(1)(C)), blank without a contribution basis.
|
||
- `books.show { asset }` opens an asset in the viewer; `page: "assets"`
|
||
and `report: "fixed_asset_register"` open the register.
|
||
|
||
## Tools — schedules and recurring entries
|
||
|
||
Amortization spreads a prepaid expense (asset → expense) or deferred
|
||
revenue (liability → revenue) over the time it covers, posting on each
|
||
month end, to the minor unit (rules.md B-16). A new schedule's convention
|
||
is `books.schedule_convention` (unset: `daily`, D-11/D-37): `daily`
|
||
spreads by the day from `start`, the first day covered, as depreciation
|
||
does, so a mid-month start posts a small first and last month (a 12-month
|
||
policy from March 15 is thirteen month ends: 54.84, eleven of 100.00,
|
||
45.16 for 1,200.00); `monthly_even` is equal months, the last absorbing
|
||
rounding, and starts on the 1st (D-37: revenue is never earned early,
|
||
ASC 606-10-25-23). Schedules registered before P3 stay `monthly_even`
|
||
(mid-month starts included), forever. Like
|
||
depreciation, the schedule is the evidence: its postings are exempt from
|
||
the receipt policy (the original payment carries the receipt). Recurring
|
||
entries never post: they generate drafts, so every real receipt still goes
|
||
through the policy.
|
||
|
||
- `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue",
|
||
description, total, start, months, balance_account, pl_account,
|
||
convention?: "daily" | "monthly_even" }` → `{ schedule_id, ...,
|
||
convention, start, end, next, total, remaining, months: [{ date, amount,
|
||
posted }], plan_error }`. Prepaid needs an asset and an expense account;
|
||
deferred revenue a liability and a revenue account. `start` is the
|
||
first day covered (for a monthly_even schedule an earlier version took
|
||
mid-month, the 1st of its month), `end` the last day covered, `next`
|
||
the next month end to post (null once every month has). Refused: a
|
||
convention but those two (naming them); a `monthly_even` schedule not
|
||
starting on the 1st (naming the 1st, and `daily`); one whose months
|
||
aren't whole minor units (yen); `months` outside 1 to 600; one whose
|
||
last day is after 9999-12-31; and, in books an older version wrote, a
|
||
daily one (it needs the journal feature `schedule_convention`:
|
||
`format_upgrade_pending` until the owner's format upgrade). `plan_error` is null, except for a schedule an earlier
|
||
version accepted whose months run past the last date the books can
|
||
compute: then it says why, `remaining` and `next` are null and `months`
|
||
empty, and `books.run_schedules` refuses until it is dealt with.
|
||
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
|
||
- `books.run_schedules { through?, confirmed?, preview? }` → `{ status: "preview" |
|
||
"posted", postings: [{ schedule_id, date, description, amount,
|
||
entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every
|
||
unposted month ending on or before `through` (default today).
|
||
Idempotent; a month reversed within its month is due again; a month
|
||
dated in a closed period, or whose posting was reversed after the month
|
||
ended (posting it again would recognize it twice until then), is left
|
||
and listed under `skipped` with why (book it in an open period by hand,
|
||
or not at all), never a failed run.
|
||
- `books.create_recurring { description, frequency: "weekly" | "monthly" |
|
||
"quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }`
|
||
→ `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and
|
||
longer occurrences are computed from the start date each time, so a
|
||
month-end start stays on month ends (Jan 31, Feb 28, Mar 31).
|
||
- `books.list_recurring {}` → `{ recurring: [...] }`.
|
||
- `books.run_recurring { through? }` → `{ drafts: [{ recurring_id, date,
|
||
entry_id }], skipped_closed_period: [{ recurring_id, date }] }`: one
|
||
draft per occurrence on or before `through`, once; an occurrence dated
|
||
in a closed period is not generated and is listed in
|
||
`skipped_closed_period` for the human to decide.
|
||
|
||
## Tools — the viewer
|
||
|
||
- `books.show { entry_id | account | report | attachment_id | asset |
|
||
party | project | reconciliation | search | page, start?, end?, columns?,
|
||
compare? }`
|
||
→ `{ shown, viewer_open, 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 project (by id or name: viewer target `{show:
|
||
"project", class_id}`; page `projects` its list), a reconciliation (by id: its account's page, with its
|
||
statements and reconciliations; viewer target `{show:
|
||
"reconciliation", id}`), a transactions search, or a page (`parties`
|
||
among them). Exactly one target; entries, accounts, attachments,
|
||
assets, parties, and reconciliations are checked to exist. Never
|
||
journaled: the request is a line `{seq, at, target}` of the books' own
|
||
`ops/show.jsonl` (at most 32 lines, replaced whole, so a reader sees
|
||
each line whole), which the viewer reads and never writes. The server
|
||
makes `ops/` and `ops/viewer.lock` when it opens the books (and on a
|
||
request); the viewer holds a shared lock on `ops/viewer.lock`, through
|
||
a read-only descriptor, while it has the books open, and
|
||
`viewer_open` says whether one does. The note says "The viewer shows
|
||
…", or "No viewer has these books open: ask the human to open it
|
||
(opened within a couple of minutes, it shows …)".
|
||
- **Dates (rules.md B-30).** For an account or a report, the dates are
|
||
resolved by the one default (omitted: the fiscal year to date; only
|
||
`end`: its fiscal year to it) and sent to the viewer resolved, so
|
||
`shown` and the screen can't differ: "the register of 1000 Business
|
||
Checking, 2026-01-01 to 2026-10-20", "the profit and loss report,
|
||
2026-01-01 to 2026-10-20", "the trial balance report, as of
|
||
2026-10-20" (trial balance, aging, fixed asset register, a balance
|
||
sheet in one total column), "the vendor 1099 report, for 2026". A
|
||
start after the end, given or derived, is refused and nothing is
|
||
sent.
|
||
- **Options a target ignores are refused**, naming what takes them:
|
||
`start`, `end`, `columns` and `compare` for any target but an account
|
||
or a report; `columns` and `compare` for an account; `columns` but for
|
||
`profit_and_loss`, `balance_sheet` and `cash_flow`; `compare` but for
|
||
those and `tax_summary`, and never with a split; `start` for a report
|
||
as of a day (the trial balance, the agings, the fixed asset register,
|
||
a balance sheet in one total column unless compared with the prior
|
||
period) or for `vendor_1099` (its year is `end`'s); and a split into
|
||
more columns than the viewer shows (`ops::MAX_REPORT_COLUMNS`, 240:
|
||
past it the viewer shows the total).
|
||
- The viewer takes a target's dates as sent; one it can't honour (text
|
||
that isn't a date, a start after the end from an earlier writer or a
|
||
hand-edited `ops/show.jsonl`, an unknown split) is ignored whole, as
|
||
an unknown page is.
|
||
|
||
## Tools — integrity
|
||
|
||
- `books.verify { blobs? }` → `{ status: "ok" | "healed", events, head,
|
||
healed?, blobs_verified?, database, other_databases?, checked }`
|
||
(`database`: the projection file verified, one for each schema,
|
||
`wanderbooks.v{N}.db`, or the older `wanderbooks.db` when it holds this
|
||
version's schema; `other_databases`: other schemas' files beside it,
|
||
left for the versions that use them). Verifies the journal's hash chain,
|
||
replays it against the live ledger, and compares every table of the
|
||
SQLite database row for row against a fresh projection of the journal.
|
||
A drifted database is rebuilt from the journal (the old file kept as
|
||
`wanderbooks.v{N}.mismatch.<time>`). With `blobs` (default true), re-hashes
|
||
every stored file. The server runs the same check, and heals the same
|
||
way, every time it starts.
|
||
|
||
## Tools — access
|
||
|
||
- `books.access_log { limit?, before?, token?, action?, outcome? }`
|
||
(books:admin; read-only) → `{ records, total, more, head: { seq, hash }
|
||
}`: who reached these books through the server (plan I-9), newest
|
||
first, each record its token, client, tool or resource, a digest of
|
||
its arguments and whether it ran (`ok`), failed (`error`) or was
|
||
refused. `limit` is 1 to 500 (default 50); `before` pages back (records
|
||
before that seq); `token` narrows to a token's name, `action` to `tool`,
|
||
`resource` or `connect` (refused at the door), `outcome` to `ok`,
|
||
`error` or `refused` (anything else is refused). `total` counts the
|
||
whole log; `more` says older records match too. The log is read whole
|
||
and its chain checked first: a damaged log is reported, never shown in
|
||
part.
|
||
|
||
## Tools — backups
|
||
|
||
Books back themselves up (plan 3.7, D-30,
|
||
D-31): each keeps `archive.git`, a bare repository whose commits are its
|
||
journal, stored files, vault, keys and access log as they were (one
|
||
commit per backup, made under the store's lock, its message
|
||
`seq N head H` and, below, `book <id>`), pushed fast-forward only to the targets the settings
|
||
name: `backup.git_remote` (the owner's private repository) and
|
||
`backup.folder` (a repository `<book>.git` in a folder on another disk).
|
||
Not encrypted: the destinations keep it private. Backups run a little
|
||
after the books are last written (`--backup-quiet`, 30 s), when they
|
||
open, before they close, and on request; a failed one is retried after
|
||
15 minutes. Each target is drilled weekly: cloned, its chain replayed,
|
||
every stored file re-hashed, every TIN record the books name there with
|
||
its key, its head the one its commit records, its journal a byte prefix
|
||
of the live one (a failed drill is tried again at the server's next
|
||
hourly look, once 15 minutes have passed). A backup's tree is checked
|
||
before any of it is written out: plain files under the backed-up paths
|
||
only, never a link or a submodule. git runs with no hooks (in a
|
||
repository on this computer it pushes to as well), no ignore or
|
||
attributes rules of anyone's (a `.gitattributes` in a book's folder
|
||
refuses the backup) and no command-running transports; a repository on
|
||
this computer is used only if plain (a bare repository at that very
|
||
path, no settings of its own but where a copy came from, nothing sending
|
||
git elsewhere, no link where it or its parts are), and a backup folder that took backups and is gone is
|
||
reported as a disk not connected, never made again (restored books count
|
||
the backups of the books they were). Books restored beside the books they
|
||
were (under another id) don't push to those books' repository, which
|
||
would stop theirs: `books.backup` says it is paused for them until
|
||
another is named. A damaged backup record is set aside and begun again;
|
||
it never keeps the books shut. One book's drill holds the others'
|
||
backups until it ends (a backup at close waits at most a minute for
|
||
it). A target
|
||
whose push failed is left out of the backups after writes for 15
|
||
minutes, then tried again, written to or not; the other targets don't
|
||
wait for it. Every
|
||
commit, push and drill is a record in the book's `ops/backup.jsonl` (a
|
||
chain log; a backup never moves the books' head), each target's records
|
||
keyed by the target itself. Setting `backup.*` (books.set_setting) needs
|
||
books:admin, as
|
||
backing up does: a backup is the whole book, its vault and key included.
|
||
|
||
- `books.backup {}` (books:read) → `{ head_seq, targets: [{ target:
|
||
"repository" | "folder", at, last_ok_at, through_seq, stale,
|
||
last_error, last_drill_at, drill_error, paused }], note }`. `paused`:
|
||
not backed up there, these books being restored as a copy of others
|
||
(its `last_error` says so). `stale`: the target
|
||
lacks the head, and the first try to back up what it lacks (since its
|
||
last good backup, or ever) is more than a day old. `last_error`: its
|
||
last push's, or a later failed commit's. `note`
|
||
says what to set when no target is. `books.start` adds a next step when
|
||
none is set up, or one is stale, failing, paused or failed its drill.
|
||
- `books.back_up_now { new_disk? }` (books:admin) → the same, after
|
||
backing up to every target now. `new_disk`: the backup folder's disk is
|
||
new, so a folder that took these books' backups and holds none now is
|
||
begun again (otherwise it is a disk not connected). The one tool whose
|
||
`openWorldHint` is true. A target paused for books restored as a copy
|
||
of others shows `paused` (and is no failure to retry).
|
||
|
||
Restoring is the owner's, from a terminal: `wanderbooks books restore
|
||
BACKUP [--id ID] [--replace] [--yes]` (the admin socket's `restore`,
|
||
previewed then confirmed against the previewed head; without `--id`, the
|
||
books keep the id of the books the backup holds, which the preview names,
|
||
else the repository's name; under another id they are a copy, and don't
|
||
push to those books' repository). The backup is
|
||
cloned into the root, proven whole as a drill proves it (but for the
|
||
live-journal check: there may be no live books), and built as an
|
||
adoption builds; it arrives as new books, or replaces books (`--replace`,
|
||
which are let go and moved whole into `legacy/<id>-<time>/`). The
|
||
restored books keep the backup's history, so their next backups carry
|
||
on from it.
|
||
|
||
## Tools — payouts
|
||
|
||
- `books.record_payout { date, description, deposit_account, deposit,
|
||
revenue: [{ account, amount, class? }], fees?: [{ account, amount,
|
||
class? }], adjustments?: [{ account, side, amount, class? }] }` →
|
||
`{ entry_id, status: "draft", gross, fees, adjustments, deposit, lines,
|
||
next }`. Builds the grossed-up entry — Dr deposit (net; Cr when negative),
|
||
Dr fees, Dr/Cr adjustments (a debit reduces the deposit: refunds,
|
||
chargebacks, reserves held, tax withheld), Cr revenue (gross; revenue or
|
||
liability accounts, e.g. deferred revenue or sales tax collected) — and
|
||
refuses unless gross − fees ± adjustments equals the deposit to the cent,
|
||
naming the difference. A draft: attach the payout statement, preview,
|
||
post. Platform-agnostic; format-specific statement parsers can feed it.
|
||
|
||
## Tools — 1099 tracking
|
||
|
||
What counts toward a vendor's 1099 is what left the books' cash accounts
|
||
(`books.cash_accounts`) and financing accounts (`books.financing_accounts`)
|
||
on entries tagged with the vendor, classified by how each payment was made
|
||
(rules.md B-19; plan P3, defects 12, 18 and 23; D-40):
|
||
|
||
- out of a cash account with no payment method recorded: counts
|
||
(presumed check, ACH or transfer; tag a debit-card payment with the
|
||
card);
|
||
- by card or payment network (a method of that kind, or a company card's
|
||
account): excluded, the processor's Form 1099-K, whoever's card it was
|
||
(Treas. Reg. §1.6041-1(a)(1)(iv));
|
||
- through a financing account (a member paid), or the account of a
|
||
member's or a platform's method, with no method recorded:
|
||
undetermined, until it is (their card: excluded; their own cash or
|
||
check: counts, the company being the payor, §1.6041-1(e)(1)).
|
||
|
||
Money moved between the company's own sources (the card bill, a member
|
||
reimbursed, a loan paid down) pays no one; money received from a vendor
|
||
for anything but a refund of what was paid (a sale, its payment of an
|
||
invoice, a disposal, a loan) isn't netted and is listed.
|
||
Books with no cash accounts named are refused. Thresholds: $600 for
|
||
payments through 2025, $2,000 from 2026 (Pub. L. 119-21 §70433; indexed
|
||
for inflation after 2026); always overridable, always for the CPA to
|
||
confirm. The books keep whether a W-9 is on file and the TIN's last four;
|
||
full TINs live only in the TIN vault ("Tools — the TIN vault").
|
||
|
||
Each vendor has a 1099 decision: `nec`, `misc`, `not_reportable` with a
|
||
reason kind (`corporation`, `tax_exempt`, `united_states`, `state`,
|
||
`foreign_government`, `foreign_person`, `merchandise`, `employee`, `other`)
|
||
and the facts in words, or `undetermined` (also what a vendor nobody
|
||
decided on reads as). Each is recorded with who decided and when, and the
|
||
date it takes effect, or none (from the beginning: the vendor's status
|
||
all along, and every decision written before decisions were kept). Each
|
||
payment takes the decision in force on its date (its reversal chain's
|
||
first entry's).
|
||
|
||
- `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?,
|
||
decision_1099, decisions_1099, tin_on_file, party_id }] }`.
|
||
`decision_1099` is the decision in force today, `{ value, reason_kind?,
|
||
reason?, authority?, effective?, decided_by?, decided_on?, citation?,
|
||
note? }` (`authority`: the rule that exempts the reason kind; no
|
||
`decided_by`: nobody decided); `decisions_1099` every recorded
|
||
decision, in date order; `form_1099` "1099-NEC" or "1099-MISC" when
|
||
today's decision files one.
|
||
- `books.set_vendor_1099 { vendor, form_1099?, reason_kind?, reason?,
|
||
effective?, tin_on_file?, decided_by?, decided_on?, citation?, note? }`
|
||
→ the vendor, with `changed` ("set", "removed", "unchanged") and
|
||
`warnings`. `vendor` is an id or a name. With `form_1099` (`nec`,
|
||
`misc`, `not_reportable`, `undetermined`), `decided_by` is required
|
||
(`decided_on` defaults to today); `not_reportable` takes `reason_kind`
|
||
and `reason` (`other` a `citation` too), no other value takes them.
|
||
"none" is refused, saying what to say instead; paid by card is never a
|
||
reason. `effective` (YYYY-MM-DD) only when the payee's status changed:
|
||
an LLC's corporate election from its Form 8832 date; a payee that
|
||
incorporates is a new vendor. A dated decision with nothing decided
|
||
before it is refused; one the same as the decision before its date
|
||
takes out any decision recorded for that date ("removed"); recorded
|
||
again exactly so (who decided and when included), nothing is journaled
|
||
but a changed W-9 flag ("unchanged"). Without `form_1099`, `tin_on_file`
|
||
alone marks the W-9, keeping the decision and who made it (`changed` is
|
||
then "unchanged", with no warnings); `reason_kind`, `reason` or
|
||
`effective` without `form_1099` are refused, and so is a call with
|
||
neither `form_1099` nor `tin_on_file`; `tin_on_file` omitted keeps the
|
||
flag. `warnings` names each
|
||
year (and its entries) whose payments the change reaches in a closed
|
||
period or after that year's 1099s were due (January 31 of the next
|
||
year): tell the human, a filed 1099 may need correcting. Journaled as
|
||
`vendor.1099_set` (a W-9 flag alone as `vendor.w9_flag_set`), under the
|
||
journal feature `vendor_1099_decisions` (books an older version wrote
|
||
refuse it, `format_upgrade_pending`, until their format upgrade: B-27).
|
||
- `books.add_instrument { kind, label, last4?, owner_party?, account }` →
|
||
`{ id, kind, card_or_network, label, last4?, owner_party?, account_id,
|
||
account, warnings }`. `kind`: `bank` (the company's checks, ACH, wires,
|
||
bill-pay), `business_card` (a company card the payee charged),
|
||
`member_card` (a member's personal card the payee charged),
|
||
`member_cash_or_check` (a member's own cash, check, ACH, Zelle, or a
|
||
friends-and-family transfer), `platform` (paid to the payee as a seller
|
||
through PayPal or Venmo goods and services; never ACH, bill-pay, Zelle or
|
||
friends-and-family). `account` (id or code) is what it pays through: the
|
||
bank account, the card's liability, Due to Members for a member's own
|
||
card or cash; an asset or liability, not a header, not payables or
|
||
receivables. `label` is unique (ignoring case); `last4` exactly four
|
||
digits. Journaled as `instrument.added` (feature `payment_instruments`).
|
||
- `books.instruments {}` → `{ instruments: [...] }`, by id. Read-only.
|
||
- `books.set_entry_instrument { entry_id, instrument? }` → the entry (as
|
||
`books.get_entry`), with `changed` and `warnings` (as above). An id or
|
||
label; omit to clear. Metadata only: drafts and posted entries alike;
|
||
the entry and its reversals are tagged together. Journaled as
|
||
`entry.instrument_set`.
|
||
- `books.set_entry_vendor { entry_id, vendor? }` → the entry (as
|
||
`books.get_entry`, which shows `vendor`, and `instrument` and
|
||
`instrument_id`). Omitting `vendor` clears the tag. On an entry naming
|
||
no party or one, the vendor's party becomes its party (clearing a
|
||
no-party mark), and clearing the tag clears the party too when it was
|
||
the vendor's. Metadata only:
|
||
posted entries may be tagged. `books.draft_entry` takes `vendor` and
|
||
`instrument` 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. 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?, csv?,
|
||
cash_accounts?, financing_accounts?, through? }` →
|
||
`{ year, threshold, vendors: [{ vendor_id, name, form_1099?,
|
||
decision_1099, decisions_in_year, tin_on_file, paid, excluded_card,
|
||
undetermined, not_reportable, status, decision_needed, reportable,
|
||
party_id?, tin_last4?, tin?, tin_type?, tin_note? }], missing_w9,
|
||
excluded_card_payments, undetermined_payments, other_receipts,
|
||
untagged_entries, unresolved, notes, warning?, csv? }`. `paid` is what
|
||
counts toward the 1099; `excluded_card` what a card or network paid;
|
||
`undetermined` what waits on its payment method; `not_reportable` what
|
||
was paid while the vendor was decided not reportable. `status`:
|
||
`reportable` (file one: what counts reaches the threshold however the
|
||
unknowns settle), `under_threshold`, `not_reportable`, or
|
||
`undetermined` (a decision or a method is needed first;
|
||
`decision_needed` says which); `reportable` is `status = reportable`.
|
||
Each payment list is `[{ entry_id, date, vendor_id, vendor, amount,
|
||
instrument? }]`. `missing_w9` names the vendors a 1099 is or may be
|
||
("(may be reportable)") due for without a W-9. The notes are quiet:
|
||
untagged entries are one count (those that paid money out for
|
||
something) with the tool to tag them. 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. Payments are counted out of `cash_accounts` and
|
||
`financing_accounts` (account codes; each defaults to the `[books]`
|
||
setting, the CPA package's rule: `ledger_package::Settings::with_accounts`;
|
||
a code the chart lacks is refused), from January 1 of `year` through
|
||
`through` when given (`YYYY-MM-DD`, a day of `year`: the worksheet as of
|
||
a day, rules.md B-33), else the whole year. On an entry naming several
|
||
parties, the buckets count only the vendor's resolved share of the
|
||
payment, split across them to the cent (rules.md B-19); an entry whose
|
||
shares don't resolve counts in full and `notes` names it. `tin_last4`
|
||
is the party's TIN facts' last four, else its W-9 facts'
|
||
(`Party::tin_last4`). 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 (not in the vault, the vault locked, a record
|
||
whose last four or hash disagree with the books); `warning` then says
|
||
"This worksheet holds full TINs, for the CPA; keep it out of the
|
||
books.". This is the only tool output that ever holds a full TIN.
|
||
`csv: true` (default false) also hands the worksheet over as a CSV file,
|
||
`csv` (a `HandedFile`, "Tools — the TIN vault"), named
|
||
`1099-worksheet-<year>.csv`, or `1099-worksheet-<year>-with-tins.csv`
|
||
with `include_tins` (TINs only then): `vendor_id, name, decision_1099,
|
||
form_1099, reason_kind, reason, decided_by, decided_on, effective,
|
||
decisions_in_year, paid, excluded_card, undetermined, not_reportable,
|
||
status, reportable, w9_on_file, tin_last4` (and `tin, tin_type,
|
||
tin_note`). The CPA package carries the same CSV: one list of cash
|
||
accounts and one of financing accounts per package, the request's, else
|
||
the settings', for every file in it (R18), the 1099 worksheet through
|
||
the package's `as_of`; so `books.vendor_1099` given the package's
|
||
`cash_accounts`, `financing_accounts` and `through` = `as_of` (and its
|
||
`include_tins`) hands over the package's `1099-worksheet-<year>.csv`
|
||
byte for byte (the tool names one with TINs `-with-tins`; checked by `ledger-cli` `oracle_mcp.rs`
|
||
`cpa_package_holds_the_year_and_its_evidence`).
|
||
|
||
## 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`, `government_agency` (regulators and
|
||
registries that don't tax: a division of corporations, a licensing board),
|
||
`financial_institution`, `landlord`.
|
||
Vendor and customer follow the records and are never set by hand. A
|
||
`tax_agency` or `government_agency` payee also classes the company's
|
||
payments to it on the cash flow statement: other operating cash
|
||
payments, never cash paid to suppliers (`books.cash_flow`, rules.md B-14
|
||
rule 5).
|
||
|
||
**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?, email?, default_terms_as_customer?, default_terms_as_vendor?,
|
||
phone?, contact?, billing_address?, shipping_address?, billing_email?,
|
||
archived?, creditor_of? }` → the party. Give at least one field; `""`
|
||
clears `display_name`, `website`, `address`, `notes`, `email`, the terms,
|
||
`phone`, `contact`, `billing_address`, `shipping_address` or
|
||
`billing_email`; `creditor_of` (account codes or ids) replaces the
|
||
party's list of the liabilities the company owes it itself (below).
|
||
`email` is its general address; `billing_email` is where its bills go
|
||
(an invoice's bill-to email, when set, else the general one). `address`
|
||
is its general/business address; `billing_address` where its bills go
|
||
(the bill-to address when set, else the general one) and
|
||
`shipping_address` where goods go; `contact` is the person there to
|
||
write to and `phone` its number (D-55). The terms are trade terms per
|
||
side (`"Net 30"`, `"2/10 net 30"`): a document's due date falls back to
|
||
them (below). 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, email, the two terms,
|
||
phone, contact, billing_address, shipping_address, billing_email,
|
||
archived, then one `party.creditor_set` per account added or dropped.
|
||
The email, terms and contact block need the journal feature
|
||
`documents_v2` (rules.md B-27): books that haven't declared it refuse
|
||
them with `format_upgrade_pending`, journaling nothing. 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, tin?, tin_type?, w9_upload_id?,
|
||
w9_file_name? }` 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.w9_flag_set`, its 1099 decision and who made it
|
||
kept), so `books.vendor_1099`'s `missing_w9` follows.
|
||
`tin` and `tin_type` put the full TIN in the TIN vault, and the signed
|
||
form (`w9_upload_id`, as in "Tools — receipts & documents") goes there
|
||
too (below); with a TIN,
|
||
`tin_last4` comes from it (a given one must agree); with a TIN or the
|
||
form, `kept_at` defaults to `"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?, 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"`. Once the entry is posted (at `post_entry`, or tagging a
|
||
posted entry), a derived split among several parties is journaled again
|
||
as the exact amounts it resolves to (a second `entry.parties_set`), so a
|
||
later change to `[[company.members]]` never re-splits posted history; it
|
||
stays derived when the shares don't resolve or a part is zero. 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:
|
||
`party` with `parties`, or either with `no_party_reason`; a share giving
|
||
both `amount` and `percent`; 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?, basis? }` → `{ year,
|
||
basis, parties: [{ party_id, name, label, roles, vendor_id?,
|
||
customer_id?, w9_on_file, tin_last4?, w4_on_file, archived, paid,
|
||
received }] }`, by id. `tin_last4` is the TIN's last four when the books
|
||
know it: its TIN facts' (`books.set_tin`; a member's SSN for the K-1),
|
||
else its W-9 facts' (the two never disagree), null with neither, kept
|
||
after `books.purge_tin` (P3 defect 11: it read W-9 facts only, so a
|
||
member's showed as missing).
|
||
- `books.party { party, year?, basis? }` → `{ party, year, totals,
|
||
entries, invoices, bills, payroll, projects }`: 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 }`); and the projects for
|
||
or with it, by name (`{ id, name }`, rules.md B-37).
|
||
- `basis` (both): `"cash"` (the default) or `"all"`; any other word is
|
||
refused (`basis is "cash" or "all"`). Echoed in the output. Not the
|
||
books' accounting method (`books.accounting_basis`).
|
||
|
||
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, in_vault }, w4?: { received_on,
|
||
state, kept_at }, tin?, creditor_of }`. `label` is the display name, else
|
||
the name; `roles` include vendor and customer when it has those records.
|
||
`creditor_of` lists, by code, the liability accounts the company owes the
|
||
party itself on: the card it issued, the loan it made, what the members
|
||
advanced (each member). `books.update_party { creditor_of: [codes] }`
|
||
replaces the list (`[]` clears it), journaled as `party.creditor_set {id,
|
||
account_id, on}`, one line per account added or dropped, under the
|
||
journal feature `party_creditors` (books an older version wrote refuse
|
||
the whole change, `format_upgrade_pending`, until their format upgrade:
|
||
B-27); refused, journaling nothing, for an account that isn't a
|
||
liability, is a placeholder, or is payables or a payroll withholding
|
||
(owed to many).
|
||
|
||
**Each party's part of an entry** (`books.get_entry`, the
|
||
`books.post_entry` preview, `books.set_entry_party`: `parties: [{ party_id,
|
||
party, share, amount?, percent?, resolved, direction, cash, in_kind }]`,
|
||
rules.md B-35):
|
||
|
||
- `resolved`: signed as a line on the party's own account would be
|
||
(debit-positive): positive, the company paid it or gave it value;
|
||
negative, the company received from it. A reversal's is exactly its
|
||
original's negated, never read from its own lines.
|
||
- `direction`: `paid`, `received` or `neither` (a promise not yet settled:
|
||
a bill, an invoice, a fee on the party's own card, a distribution still
|
||
owed; or an entry that moved nothing with anyone, like a close).
|
||
- `cash`: what of it moved as money; `in_kind`: what moved as property.
|
||
Both `"0.00"` for `neither`; `resolved − cash − in_kind` is a promise,
|
||
counted on neither basis. All four are null exactly when the shares
|
||
don't resolve (`parties_error` says why).
|
||
- What the shares split (B-35 item 3): a close, its parties' own-account
|
||
movement, neither; a reversal, its original's negated; an entry naming
|
||
only members that moves equity, what moved their capital (a
|
||
contribution received: the property in it, asset accounts that are
|
||
neither cash nor receivables, the K-1 worksheet's test, in kind and the
|
||
rest cash; a distribution paid: the money it paid as cash, the property
|
||
it gave in kind, the rest still owed, counted when it is paid); money
|
||
moved, the money (paid or
|
||
received); otherwise the parties' own-account movement (payables,
|
||
receivables, a liability owed to them: a bill −, an invoice +), else the
|
||
total debits, neither.
|
||
|
||
**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* (B-35 item 5, P3 defect 24): the `[books]
|
||
cash_accounts` (every asset account but receivables when none are set),
|
||
the cards (the business-card payment methods' liability accounts,
|
||
`books.instruments`; while the books record none, the liability
|
||
accounts whose names say "card" and not "gift"; never a member's
|
||
card or a platform's account: the one rule the reconciliation reminders
|
||
and the viewer read), and the `[books]
|
||
financing_accounts` (what the members advanced, loans). Any other
|
||
liability (an accrued expense, deferred revenue, gift cards sold,
|
||
distributions payable) is a promise, counted when it is settled, never
|
||
also when it is booked. A liability owed to a party the entry names (`creditor_of`)
|
||
isn't money for that entry: a credit to it never counts as paying the
|
||
party.
|
||
- `paid` / `received`: each entry's part for the party, added up on its
|
||
root original's side (a reversal subtracts there, and reversing the
|
||
reversal adds back): on basis `cash`, the `cash` parts; on basis `all`,
|
||
`cash` plus `in_kind`. A cash payment, a card charge, a member paying
|
||
for the company: paid to the payee then; cash paying one of the money
|
||
liabilities down: paid to its creditor (a reimbursement to the members,
|
||
a card payment to the issuer); cash raised by one: received from the
|
||
lender. A bill or invoice counts on neither basis (a promise is not
|
||
payment); a transfer between the company's own cash accounts nets to
|
||
zero. An entry naming several parties counts this party's part only (an
|
||
entry whose shares don't resolve counts for none). For a member, basis
|
||
`all` over the entries that name only members and move their capital
|
||
(and the payments of distributions declared to them) is the statement
|
||
of members' equity's contributions and distributions, and basis `cash`
|
||
their contributions is the K-1 worksheet's cash contributions (B-35
|
||
item 4 gives the conditions: a distribution declared and not yet paid
|
||
is in the statement only). The year is the whole
|
||
calendar year; the viewer's party page and directory show these same
|
||
numbers, labelled "cash basis", with the in-kind figures beside them.
|
||
- `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
|
||
toward its vendor record's 1099 in the year (its `paid`: card and
|
||
network payments excluded, member payments with no method recorded
|
||
undetermined; absent when the worksheet can't be made); `form_1099` and
|
||
`decision_1099` are the record's decision in force today.
|
||
- `gross_pay` / `net_pay`: payroll lines naming it, in the year.
|
||
|
||
## Tools — the TIN vault
|
||
|
||
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
|
||
(the owner, 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
|
||
(XChaCha20-Poly1305) and bound to its party. **Key:** the file named by
|
||
`[vault] key_file` in `wanderbooks.toml`, else
|
||
`~/.config/wanderbooks/vault.key` (`$XDG_CONFIG_HOME`), mode 0600,
|
||
created the first time a TIN or form is stored, never when reading. A
|
||
key file inside the books dir is refused, and so is storing into books
|
||
kept in git (a `.git` beside them) whose `.gitignore` doesn't list
|
||
`vault/`. Without the key the vault is
|
||
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 CPA
|
||
package carries them only in its zip, handed over as an export, never
|
||
written to a path.
|
||
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_upload_id?, w9_file_name? }`
|
||
(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). 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 (a PDF, PNG or JPEG, judged by its own first bytes,
|
||
whatever it is called; an upload of purpose `w9`, at most 25 MiB) goes
|
||
to the vault only, never the attachment store. Its file name is
|
||
`w9_file_name` if given, else the upload's, checked as any filed name; `w9_file_name` without a W-9 is refused. 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 (pass the new TIN, or purge). Clearing
|
||
the facts (`clear: true`) is refused while the vault holds the party's
|
||
TIN or form: purge first.
|
||
- `books.get_w9 { party }` → `{ party_id, file }`: the party's form from
|
||
the vault, handed over. Journals nothing.
|
||
- **Handed files** (D-29, D-46): no tool takes a path, and no tool gives
|
||
a file's bytes back. Every file an agent takes away (`books.get_w9`'s
|
||
form, `books.vendor_1099`'s `csv`, `books.export_cpa_package`'s zip,
|
||
`books.export` in every format, `books.asset_labels`' sheet,
|
||
`books.get_receipt` view `bytes`) comes back as a `HandedFile {
|
||
file_name, mime_type, size_bytes, sha256, download, expires_at }`: an
|
||
export, kept 15 minutes for the token that asked (at most 64 per
|
||
token, swept like uploads), and `download` is the command that saves
|
||
it in a folder the human names: `wanderbooks download --book B
|
||
[--token NAME] ex_… --to <a folder>` (`GET /b/{book}/x/{export}`,
|
||
"Transports"). Its bytes never come back in the answer (D-44, D-46).
|
||
A resource read is text only (JSON, Markdown): `books://receipts/{id}`
|
||
is the receipt's meta, never its bytes.
|
||
A file with full TINs never goes into the books: the server keeps, beside the vault, the SHA-256 of every
|
||
file it read or wrote with full TINs (the W-9 `books.set_w9` reads,
|
||
recorded once the call has passed every check, just before the vault
|
||
keeps it, so a refused call marks no file; each of these outputs; each
|
||
file inside a CPA package that holds a TIN), shared by every session and
|
||
kept across restarts, and every filing tool (`books.attach_receipt`,
|
||
`books.file_document`) refuses those exact files and any text file (CSV,
|
||
TSV, text, JSON, IIF, XML, HTML, Markdown, by type, name or UTF-8 or
|
||
UTF-16 decoding, UTF-16 with a byte-order mark or NUL-interleaved
|
||
without one) holding a full TIN, storing nothing. A text file holds one
|
||
when it has a TIN the vault holds, anywhere, as nine digits, 3-2-4
|
||
joined by hyphens, spaces or dots, or 2-7 joined by a hyphen or a space
|
||
(compared on the server; a vault that can't be opened leaves the next
|
||
test alone), or a stand-alone number printed as an SSN or EIN is
|
||
(123-45-6789, 12-3456789; no digit, and no hyphen or dot followed by a
|
||
digit, beside it). Bank exports pass: routing and account numbers, long
|
||
references and dates next to amounts are not TINs (rules.md B-26). No
|
||
probing: a text with more than two TIN-shaped numbers ending in the last
|
||
four of a TIN the books know is refused before the vault is opened, in
|
||
the same words whether or not one is that TIN, so the vault is compared
|
||
against at most two candidates a filing (rules.md B-26). An altered copy
|
||
of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault
|
||
doesn't hold written another way, are not detected: P7 takes full
|
||
TINs out of the agent's reach.
|
||
- `books.purge_tin { party, reason, confirmed?, preview? }` → `{ status: "preview" |
|
||
"purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?,
|
||
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;
|
||
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
|
||
|
||
A stored file (a receipt or a document) links to any number of subjects;
|
||
a subject is `{ kind, id? }`: `entry` (id), `account` (code or id),
|
||
`asset` (tag or id), `party` (id or name), `project` (id or name: its
|
||
contract, spec, pitch; journal feature `projects`), `invoice`, `bill`, `period`,
|
||
`payroll_run`, `schedule`, `reconciliation` (ids), or `company` (no id).
|
||
Every subject is checked to exist. In outputs a subject is `{ kind, id?,
|
||
label }` (`id` absent for the company; for an account, its id, with the
|
||
code in `label`). A file linked to an entry is one of the entry's receipts:
|
||
the receipt policy counts it.
|
||
|
||
- `books.link_attachment { attachment_id, subject }` → `{ attachment_id,
|
||
links }`. Journaled as `attachment.linked`. Refused for an unknown or
|
||
withdrawn attachment, a subject that doesn't exist, or one it is linked
|
||
to already (or filed on).
|
||
- `books.unlink_attachment { attachment_id, subject, reason }` → `{
|
||
attachment_id, links }`. Journaled as `attachment.unlinked` with the
|
||
reason; the file stays filed. Refused when the reason is blank, it isn't
|
||
linked there, it is filed on that entry (`books.unlink_receipt`), it is
|
||
a posted entry's last receipt under the receipt policy
|
||
(`receipt_required`), or it is the statement of a reconciliation not
|
||
voided.
|
||
- `books.attachments { subject }` → `{ subject, attachments: [...] }`: the
|
||
files on a subject (an entry's: filed on it and linked to it), not
|
||
withdrawn, each as in `books.list_receipts`.
|
||
- `link_to: [subject]` on `books.attach_receipt` and `books.file_document`
|
||
links the new file in the same call, each link its own
|
||
`attachment.linked`. All or nothing: every subject is checked (exists,
|
||
named once, not the entry it is filed on, its journal feature declared:
|
||
a `project` needs `projects`) before anything is filed, so a refusal
|
||
leaves no file filed without its links.
|
||
|
||
## Tools — receipts & documents
|
||
|
||
A file comes in one way only (D-46: a file never passes through an
|
||
agent's context, in either direction), named in the tool that files it
|
||
(`books.attach_receipt`, `books.file_document`, `books.set_w9`,
|
||
`books.import_bank_csv`, `books.import_chart_csv`): `upload_id` (for the
|
||
W-9, `w9_upload_id`), an upload made with `books.begin_upload` (any file
|
||
up to 25 MiB) and sent by `wanderbooks upload`. These tools take no other
|
||
argument (their schemas say so): an argument that once carried a file's
|
||
bytes or text inline (`content_base64`, `sha256`, `csv_text`,
|
||
`w9_content_base64`, `w9_sha256`) is refused as unknown by every tool
|
||
before it runs (a protocol error, recorded as refused `inline_file`),
|
||
its words naming `books.begin_upload`. An upload is kept for the token
|
||
that began it. An MCP request is at most 1 MiB. A file's name and type are checked when
|
||
it is filed (every filing tool, `books.begin_upload`, and the W-9's
|
||
name): a name of 1 to 255 bytes, with no `/` or `\`, not `.` or `..`,
|
||
and no control or invisible (Unicode format) characters; a MIME type
|
||
`type/subtype` (parameters allowed) of visible ASCII, at most 127 bytes.
|
||
|
||
- `books.begin_upload { sha256, size, mime_type, file_name, purpose:
|
||
"receipt" | "document" | "import" | "w9" }` → `{ upload_id?, command?,
|
||
expires_at?, max_bytes, existing_attachment_id? }`. Makes a grant for
|
||
the calling token: one SHA-256, one size (at most 25 MiB; empty
|
||
refused), one purpose, lasting 15 minutes; at most 16 under way per
|
||
token. A tool takes only an upload of its own purpose:
|
||
`books.attach_receipt` and `books.file_document` take `receipt` or
|
||
`document`, the CSV imports `import`, `books.set_w9` `w9`; another
|
||
tool refuses it and leaves it for the right one. `command` is
|
||
`wanderbooks upload --book B <upload_id> -- <the file's path>` (with
|
||
`--token NAME` when the caller's token isn't the default `agent`),
|
||
which sends the bytes (`PUT /b/{book}/u/{upload}`, "Transports"); the
|
||
agent runs it, then names `upload_id` in the tool that files it.
|
||
Bytes that aren't the size and SHA-256 declared are refused and
|
||
nothing is kept. An upload's bytes are sent once, only by the token
|
||
that began it, and used once: the call that takes them holds them
|
||
(hashed again) and spends them only when they are in the books (an
|
||
import: once its rehearsal passed); a call refused after taking them,
|
||
or one that panics, gives them back, and a server stopped mid-call
|
||
gives them back when the books next open. Lapsed grants are refused,
|
||
and swept when the books open, whenever a grant is made, and every
|
||
10 seconds. For `receipt` or `document`, when an attachment not withdrawn
|
||
already holds those bytes, no grant is made: `existing_attachment_id`
|
||
names it (link it, `books.link_attachment`, instead of filing a copy);
|
||
for `import` and `w9` a grant is always made. Refusals never name a
|
||
path.
|
||
- `books.attach_receipt { entry_id?, file_name?, mime_type?, upload_id,
|
||
description?, link_to? }` → `{ attachment_id, sha256, deduplicated,
|
||
links }`. `entry_id` is optional: a receipt can be filed before its
|
||
entry exists. `file_name` and `mime_type` default to the upload's. The upload is taken only after
|
||
the entry and every `link_to` subject check out. `link_to` as in "Tools
|
||
— attachments".
|
||
- `books.link_receipt { attachment_id, entry_id }` → `{ ok }`. Links a previously filed
|
||
(unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere
|
||
must be unlinked first; one already linked to that entry
|
||
(`books.link_attachment`) is refused, as filing it there twice.
|
||
Journaled as `receipt.linked`.
|
||
- `books.unlink_receipt { attachment_id, reason }` → `{ ok, entry_id,
|
||
remaining_receipts }`. Detaches a receipt from its entry; it stays filed,
|
||
unlinked. Journaled as `receipt.unlinked` with the reason. Subject to the
|
||
receipt policy: an entry that requires a receipt cannot be left with none
|
||
(`receipt_required` error, remedy `books.attach_receipt`), so replacing a
|
||
receipt is attach-new, then unlink-old.
|
||
- `books.withdraw_attachment { attachment_id, reason }` → `{ ok }`. Retires
|
||
a receipt or document that is neither filed on an entry nor linked to
|
||
any subject (take those off first: `books.unlink_receipt`,
|
||
`books.unlink_attachment`); the reason must not be blank. Hidden from listings, never
|
||
linkable again. Journaled as `attachment.withdrawn`; the bytes stay in the
|
||
blob store. Nothing in the evidence trail is ever erased — receipts change
|
||
by new events, the same way entries change by reversals.
|
||
- `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list
|
||
(no bytes), each with `withdrawn` / `withdrawn_reason` and `links` (every
|
||
subject it is linked to, its filing entry first). Withdrawn attachments
|
||
are omitted unless `include_withdrawn` is true. `entry_id` lists the
|
||
files filed on the entry and linked to it.
|
||
- `books.get_receipt { attachment_id, view?: "meta" | "text" |
|
||
"page_image" | "bytes", page?, max_px? }` → `{ attachment_id, sha256,
|
||
entry_id?, category, file_name, mime_type, size_bytes, description?,
|
||
links, text?, text_truncated, text_status, pages, receipt_number?,
|
||
annotation?, file? }`. `meta` (the default) returns no file: the first
|
||
8 KiB of the file's extracted text (`text`, `text_truncated` when cut),
|
||
its page count (`pages`), and `text_status` (`ok`, `none`, `failed`, or
|
||
`not_extracted` for books filed before this version: `wanderbooks books
|
||
extract`). `text` gives the whole extracted text (past 64 KiB it comes
|
||
as a handed-over `.txt` instead). `page_image` with `page` (1 is the
|
||
first) and `max_px` (default 1600, longest side) gives one page as a
|
||
PNG, handed over (D-46; D-15: no OCR, so a picture-only receipt is read
|
||
from its pages). Read the text first; a page picture only when the text
|
||
is empty or looks wrong; never `bytes` into context. `bytes` adds
|
||
`file`, the file handed over (a `HandedFile`, read hash-verified,
|
||
whatever its size: D-46), whose `download` saves a copy as a new file
|
||
in a folder the human names. The stored file is also fetched by
|
||
`wanderbooks download --book B <id> --to <a folder>` (`GET
|
||
/b/{book}/a/{attachment}`, books:read, "Transports").
|
||
- `books.file_document { category: "formation" | "legal" | "tax" |
|
||
"accounting" | "statement" | "contract" | "photo" | "warranty" |
|
||
"import_source" | "notices" | "correspondence" | "plans" | "history" |
|
||
"other", file_name?, mime_type?, upload_id, description?, link_to? }` →
|
||
`{ attachment_id, sha256, deduplicated, links }`. The bytes, name and
|
||
type as in `books.attach_receipt`. Every company record has a home
|
||
(D-20): the Drive folders' replacement, and formation papers,
|
||
registrations, plans, notices, correspondence and history besides what
|
||
the CPA asks for. Never a W-9: it shows the full TIN (`books.set_w9
|
||
w9_upload_id`).
|
||
- **One file, one record** (plan P6 step 4): filing bytes already stored
|
||
(and not withdrawn) returns that attachment's `attachment_id` with
|
||
`deduplicated: true` and links it to the new subject, instead of filing
|
||
a second record. Records an older version filed twice stay as written
|
||
until `books.consolidate_duplicates`.
|
||
|
||
## Tools — evidence
|
||
|
||
What the books know about a filed file beyond its bytes (plan P6): its
|
||
extracted text, the facts a human read off it, and which entries a payment
|
||
settles. Extraction is journaled as it is made (`attachment.text_extracted`,
|
||
content-addressed, one record per attachment, extractor and version): an
|
||
extractor upgrade adds a record, never replaces one, and `books.verify`
|
||
checks the text blob's presence and hash.
|
||
|
||
- `books.find_attachment { sha256 | receipt_number | text }` → `{
|
||
attachments: [...] }`. Exactly one of the three: the file's exact
|
||
SHA-256 (64 hex digits), the number recorded with
|
||
`books.annotate_attachment`, or words its extracted text says
|
||
(case-insensitively). Withdrawn files are listed too, marked, so a
|
||
duplicate is found before it is consolidated. Each attachment is as in
|
||
`books.list_receipts`, with its links.
|
||
- `books.annotate_attachment { attachment_id, fields { receipt_number?,
|
||
payee?, amount?, date?, instrument?, category?, source?,
|
||
reimbursable_to? }, note? }` → `{ attachment_id, fields,
|
||
next_receipt_number }`. The closed list of what a human reads off a
|
||
file: `amount` an exact decimal string, `date` YYYY-MM-DD, `instrument`
|
||
a payment method's id or label. It changes no amount, no file and no
|
||
posting; a later annotation replaces it, journaled as
|
||
`attachment.annotated`. `next_receipt_number` is one past the highest
|
||
number annotated, so numbers run without reuse. At least one field or
|
||
the note is required; an unknown or withdrawn attachment is refused.
|
||
- `books.set_entry_settles { entry_id, settles }` → `{ ok, entry_id,
|
||
settles }`. Records which entries an entry pays back (a reimbursement
|
||
settling the expenses a member paid personally), journaled as
|
||
`entry.settles`; an empty list clears it. Every named entry must exist,
|
||
none may be the paying entry itself or be named twice. It changes no
|
||
amount. "The member was paid back" is read from this record only, never
|
||
guessed (IZ-18); Needs attention asks until it is recorded.
|
||
- `books.consolidate_duplicates { confirmed?, preview? }` → `{ status: "preview" |
|
||
"consolidated", groups: [{ sha256, keep, withdraw, links }], withdrawn
|
||
}`. For every set of attachments holding the same bytes it keeps the
|
||
oldest (`keep`), links it to every subject the others had, unlinks the
|
||
others and withdraws them as duplicates (`attachment.withdrawn` with
|
||
`duplicate_of`; nothing is deleted). A posted entry that would be left
|
||
with no receipt the policy requires refuses the whole call, journaling
|
||
nothing. Without `confirmed`, a preview that changes nothing.
|
||
|
||
## Receipt enforcement
|
||
|
||
Prose tells the model to attach receipts; the policy makes it true. The
|
||
engine stays policy-free (it knows nothing of receipts) — enforcement lives
|
||
in the MCP layer, which reads the `attachments` table before confirming a
|
||
posting.
|
||
|
||
- **Policy** (the books' settings, "Company settings"; not per-call):
|
||
`policy.evidence.threshold` (receipts strictly above it; unset
|
||
`"25.00"`; `"0.00"`: every entry), `policy.evidence.required_accounts`
|
||
(account codes whose entries always need one, e.g. meals and travel)
|
||
and `policy.evidence.exempt_accounts` (e.g. automated bank fees; an
|
||
entry is exempt only when every line is in one). Changed with
|
||
`books.set_setting`.
|
||
- **Preview.** Every `post_entry` preview includes the receipt checklist
|
||
(`receipts: [...]`, `receipt_required: bool`, `receipt_missing: bool`,
|
||
`split_detected: [...]`), so the human sees it before confirming. A
|
||
`reverse_entry` preview carries none: a reversal needs no receipt (the
|
||
original carries the evidence).
|
||
- **Hard error.** Confirming a posting that violates the policy fails with a
|
||
structured, machine-readable error (JSON in the tool-error string):
|
||
`{ "code": "receipt_required", "message": "…", "entry_id": 5,
|
||
"debits": "100.00", "policy": "strictly greater than $25.00",
|
||
"remedy_tool": "books.attach_receipt",
|
||
"remedy_hint": "…" }`. Clients match on `code`, not prose. The agent's job
|
||
then is to get the receipt attached (ask the human for it) and re-confirm
|
||
— not to shrink the amount or split the entry to dodge the threshold. The
|
||
tool description says this explicitly; gaming the policy is a correctness
|
||
violation, and the append-only audit log would show it anyway.
|
||
(`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]`
|
||
when the split check triggered.)
|
||
- **One gate (P4, I-5, D-47).** `ledger_core::evidence::requirement`
|
||
decides whether an entry needs evidence; the store's gate
|
||
(`Store::post_entry_checked`), the previews, the digest, the viewer and
|
||
Needs attention all call it. A reversal needs no receipt of its own (its
|
||
evidence is its original's, D-12); neither does a closing entry, a
|
||
schedule month's or a depreciation run's posting, an asset's disposal,
|
||
a document's entry or a payroll run or remittance. A refusal is the
|
||
`receipt_required` error above, unchanged.
|
||
- **Not checked:** `books.receive_payment`, `books.pay_bill`,
|
||
`books.run_payroll` and `books.remit_payroll_taxes` are exempt from the
|
||
receipt policy by origin (above): attach their evidence right after, to
|
||
the entry they return. (`books.create_invoice` and `books.create_bill` preview and
|
||
confirm, and a bill's vendor invoice is required, in books that have
|
||
declared `documents_v2`.)
|
||
- **Anti-splitting.** Posting an entry triggers a split check: posted
|
||
entries on the same date with the same normalized description and debit
|
||
accounts are grouped, and if the group total exceeds the threshold while
|
||
the entry alone does not, a receipt is required. The preview surfaces
|
||
`split_detected: [...]`, and the error names the group. Same-day,
|
||
same-description, same-accounts is the signature; recurring spending
|
||
spread across days is not flagged.
|
||
- **Below the threshold**, receipts are still expected per the operating
|
||
contract ("a material entry without its source document is incomplete")
|
||
but not hard-required — the preview checklist keeps them visible.
|
||
|
||
## Tools — export
|
||
|
||
- `books.export { format, report, start?, end?, as_of?, cash_accounts?,
|
||
financing_accounts?, investing_accounts? }` → `{ format, report, file,
|
||
warnings? }`: `file` the export handed over (a `HandedFile`, D-46),
|
||
named `<report>-<today>.<format>`, every format alike; never its bytes
|
||
in the answer.
|
||
- Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (direct
|
||
method; needs `cash_accounts`; json is `books.cash_flow`'s answer, csv
|
||
is `section,kind,class,entry,date,label,amount,on_behalf` (`on_behalf`
|
||
on a section's lines only) with sections
|
||
`operating`, `investing`, `financing`, `reconciliation`, `noncash`,
|
||
`footing` and `warning` (its kind, entry and message; no amount); xlsx
|
||
and pdf are the printed statement, operating by class and investing
|
||
and financing by kind with each kind's transactions under it, with its
|
||
two schedules), `trial_balance`, `members_equity` (statement of
|
||
changes in members' equity; members from the settings (`company.members`); earnings
|
||
not yet closed are split the way the next members close will split
|
||
them, so a member's capital on a day never changes when its month is
|
||
closed: rules.md B-13, D-38),
|
||
`general_ledger` (by account: beginning balance, postings with running
|
||
balance and split, ending balance), `general_journal` (alias
|
||
`journal`: each entry posted in the period, line by line; `iif`:
|
||
every posted entry from the first through `as_of`, never a period),
|
||
`tax_summary` (see "Tools — tax categories"), `fixed_asset_register`
|
||
(as of a day; see "Tools — fixed assets"), `ar_aging`, `ap_aging`
|
||
(as of a day: columns `number,party,date,due_date,terms,total,paid,
|
||
discounted,outstanding,bucket,days_overdue`, one row per open
|
||
document, then a tie-out line `TOTAL,,,,,,,,<Σ outstanding>,GL
|
||
<account> <balance>,difference 0.00`; B-23, 8.3.10).
|
||
- Formats: `pdf` and `xlsx` for `profit_and_loss`,
|
||
`balance_sheet`, `cash_flow`, `trial_balance`, `members_equity`,
|
||
`tax_summary` and `fixed_asset_register`; `xlsx`, `csv`, `json` for
|
||
every report (`general_ledger` and `general_journal` have no pdf);
|
||
`iif` for `general_journal` (alias `journal`), for QuickBooks Desktop.
|
||
- Dates (rules.md B-30): the period reports (`profit_and_loss`,
|
||
`cash_flow`, `members_equity`, `tax_summary`, `general_ledger`, and
|
||
`general_journal` as csv, json and xlsx) take `start` and `end`
|
||
(omitted: the fiscal year to date; a start after the end is
|
||
refused); `balance_sheet`, `trial_balance`, `fixed_asset_register`
|
||
and `iif` take `as_of` (omitted: today, the company's time zone).
|
||
- IIF (`docs/spec/rules.md` B-34; plan D-36): plain ASCII text, every
|
||
line ending CR LF (`text/plain`, handed over like every export): every posted entry
|
||
from the first through `as_of`, period-closing entries left out
|
||
(QuickBooks keeps none; imported, a close would zero its period's
|
||
profit), each transaction's DOCNUM its entry id; its BANK accounts are
|
||
`cash_accounts`, else the settings'. `start` or `end` is refused: the
|
||
file is cumulative, so QuickBooks' balances match the books'. A
|
||
character past ASCII is transliterated ("René" "Rene", "北京" "Bei
|
||
Jing", "—" "--"), a double quote is a single one (QuickBooks reads it
|
||
as a text delimiter), a tab or line break is a space, a control
|
||
character, or a character with no transliteration, '?'; a colon in an
|
||
account's name or number '-' (a colon separates levels). Within QuickBooks Desktop's limits (Intuit): an account
|
||
name level past 31 characters is cut there; accounts whose names would
|
||
then match get " (code)" (QuickBooks would merge them); a subaccount
|
||
whose QuickBooks type differs from its parent's is written at the top
|
||
level (QuickBooks gives a subaccount its parent's type); each parent
|
||
is declared before its subaccounts; an account number past 7
|
||
characters, or written like another's, is left out of ACCNUM; a memo
|
||
past 4,095 characters is cut. Each line's class (project) is in
|
||
CLASS, every class declared first in a `!CLASS` list (none: no list),
|
||
its name written as an account's own name level is (plain ASCII, a
|
||
colon '-', at most 31 characters; classes whose names would then match
|
||
get " (id)", a name already written differently from the books (made
|
||
safe or tagged) yielding first, so a class's own name stands wherever
|
||
it can: "Café" beside "Cafe" is written "Cafe (1)" and "Cafe" is
|
||
kept, rules.md B-34 rule 8). No line names a customer or vendor (no
|
||
NAME column; see the package's README). `warnings` (absent when
|
||
there are none) says what the file writes differently from the books,
|
||
for the human: each account's name, number and place and each
|
||
class's name a line (`class 2's name "Café rebrand" is written "Cafe
|
||
rebrand"`, `account 6010's
|
||
name "Café meals" is written "Cafe meals"`, `account number
|
||
"12345678" is left out: QuickBooks Desktop holds 7 characters at
|
||
most`); the memos grouped, each thing written differently once with
|
||
every entry it is in (`"—" is written "--" in the memos of entries 3,
|
||
5, 9`; a run of changed characters is one thing), then the memos cut,
|
||
so a hundred memos with an em dash make one line. No line holds a full
|
||
TIN: a run of nine digits, or every numeral of a text holding one in
|
||
another script, is masked `•`.
|
||
- Presentation (PDF and XLSX alike, QuickBooks-style): centered company,
|
||
title, and period; a column-header row; "$" on subtotals and totals;
|
||
negatives in parentheses with decimal points aligned; a rule above every
|
||
sum and a double rule under statement totals; a footer with the
|
||
accounting basis, preparation date, and journal seq. Multi-member LLCs
|
||
get "Members' equity" with members named; the balance sheet separates
|
||
retained earnings from the fiscal year's net income (loss).
|
||
|
||
## Tools — chart templates
|
||
|
||
- `books.chart_template { entity_type, owners? }` → `{ csv, settings,
|
||
notes }`: a starter chart for a sole proprietorship (incl. single-member
|
||
LLC), partnership (incl. multi-member LLC), S corporation, or C
|
||
corporation — the equity and owner-pay accounts that entity needs (owner's
|
||
capital and draws; per-partner capital and distributions plus guaranteed
|
||
payments; officer compensation and shareholder distributions; dividends
|
||
and income tax expense), common income and expense accounts with tax
|
||
categories, and `settings`, the settings that go with it (tax form,
|
||
members, where closes go) as `[{ key, value }]` changes for
|
||
`books.set_setting`. A proposal: review with the human, then
|
||
`books.import_chart_csv` and `books.set_setting`.
|
||
|
||
## Tools — tax categories
|
||
|
||
- `books.tax_categories {}` → `{ form?, categories: [{ key, label, kind,
|
||
default_deductible_percent, note }] }`: where an account can land on the
|
||
income tax return, filtered to the business's return when known
|
||
(`[company] tax_form`, or derived from `tax_classification`). Stable
|
||
semantic keys (`meals`, `guaranteed_payments`, `commissions_fees`, ...),
|
||
never form line numbers, which change by year.
|
||
- `books.renumber_account { account, code }` → `{ id, previous_code,
|
||
code, config_mentions }`: give an account a new code. Only the code
|
||
changes (entries name accounts by id), so balances and reports don't
|
||
move. Refuses an empty code, a code in use, an unassigned control
|
||
role's default code for an account of the wrong type or a header
|
||
(placeholder), and any account an approval rule names (revise or retire
|
||
the rule first). `config_mentions` lists each setting still naming the
|
||
old code (as `books.set_setting` keys, or settings-file labels for books
|
||
whose settings are in a file) and each convention whose template or
|
||
approval rule names it: change each with `books.set_setting` /
|
||
`books.set_convention`. Journaled as `account.renumbered`.
|
||
- `books.rename_account { account, name, reason? }` → `{ id, code,
|
||
previous_name, name, notes }` (scope `books:post`, destructive: a
|
||
rename changes the label every past report prints): give an account a
|
||
new name (rules.md B-31). Only the name changes (entries name accounts
|
||
by id): no entry, balance or statement amount moves, and every report,
|
||
past periods included, prints the new name. Refuses an unknown account,
|
||
a blank name, the name it has, a name another account under the same
|
||
parent has (ignoring capitals), one holding a run of nine digits, or
|
||
one that isn't one line of at most 200 characters (kept trimmed); a
|
||
`reason` is one to 2,000 characters with no full TIN. Journaled as
|
||
`account.renamed { account_id, code, name, previous, reason }`, keyed
|
||
by id (a code can change; an id can't), under the journal feature
|
||
`account_renamed` (rules.md B-27; books an older version wrote refuse
|
||
it, `format_upgrade_pending`, until their format upgrade), so an
|
||
earlier version stops at the declaration. `notes` always says that QuickBooks
|
||
matches accounts by name (tell a CPA who imported an earlier IIF), says
|
||
when sub-accounts' full names change with it, when the new name moves
|
||
the account into or out of the cash accounts (`books.cash_accounts`
|
||
unset: picked by name) or the card accounts (named "card", while no
|
||
business-card payment method is recorded), and lists
|
||
conventions whose text names the old name.
|
||
- `books.set_tax_category { account, category?, deductible_percent? }`:
|
||
map a revenue or expense account to a category (null clears),
|
||
optionally overriding its deductible percent. Under
|
||
`policy.require_tax_category = refuse` a revenue or expense account
|
||
that isn't a header is never left without one. Journaled as
|
||
`account.tax_set`.
|
||
- `books.export { report: "tax_summary" }`: book vs. tax amounts by
|
||
category, the difference where they disagree (meals 50%, penalties 0%),
|
||
and every P&L account still uncategorized. Gains and losses on business
|
||
property (`form_4797_gain_loss`) are listed apart in `form_4797`, at
|
||
their book amounts with no tax amount and outside income and both nets,
|
||
on every return: Form 4797 figures them from the tax basis and splits
|
||
ordinary from section 1231 gain, which decides the line (never Schedule
|
||
C). In the CPA package for the fiscal year to date.
|
||
- `books.import_chart_csv` accepts an optional fifth `tax_category` column,
|
||
validated before anything is created. A revenue or expense row without
|
||
one is listed in `warnings`, or, under `policy.require_tax_category =
|
||
refuse`, refuses the whole file (a row is never a header account: the
|
||
file can't say so).
|
||
- `policy.require_tax_category` (`books.set_setting`): `warn` (unset) or
|
||
`refuse` (rules.md B-32). The accounts invoices, bills and payroll
|
||
create (Revenue 4000, General Expenses 6999, 6100, 6110) are never
|
||
refused; their results' `warnings` name them. A change naming the key
|
||
needs the journal feature `require_tax_category`: books an older
|
||
version wrote refuse it (`format_upgrade_pending`) until the owner's
|
||
format upgrade (rules.md B-27).
|
||
|
||
## Tools — import
|
||
|
||
- `books.import_bank_csv { upload_id, bank_account_code,
|
||
default_offset_code, rules?, date_order?, source_attachment_id? }` →
|
||
`{ imported: [{ entry_id, date, description, amount, offset_account }],
|
||
count, source_attachment_id, source_linked }`: one draft per
|
||
transaction, all or none, from the QuickBooks bank-upload layout
|
||
(`Date,Description,Amount`, payments negative, or
|
||
`Date,Description,<money out>,<money in>`). `rules` maps description
|
||
substrings (ignoring case; the longest match wins) to account codes,
|
||
else `default_offset_code`. `source_attachment_id` (the export as filed, category
|
||
`import_source`) is checked before any draft and linked to the bank
|
||
account (`source_linked`; false when it was already), never to the
|
||
drafts: a bank export is not a receipt.
|
||
- `books.import_chart_csv { upload_id }` → `{ created,
|
||
count, warnings? }`: creates accounts from
|
||
`code,name,type,parent_code[,tax_category]` CSV. All-or-nothing; the
|
||
name and tax category rules are `books.add_account`'s.
|
||
- Each takes the CSV only as an upload (`books.begin_upload` purpose
|
||
`import`; UTF-8; D-46). The CSV itself is not filed: file
|
||
it with `books.file_document` (category `import_source`) for
|
||
`source_attachment_id`.
|
||
|
||
## Resources (read-only)
|
||
|
||
Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros):
|
||
`list_resources` advertises, `list_resource_templates` advertises the
|
||
templates below (P5), `read_resource` renders.
|
||
|
||
- Templates: `books://entries/{id}` (as `books.get_entry`),
|
||
`books://attachments/{id}` (as below), `books://journal/{seq}` (that line
|
||
in words: `books.journal_history`'s answer for it),
|
||
`books://reports/{kind}{?start,end,as_of}` (`kind` is `trial-balance`,
|
||
`balance-sheet` or `profit-and-loss`; `as_of` for the first two, `start`
|
||
and `end` for the third; omitted: today, or the fiscal year to date) and
|
||
`books://digest{?since,since_seq,as_of}` (`since_seq` wins over `since`;
|
||
bare: this month so far).
|
||
|
||
- `books://start`, `books://settings`, `books://conventions`
|
||
- `books://chart`
|
||
- `books://reports/trial-balance`, `books://reports/balance-sheet`,
|
||
`books://reports/profit-and-loss`
|
||
- `books://digest?since=2026-10-01`; bare (or with an empty `since=`),
|
||
this month so far
|
||
- `books://handbook` (the index) and `books://handbook/{section}` (one per
|
||
section, Markdown)
|
||
- `books://receipts/{id}` (an alias of `books://attachments/{id}`) —
|
||
receipt metadata and the first 8 KiB of its text (as `books.get_receipt`
|
||
view `meta`; no bytes)
|
||
- `books://attachments/{id}/page/{n}` — page `n` (from 1) as a PNG blob,
|
||
the first binary resource (plan P6; D-46: the bytes are the resource's
|
||
contents, never a path)
|
||
|
||
Resources exist so read-only clients (dashboards, the viewer,
|
||
auditors) never need tool credentials. Tested end to end through a real
|
||
books server (ledger-cli's tests).
|
||
|
||
## The agent operating contract
|
||
|
||
Any AI client driving these tools follows this contract (it will also live
|
||
in the server's tool descriptions, so the model reads it at use time):
|
||
|
||
1. **Show the preview before confirming any posting.** No silent posts, ever.
|
||
2. **Echo exact numbers back to the human.** Amounts as written, no rounding
|
||
in prose.
|
||
3. **Attach receipts at record time.** A material entry without its source
|
||
document is an incomplete entry. File first, link later if the entry
|
||
isn't ready. Postings strictly above the receipt policy threshold *cannot*
|
||
post without one — the server refuses with `receipt_required`.
|
||
4. **When unsure of categorization, draft and ask.** A draft is cheap; a
|
||
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
|
||
|
||
Bank feeds, payment processing, payroll tax tables and filing (payroll is
|
||
accounting only: the caller supplies every figure), tax filing.
|
||
|
||
## Tools — periods
|
||
|
||
- `books.create_period { name, start, end }` → `{ period_id }`.
|
||
Dates are YYYY-MM-DD, inclusive.
|
||
- `books.close_period { period_id, confirmed?, preview? }` → without confirmation:
|
||
`{ status: "preview", period_id, closing_entry: null, lines: [...],
|
||
members }`: the exact closing entry (the close run on a copy of the
|
||
ledger), posting nothing; with `confirmed: true`: `{ status: "closed",
|
||
period_id, closing_entry, lines, members }`, posting it and marking the
|
||
period closed. The entry zeroes revenue and expense balances as of the
|
||
period end; the net goes to `3999` Retained Earnings (created if
|
||
missing), or to `books.close_into` (a single owner's capital), zeroing
|
||
the `books.close_sweep` accounts (draws, dividends) into it too; or,
|
||
with `company.members` in the settings, to each member's capital
|
||
account by share (each member's distribution account zeroed into it), cumulatively (rules.md B-13; D-10): each close gives a member
|
||
their share of everything closed to these members and shares so far, to
|
||
the currency's minor unit (odd units by largest remainder, ties to the
|
||
member listed first when the count began), less what earlier closes gave
|
||
them, so no member is ever a minor unit from their exact share. A close
|
||
may carry a one-time true-up of rounding by closes made under an earlier
|
||
rule, which can run against the period's result by a cent. `members`
|
||
(null for a close not to the members) is `{ since_close, members: [{
|
||
name, capital_account, share, this_close, running, exact }] }`: the
|
||
close the count starts at (the first members close with these members
|
||
and shares; 1 is the books' first close of all), and per member, in the
|
||
settings' order, their capital account's code, share ("0.50"), what this
|
||
close credits them (negative: debits), what the count's closes have
|
||
credited them in all, and their exact share of everything the count
|
||
closed (unrounded, e.g. "0.005"). The preview's `members` is exactly what
|
||
confirming posts. Periods close in order. Closed periods reject new
|
||
entries.
|
||
- `books.list_periods {}` → `{ periods: [{ id, name, start, end, status,
|
||
closing_entry, close_rule }] }`. `close_rule` says which rounding rule a
|
||
period closed to the members was closed under:
|
||
`"members_cumulative_since:N"` (the rule since P3, counted from close N),
|
||
`"members_run"` (2026-10-13 to P3), `"members_per_close"` (2026-10-13) or
|
||
`"members_last_absorbs"` (before 2026-10-13); null for an open period or
|
||
one closed to anything else.
|
||
|
||
## Tools — bank reconciliation
|
||
|
||
- `books.mark_cleared { entry_id, line_index, cleared }` → `{ ok }`.
|
||
Only posted lines can be marked; the flag is workflow metadata, not
|
||
accounting data (posted entries stay immutable).
|
||
- `books.record_statement { attachment_id, account, period_start,
|
||
period_end, opening_balance, closing_balance }` → the statement `{
|
||
attachment_id, file_name, account_code, account_name, period_start,
|
||
period_end, opening_balance, closing_balance, reconciliation_id? }`.
|
||
Records what a filed statement says (balances as printed, in the
|
||
account's normal direction, cents at most) and links it to the account.
|
||
Journaled as `statement.recorded` then `attachment.linked`. Refused for
|
||
an unknown or withdrawn attachment, one already recording a statement
|
||
(not voided), an account that isn't an asset or liability, or a period
|
||
that runs backwards.
|
||
- `books.statements { account? }` → `{ statements }`, by statement date:
|
||
those not voided whose file isn't withdrawn (withdrawing a statement's
|
||
file retires it).
|
||
- `books.void_statement { attachment_id, reason }` → the statement as it
|
||
was recorded. For a statement recorded wrong: journaled as
|
||
`statement.voided`; it is no longer listed, backs no reconciliation, and
|
||
the same attachment can record it again. Refused while a reconciliation
|
||
not voided rests on it.
|
||
- `books.reconcile { statement_attachment? | account, statement_date,
|
||
statement_balance, confirmed?, preview? }` → `{ status: "preview" | "reconciled",
|
||
account_code, statement_date, statement_attachment?, book_balance,
|
||
cleared_balance, statement_balance, difference, uncleared_lines,
|
||
cleared_lines, problems, reconciliation? }`, each line `{ entry_id,
|
||
line_index, date, description, side, amount }`. `difference` is cleared
|
||
− statement; zero means reconciled. With `statement_attachment` the
|
||
account, date, and balance are the recorded statement's (any given must
|
||
agree). Without `confirmed: true` it is a preview that records nothing;
|
||
`problems` lists why a confirmation would be refused. Confirmed, it
|
||
needs a recorded statement (its file not withdrawn: refused before
|
||
anything is journaled), a difference of exactly zero, and a date after
|
||
the account's last reconciliation not voided; it then journals
|
||
`reconciliation.completed` (the cleared lines, the statement) and links
|
||
the statement to it, returning `reconciliation`.
|
||
- `books.reconciliations { account? }` → `{ reconciliations: [{
|
||
reconciliation_id, account_code, account_name, statement_date,
|
||
statement_balance, statement_attachment, cleared: [{ entry_id,
|
||
line_index }], voided, voided_reason? }] }`, voided ones included.
|
||
- `books.void_reconciliation { reconciliation_id, reason }` → the
|
||
reconciliation, voided. Journaled as `reconciliation.voided`; the
|
||
account can be reconciled through that date again.
|
||
- `books.mark_cleared` refuses to un-clear a line a reconciliation not
|
||
voided recorded as cleared: void the reconciliation first. Clearing a
|
||
line dated on or before a reconciliation (an outstanding check that
|
||
clears on a later statement) is allowed.
|
||
|
||
## Tools — A/R and A/P
|
||
|
||
S4 gives `books.create_invoice` and `books.create_bill` a preview and a
|
||
confirm (A-1) and the documents numbers, terms, line accounts and party
|
||
snapshots; see plan 8.2 S4.
|
||
|
||
- `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 { customer | party_id, date, terms? | due_date?,
|
||
lines: [{ description, amount, quantity?, unit_price?, account?,
|
||
class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?,
|
||
phone?, contact?, billing_address?, shipping_address?, billing_email? },
|
||
confirmed?, preview? }` → without `confirmed`: `{ preview, number?, total,
|
||
due_date, terms?, lines: [{ account, name, side, amount, class? }],
|
||
pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?,
|
||
total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 },
|
||
mailto?, warnings?, notes? }`.
|
||
Confirmed it posts Dr `1200` Accounts Receivable / Cr each line's
|
||
account (the revenue role, `4000`, when a line names none), the
|
||
receivable line tagged with the invoice and the receivable side one line
|
||
per (account, project) part.
|
||
- `customer` is the customer's id, its party's id or its name (a name
|
||
two customers answer to is refused); it is required (the old `party_id`
|
||
spelling is gone, P5).
|
||
- The due date: `due_date` as given (and it overrides `terms`); else
|
||
`terms` ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on receipt",
|
||
"Due 2026-11-15", rules.md B-39); else the customer's
|
||
`default_terms_as_customer`; else the books' `invoicing.default_terms`
|
||
("Net 30" unset); else refused, naming those ways. Terms are kept on
|
||
the invoice only when given or taken from a default: with only a due
|
||
date the invoice prints none.
|
||
- Its number (`INV-0001`) is `invoicing.number_prefix` and
|
||
`invoicing.first_number` plus the count of numbered invoices before it
|
||
(A-2, B-40); it is stored at issue and never recomputed, and a preview
|
||
shows the number the confirm will take (an abandoned preview uses up
|
||
none). Books that haven't declared `documents_v2` number nothing and
|
||
show a document as "Invoice {N}".
|
||
- The bill-to snapshot is the customer's name, address and email at
|
||
issue, with its `billing_address` and `billing_email` in their place
|
||
when it has them, and its `contact` person, `phone` and
|
||
`shipping_address` (D-55); a `bill_to` object overrides any part for
|
||
this one document. `customer_ref` is the customer's own reference (a
|
||
purchase order) and `memo` prints on the page. These are new fields:
|
||
books that haven't declared `documents_v2` refuse a document carrying
|
||
any of them with `format_upgrade_pending`, journaling nothing, and
|
||
take one carrying none as before.
|
||
- A line's `class` (a project: name or id) tags its revenue, as before;
|
||
a line's `account` sends it to a revenue account or a liability
|
||
holding no role (deferred revenue). `quantity` and `unit_price` go
|
||
together, and quantity × unit_price must equal `amount` exactly.
|
||
- Without `confirmed` the DRAFT copy of the PDF is handed over (`pdf`,
|
||
run its `download`, D-46); confirming renders the issued copy before
|
||
anything is journaled and files it with the invoice in the same call
|
||
(A-7, B-40), returning its `attachment_id` and `sha256` (the same
|
||
`sha256` `books.get_receipt` reports).
|
||
- `mailto` is a convenience link for a human to send it from their own
|
||
mail program: the subject "Invoice INV-0007 from {company}", the body
|
||
URL-encoded and cut to 1,800 bytes at the last whole word. The books
|
||
never send anything (A-4): a mailto link carries no attachment, so say
|
||
which file to attach.
|
||
- `warnings` names a revenue account it created with no tax category
|
||
(rules.md B-32); `notes` one per finished or abandoned project a line
|
||
names, and on confirm one per on-spec project (is there now an
|
||
agreement?).
|
||
- `books.create_bill { vendor | party_id, date, received_on?, vendor_ref?,
|
||
terms? | due_date?, lines: [{ description, amount, account, class? }],
|
||
memo?, upload_id | existing_attachment_id, duplicate_reason?,
|
||
confirmed?, preview? }` → without `confirmed`: `{ preview, total, due_date,
|
||
terms?, lines: [...], discount_by?, max_discount? }` and nothing is
|
||
written; with `confirmed: true`: `{ id, total, due_date, terms?,
|
||
entry_id, lines?, discount_by?, max_discount?, file: { attachment_id,
|
||
sha256 }, warnings?, notes? }`.
|
||
Confirmed it posts Dr each line's account / Cr `2000` Accounts Payable,
|
||
the payable line tagged with the bill and one debit line per (account,
|
||
project) part; `vendor` is a vendor id, its party's id or its name
|
||
(required; the old `party_id` spelling is gone, P5).
|
||
- `vendor_ref` is the vendor's own invoice number: required in books
|
||
that have declared `documents_v2` (B-40). The same vendor and number
|
||
twice is refused unless `duplicate_reason` says why, which is
|
||
journaled with the bill; case, spaces and hyphens are ignored.
|
||
`received_on` is the day it was received, when it differs from its
|
||
date.
|
||
- In books that have declared `documents_v2` every line names its
|
||
`account` (A-10: an expense account, or an asset that is not cash and
|
||
holds no role: equipment, prepaid), and the vendor's invoice is
|
||
required: `upload_id` (books.begin_upload and `wanderbooks upload`,
|
||
D-46) or `existing_attachment_id` (bytes stored already,
|
||
books.attachments). It is filed on the bill's entry as its evidence,
|
||
linked to the bill, and returned as `file` with its `sha256`. In books
|
||
that haven't declared the feature, lines without `account` post to
|
||
`6999` as before and the file is optional.
|
||
- The due date and terms fall back as an invoice's, to the vendor's
|
||
`default_terms_as_vendor` and no further. `discount_by` is the last
|
||
day an early-payment discount is open and `max_discount` the largest
|
||
it allows (B-39): the caller gives the exact cents when it takes one.
|
||
- `warnings` names an expense account it created with no tax category;
|
||
`notes` one per finished or abandoned project a line names (none for
|
||
on-spec: a cost of on-spec work is an expense like any other).
|
||
- `books.receive_payment { date, deposit_to, instrument?,
|
||
reference?, invoice, amount? | applications: [{ invoice,
|
||
amount, discount?, discount_reason? }], fee?: { amount, account?,
|
||
vendor? }, paid_in_full_marking?, confirmed?, preview? }` → `{ preview?,
|
||
entry_id, documents: [{ ref, number?, outstanding, status }], deposited,
|
||
remaining?, status?, warnings? }`. Without `confirmed: true` a preview
|
||
(entry 0, the figures the documents would show, `preview` to pass
|
||
back) and nothing is written (P4: the S6 tools preview and confirm like
|
||
the create tools). Settles one or several invoices of one customer in one
|
||
entry (B-42): Dr the deposit account the cash less the fee, Dr the
|
||
payment-processing-fees account the fee, Dr sales allowances the
|
||
discounts, Cr A/R each invoice's cash and discount, every receivable
|
||
line tagged with its invoice (payment lines in application order, then
|
||
the discount lines). The `amount`s are cash only; a discount goes beside
|
||
one. A discount at most the invoice's terms allow and dated by their
|
||
discount date needs no reason; a larger or later one needs
|
||
`discount_reason`. `fee.vendor` (a processor the books know as a vendor)
|
||
is named in the entry's description; only cash movements count toward a
|
||
1099, so the fee is not a payment to the processor. `deposited` is the
|
||
cash less the fee (A-4's gross presentation); `remaining` and `status`
|
||
repeat the single document's figures. An overpayment is refused with the
|
||
excess and the remedy (a plain entry to 2300 Customer Deposits).
|
||
`paid_in_full_marking: true` adds a UCC §3-311 warning and records
|
||
nothing else. (The older names `document_id` and `cash_account` are
|
||
gone, P5.) The books record
|
||
payments; they never collect them (D-45).
|
||
- `books.pay_bill { date, paid_from, instrument?,
|
||
reference?, bill, amount? | applications: [{ bill, amount,
|
||
discount?, discount_reason? }], confirmed?, preview? }` → as `receive_payment`, per bill and
|
||
one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer
|
||
account the cash, Cr purchase discounts the discounts. A discount on a
|
||
bill with an asset or prepaid line is refused with its remedy (record
|
||
the bill net, or reclassify Dr 6995 / Cr the asset, D-49); a processor's
|
||
fee is refused on a bill.
|
||
- `books.void_document { kind, id, date, reason }` → `{ entry_id, status }`.
|
||
Voids an invoice or bill (`kind` `invoice` or `bill`) that should never
|
||
have existed by reversing its own entry with the reason; it keeps its
|
||
number and reads `void`. Refused while a payment stands on it (the
|
||
refusal names the payment entry to reverse first), when void already,
|
||
before its date or in a closed period (P4b S1, B-38).
|
||
- `books.mark_sent { invoice, on?, by?, via?, recipients?: [string],
|
||
kind?, reference?, note? }` → `{ invoice, sends: [{ on, by, via, to, kind,
|
||
reference?, note? }] }`. Records that an invoice went out (P4b S5,
|
||
A-4): the books never send, so the owner or a delegate sends and this
|
||
records who (`by`: the token's name unless given), how (`via`: email
|
||
the default, or mail, portal, hand, another channel's name), to whom
|
||
(`recipients`: the invoice's bill-to email, else the customer's, else
|
||
required), when (`on`: today in the company's time zone), original or
|
||
reminder (`kind`: original on the first send, reminder after), and the
|
||
channel's own id (`reference`: a message id). `invoice` is
|
||
`invoice:N` or `N`. Several sends are allowed; none moves a figure or
|
||
the due date. Refused on a void invoice, dated before it, with no
|
||
recipients, or while the books wait for `documents_v2`
|
||
(`format_upgrade_pending`). `sends` is every send on the invoice, in
|
||
order, the new one last.
|
||
- `books.write_off_invoice { invoice, date, amount?, reason, upload_id |
|
||
existing_attachment_id, confirmed?, preview? }` → `{ entry_id, document: { ref,
|
||
status, paid, written_off, outstanding } }`. Writes off an invoice that
|
||
will not be collected (a bad debt, P4b S7; rules.md B-41): posts Dr
|
||
`6990` Bad Debt Expense / Cr `1200` Accounts Receivable, the receivable
|
||
line tagged with the invoice (`WriteOff`), so the invoice reads
|
||
`written_off` and its figures stay derived (B-38). `amount` (a decimal
|
||
string, default all it owes) is at most what is owed and may be partial
|
||
(§1.166-3(a)). Its evidence of worthlessness (collection
|
||
correspondence, a bankruptcy notice: §1.166-2(a)) is filed in the same
|
||
call, as `upload_id` (books.begin_upload, purpose `document` or
|
||
`receipt`, then `wanderbooks upload`) or as an
|
||
`existing_attachment_id` already filed and not linked; it becomes a
|
||
receipt on the write-off's entry and a link to the invoice. Refused on
|
||
cash-basis books (§1.166-1(e)), above what it owes, before the
|
||
invoice's date or in a closed period. Without `confirmed: true` it
|
||
answers `entry_id` 0 and the figures the write-off would leave, writing
|
||
nothing. A recovery is not a new command (D-49): the same tax year,
|
||
reverse the write-off (`books.reverse_entry`, the invoice reopens) and
|
||
record the payment; a later year, a plain entry Dr the deposit account /
|
||
Cr a "Bad debt recoveries" revenue account, the invoice staying written
|
||
off (§111).
|
||
- `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
|
||
kind, documents: [{ ref, party, date, due_date, total, outstanding,
|
||
status, overdue_days, last_sent? }], outstanding_total }`. Invoices (`kind`
|
||
`invoices`) or bills (`bills`), due date first, every figure derived
|
||
from the document's lines that stand (B-38). `status` filters: `open`
|
||
(default: anything still owed, partly paid and overdue included),
|
||
`overdue`, `paid`, `void`, `written_off`, `overpaid` or `all`; a row's
|
||
own `status` is one of `open`, `partial`, `paid`, `written_off`,
|
||
`void`, `overpaid` or `unattached` (`DocStatus::key()`). `party` (a
|
||
customer's or vendor's name, ignoring case) and `due_by` (due on or
|
||
before that day) narrow the list (P4b S2). `last_sent` is the day the
|
||
invoice last went out, when a send is recorded (P4b S5); absent for an
|
||
invoice never sent and for every bill.
|
||
- `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date,
|
||
entry_id, lines: [{ description, amount, project? }], total, paid,
|
||
discounted, written_off, outstanding, status, overdue_days, timeline:
|
||
[{ date, entry_id, kind, amount, reversal, stands }], sends: [{ on, by,
|
||
via, to, kind, reference?, note? }], pdf? }`. `ref` is
|
||
`invoice:N` or `bill:N`; `status` as `books.documents`' rows; the
|
||
timeline is every ledger line tagged with the document, dated on or
|
||
before the day, in posting order, each saying whether its entry still
|
||
stands as of the day (P4b S2). With `pdf: true` an invoice's PDF comes
|
||
back as a handed file (`pdf`: run its download): re-created from the
|
||
books as of the day, each standing payment on its own line with its
|
||
date, method and reference, stamped PAID (with the day), PARTIALLY
|
||
PAID, PAST DUE or VOID (P4b S3); refused for a bill. The page prints
|
||
the invoice's own number, terms (an early discount reads in them), the customer's PO,
|
||
each line's quantity beside its price, the customer as at issue
|
||
(contact, billing address, phone, email, a Ship-to block), the
|
||
company's email, website, ways to pay (a QR code for a method with a
|
||
link) and footer; it says "Re-created on" the `as_of` day, never the
|
||
day it was printed, and the filed issued copy is never replaced by it
|
||
(P4b S4.5).
|
||
- `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role,
|
||
code, name, balance, documents_total, unattached: [{ entry_id, line,
|
||
date, amount }], difference, attention: [{ kind, id, status, total,
|
||
outstanding }] }] }`. Receivables and payables tied to their documents
|
||
(B-23, B-38 INVARIANT 1): `difference` is always `0.00`; `agrees` when
|
||
there is no unattached line and nothing needing attention.
|
||
`books.verify` checks the same identity and `books.digest` lists its
|
||
problems (`control_problems`).
|
||
- `books.ar_aging { as_of?, detail? }` → `{ as_of, rows: [{ name,
|
||
current, days_30, days_60, days_90, days_90_plus, total }], documents?:
|
||
[{ ref, number?, party, date, due_date, terms?, total, paid,
|
||
discounted, outstanding, bucket, days_overdue }], control: { account,
|
||
balance, documents_total, unattached, difference } }` (`as_of` omitted:
|
||
today). `detail: true` adds one row per open invoice; `control` is the
|
||
receivables account's tie-out (B-23, B-38 INVARIANT 1), `difference`
|
||
always `0.00`.
|
||
- `books.ap_aging { as_of?, detail? }` → same, per vendor and bill.
|
||
- `books.bills_due { through?, as_of? }` → `{ as_of, through, bills:
|
||
[{ ref, vendor, vendor_ref?, due_date, outstanding, discount_by?,
|
||
max_discount?, pay_by_discount_date_amount? }], by_date: [{ date,
|
||
amount }] }`. Every open bill due on or before `through` (omitted:
|
||
today), due date first, with its terms' early-payment discount when it
|
||
is still open (`discount_by`, the most off, what to pay to take it);
|
||
`by_date` is the running total of what falls due through each date.
|
||
|
||
## Tools — foreign exchange
|
||
|
||
- `books.add_line` gains optional `foreign_currency`, `foreign_amount`,
|
||
`fx_rate`. When set, the base amount is `foreign_amount * fx_rate`; the
|
||
foreign original is stored on the line for reference. Reports work in
|
||
base currency.
|
||
- `books.set_fx_rate { currency, date, rate }` → `{ ok }`. Records a
|
||
historical rate for reference; lines store their own rate at entry time.
|
||
|
||
## Tools — payroll (accounting only)
|
||
|
||
No tax tables: the caller supplies every number; the engine cuts correct
|
||
entries.
|
||
|
||
- `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, warnings? }` (`warnings`
|
||
names an expense account it created with no tax category). 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. When every line with net pay names its party, the entry
|
||
names those parties, each with its net pay, so each employee's
|
||
`books.party` counts it; under `[books] parties = "require"` a line
|
||
with net pay and no party is refused before anything posts
|
||
(`party_required`).
|
||
- `books.remit_payroll_taxes { date, cash_account, agency? }` → `{
|
||
entry_id, total }`. Clears every payroll payable to cash in one entry,
|
||
naming `agency` (the tax agency's party) on it; required under
|
||
`[books] parties = "require"` (`party_required`, nothing posted).
|
||
- `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count,
|
||
total_gross, entry_id }] }`.
|
||
|
||
## Tools — projects (classes)
|
||
|
||
A project is a class with its facts (`docs/spec/rules.md` B-37): the
|
||
same id, the same name, so `books.add_line`'s `class`, the P&L `class`
|
||
filter and `books.profit_and_loss_by_class` take projects. A `project`
|
||
reference is an id or a name (ignoring capitals in any script and
|
||
surrounding space; in a link's subject a JSON number is always an id); a
|
||
number that is one project's id and another's name, which only old books
|
||
can hold, is refused, naming what to pass instead. Names are unique,
|
||
ignoring capitals and surrounding space, and never digits only (they
|
||
would read as an id). A change that keeps a project's own name never
|
||
checks it again, so old books holding a duplicate name can change either
|
||
project's facts; a rename onto another project's name is refused. Every project write but a name alone needs the journal feature
|
||
`projects` (B-27): books waiting for their format upgrade (D-39) can add
|
||
a project by name only, and every other project write (facts, a link to
|
||
a project, a decision naming one) is refused with `format_upgrade_pending`
|
||
until the owner upgrades them.
|
||
|
||
- `books.add_project { name, description?, status?, kind?, party?,
|
||
started_on?, ended_on?, links?, notes? }` (books:write) → the project: `{ id,
|
||
name, status, kind, description, party: { party_id, label }?,
|
||
started_on, ended_on, links, notes }`. `status`: `active` (default),
|
||
`paused`, `finished`, `abandoned`. `kind`: `internal` (the company's own
|
||
product), `client` (work for a customer), `on_spec` (work done hoping
|
||
to be paid), `other`; null when not recorded. `party`: who it is for or
|
||
with (id or name). `ended_on` only when finished or abandoned, never
|
||
before `started_on`. `links`: up to 8 web addresses, one line each, no
|
||
spaces, none twice (never opened). Text fields: one line (name, links,
|
||
at most 200 characters) or long text (description, notes, at most
|
||
2,000), never a full TIN. A name alone journals `class.added`; facts add
|
||
`project.set` (the whole record).
|
||
- `books.update_project { project, name?, description?, status?, kind?,
|
||
party?, started_on?, ended_on?, links?, notes? }` (books:write;
|
||
idempotentHint) → the
|
||
project. Only the fields given change; `""` clears description, kind,
|
||
party, started_on, ended_on or notes; `links` replaces the list (`[]`
|
||
clears it). At least one field. Every field is checked before anything
|
||
is written; then the whole record is journaled as `project.set`, and a
|
||
record with nothing new journals nothing. A rename changes the label
|
||
every report prints, nothing else (not destructive, as a party's name).
|
||
- `books.projects { status? }` (books:read) → `{ through, projects: [{ ...project,
|
||
to_date: { through, revenue, expenses, net, margin_pct, unrecovered_cost } }] }`: every
|
||
project (every class), by name ignoring capitals; `to_date` counts every
|
||
posted revenue and expense line tagged to it through today in the
|
||
company's time zone, closing entries left out; `net` is its
|
||
contribution before overhead (B-37 item 7: revenue less every cost
|
||
tagged to it, fixed and variable alike, no company overhead allocated:
|
||
a segment margin in Garrison's sense, not a variable-cost contribution
|
||
margin); `margin_pct` is net as a percent of revenue to one place, half
|
||
to even ("64.0"), null when revenue is zero or less; `unrecovered_cost` only
|
||
for an on-spec project (else null): expenses less revenue, never below
|
||
zero.
|
||
- `books.project { project, start?, end? }` (books:read; omitted dates: the fiscal
|
||
year to date, B-30, as every period report) → `{ project, period: {
|
||
start, end, revenue, expenses, net, margin_pct, by_account: [{ code, name, type
|
||
("revenue" | "expense"), amount }], by_month: [{ month ("YYYY-MM"),
|
||
start, end, revenue, expenses, net, net_to_date }] }, to_date (as above, through
|
||
end, plus `by_account` to date, as the period's: its revenue accounts,
|
||
then its direct costs by expense account; always present here, `[]`
|
||
when nothing tagged nets to anything, and absent from
|
||
`books.projects`), entries: [{ entry_id, date, description, status, project_amount,
|
||
total, reversal_of?, receipts: [attachment_id] }], documents: [as
|
||
books.list_receipts], decisions: [as books.decisions] }`. The figures
|
||
are `ledger_core::project_figures`'s, the viewer's: only revenue and
|
||
expense lines count (a tagged asset purchase is listed with its entries,
|
||
its project amount zero); `by_month` holds the period's last 240 months
|
||
at most, each month's `net_to_date` the running net through its end
|
||
(every tagged line from the first day the books keep: the last month's
|
||
is `to_date.net`). `to_date` always counts every tagged line through `end`,
|
||
whatever the project's dates: job-to-date beside the period. The on-spec
|
||
`unrecovered_cost` is the "if repaid" figure, never a receivable (ASC
|
||
450-30-25-1). Entries are newest first, drafts included; decisions
|
||
oldest first, superseded ones too.
|
||
- `books.add_class { name }` → `{ id }`: a project with only its name;
|
||
refuses a name another project has.
|
||
- `books.list_classes` → `{ classes: [{ id, name }] }` in id order.
|
||
- `books.add_line` takes optional `class` (a project: name or id): tags the
|
||
new line. Shared costs are split explicitly across lines at entry time.
|
||
Its result gains `note` when the project is finished or abandoned
|
||
("project X is finished (ended 2026-09-30): tag a late cost or invoice
|
||
to it only if it belongs to it"): tagged anyway.
|
||
- `books.profit_and_loss` takes optional `class` (a project: name or id)
|
||
to filter to.
|
||
- `books.record_payout` lines and adjustments take optional `class` (a
|
||
project); its result gains `notes`, one per finished or abandoned
|
||
project tagged, as `books.add_line`'s `note`.
|
||
- An invoice's or bill's lines take a project too (`books.create_invoice`
|
||
and `books.create_bill`, `class` on a line, rules.md B-37 item 8), in
|
||
books that declared the journal feature `document_line_projects`; the
|
||
handbook's projects section says which these books are. Their payments
|
||
take none.
|
||
- `books.profit_and_loss_by_class { start?, end?, class? }` (omitted dates:
|
||
the fiscal year to date, as every period report) → per-class revenue,
|
||
expenses, net income, plus an unclassified row (only that class's row
|
||
when `class` is given; an unknown or ambiguous class is refused). Reversals carry their
|
||
class so divisional P&L nets correctly.
|
||
|
||
## Tools — CPA handoff
|
||
|
||
- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?,
|
||
investing_accounts?, include_receipts?, include_tins?,
|
||
allow_open_drafts?: { reason } }` → `{ file,
|
||
tins_included, tin_gaps, warnings?, note }`: the zip,
|
||
`cpa-package-<as_of>.zip`, handed over as `file` (a `HandedFile`,
|
||
"Tools — the TIN vault").
|
||
**Open drafts** (P4, plan 4.8): refused with `open_drafts` while a draft
|
||
dated on or before `as_of` is open (post it or discard it); with
|
||
`allow_open_drafts: { reason }` the package goes and the reason is
|
||
journaled as a decision.
|
||
**As of one day** (`docs/spec/rules.md` B-33): nothing in it reflects
|
||
anything dated after `as_of` (default today): no figure, entry,
|
||
receipt or worksheet row; the chart and `documents/` are as they stand.
|
||
One list of cash accounts and one of financing accounts serve the
|
||
whole package: the request's, else the settings'.
|
||
**QuickBooks file** (`general-journal.iif`, B-34): as `books.export`
|
||
iif makes it, through `as_of`; `warnings` says what it writes
|
||
differently from the books, in the same lines as `books.export` iif's
|
||
(account and class names and account numbers one each, the memos
|
||
grouped; every other file keeps the books' text), the README lists the
|
||
same lines (and names the entries with a line on accounts receivable or
|
||
payable, to which QuickBooks Desktop may ask the CPA to add a customer
|
||
or vendor), and, when the file writes anything differently, `note` ends
|
||
"N account or class name(s), number(s), place(s) or memo(s) are written
|
||
differently in general-journal.iif to fit QuickBooks Desktop (warnings
|
||
says how; so does the README): tell the human.", N counting each name,
|
||
number, place and memo.
|
||
**Receipts** (`include_receipts`, default true): `receipts/` holds every
|
||
current file behind each posted entry of the fiscal year to date, filed
|
||
on it or linked to it (`books.link_attachment`; a linked file counts as
|
||
the entry's receipt), one copy per entry, each with a row in
|
||
`receipts/index.csv`; `documents/<category>/` holds the files behind no
|
||
entry, receipts excepted.
|
||
**TINs** (`include_tins`, default true; DECISION, the owner, 2026-10-15):
|
||
the K-1 worksheet and the 1099 worksheet carry every member's and
|
||
payee's full TIN from the vault; the zip, and each file in it with a
|
||
TIN, is recorded so that, given back to be filed, 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. `note` is "The package, with
|
||
last fours only.", or "The package, with full TINs, for the CPA.",
|
||
followed, with gaps, by "N TIN(s) are missing (tin_gaps): collect them
|
||
with books.set_tin or books.set_w9 and export again." **K-1 worksheet** (`k1-members.csv`, with
|
||
`[[company.members]]`): one row per member, in order (with more than one
|
||
member, a last row totals them, "Total, all members (not a K-1)", to tie
|
||
to Schedule L; a worksheet that can't be made is replaced by
|
||
`<name>-NOT-INCLUDED.txt` saying why, the K-1, tax-basis and 1099
|
||
worksheets alike): `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 an entry crediting a member's capital debits to asset
|
||
accounts other than cash and receivables: equipment, software, or a
|
||
prepaid service contract the members owned and handed over straight to
|
||
capital, e.g. AppleCare+ contributed with a laptop) and cash (the rest:
|
||
deposits, and what the members paid for the company, including a
|
||
service they paid a vendor ahead for, booked through Due to
|
||
Shareholders and then converted to capital; DECISION, the owner,
|
||
2026-10-15). 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. **Tax-basis capital** (`k1-tax-basis-capital.csv`,
|
||
P3, D-41, rules.md B-36; Form 1065 instructions, Item L, the tax-basis
|
||
method): per member, `member, item, book, tax_basis, book_less_tax,
|
||
status, note` for each Item L line (beginning; capital contributed,
|
||
cash and property; current year net income (loss); other increase
|
||
(decrease); withdrawals and distributions; ending), the property at its
|
||
§723 basis (`ledger_core::tax_capital`), then the reconciliation of book
|
||
to tax capital by cause (the §704(c) built-in gain or loss share,
|
||
property without a tax basis in the books, property distributed). A
|
||
line the books can't know (tax depreciation of the fixed assets in
|
||
service, a disposal's tax gain, the basis of property distributed or of
|
||
contributed property no asset holds) has `tax_basis` blank, status
|
||
`open: the CPA's`, and a note with what the books know so far and the
|
||
book amount the CPA's figure replaces; never a guessed number.
|
||
**1099 worksheet** (`1099-worksheet-<year>.csv`, `as_of`'s calendar
|
||
year) is `books.vendor_1099`'s CSV (above) for the package's cash and
|
||
financing accounts: the payments from January 1 through `as_of` (the
|
||
year to date; the whole year for a December 31 package; DECISION D-36),
|
||
and the README says which. `books.vendor_1099` given the same
|
||
`cash_accounts`, `financing_accounts` and `through` hands over the
|
||
identical file. With TINs it adds `tin, tin_type, tin_note`. A zip with
|
||
the full set, each
|
||
statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`,
|
||
`cash-flow` (the direct method, as `books.export` prints it: operating
|
||
by class, investing and financing by kind with each member named, and
|
||
its two schedules; reconciled to the package's cash accounts),
|
||
`members-equity`
|
||
(multi-member LLCs), `tax-summary`, `trial-balance`;
|
||
`fixed-asset-register` (PDF and XLSX) when an asset was acquired by
|
||
`as_of`; plus `general-ledger.xlsx` and `general-journal.xlsx`; and
|
||
`README.txt` first (the year, the basis, the journal's verified event
|
||
count and what each file is). Period reports cover the fiscal year
|
||
holding `as_of`, through it.
|
||
What the CPA needs; they never log into the system.
|
||
|
||
## Tools — drafts (P4)
|
||
|
||
A draft is an entry being built (`books.draft_entry`, `books.add_line`) that
|
||
is not in any report until it posts. P4 gives it a state, a way out, and a
|
||
read-only preview (rules.md B-43; the journal feature `drafts_v2`: the
|
||
state and discard tools wait for the books' format upgrade).
|
||
|
||
- `books.list_drafts { state?, as_of? }` → `{ drafts: [{ entry_id, date,
|
||
description, total, state, note, missing_evidence }] }`. `state` is
|
||
`building` or `awaiting_evidence`; `as_of` keeps drafts dated on or
|
||
before a day. `missing_evidence` is true when the receipt policy covers
|
||
the draft and it has no receipt yet.
|
||
- `books.set_draft_state { entry_id, state, note? }` → `{ entry_id, state,
|
||
note }`. `awaiting_evidence` with a note ("Figma invoice due 11/3") says
|
||
the draft is finished and waits for its document; `building` says it is
|
||
being worked on. A state is a note: no line or figure changes, and the
|
||
receipt policy is still checked when the draft posts. Only a draft has
|
||
one.
|
||
- `books.discard_draft { entry_id, reason, confirmed?, preview? }` →
|
||
`{ status: "preview" | "discarded", entry_id, description, total,
|
||
holds_open?, preview }`. Throws away a draft that will never post; only a
|
||
draft (a posted entry is reversed, never removed). Nothing in any report
|
||
moves, the id is not reused, and a period the draft held open can close
|
||
(`holds_open` names those). Previewed first, then confirmed with the
|
||
`preview` digest.
|
||
- `books.preview_entry { entry_id }` → the `books.post_entry` preview
|
||
(`status: "preview"`, lines, receipts, `receipt_required`,
|
||
`receipt_missing`, party and approval checks, `preview`) as a read-only
|
||
call; pass its `preview` to `books.post_entry` with `confirmed: true`.
|
||
|
||
**What a report leaves open.** `books.trial_balance`, `books.profit_and_loss`,
|
||
`books.balance_sheet`, `books.cash_flow` and
|
||
`books.profit_and_loss_by_class` return `as_of_status: {
|
||
drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems:
|
||
[...] }` for the report's end date, and take `strict: true` to be refused,
|
||
naming what is open, instead of warned.
|
||
|
||
## Tools — the journal in words (P5)
|
||
|
||
- `books.journal_history { after_seq?, types?, subject?, limit? }` → `{
|
||
lines: [{ seq, ts, type, summary, subject?, entry_id? }], more, head_seq,
|
||
tail_repairs: [{ at, bytes }] }`. The journal in order, a sentence for
|
||
each line (`summary`: what it did, to what, the short names a person
|
||
typed; never a setting's value, never a full TIN), the line's own
|
||
`subject` (`entry:12`, `invoice:3`, `bill:3`, `account:10`, `party:5`,
|
||
`asset:2`) and the entry it belongs to. `types` takes an exact type
|
||
(`entry.posted`) or a group ending in a dot (`entry.`); `subject` keeps
|
||
the lines about one thing (a line is about the subject its type names and
|
||
every one its payload refers to); `limit` is 50 by default, 200 at most;
|
||
`more` says to ask again with the last `seq` as `after_seq`. Read-only,
|
||
`books:read`. It is how a figure is traced to what produced it and the
|
||
only way to read the journal; `tail_repairs` lists torn tails set aside
|
||
when the books opened (when, how many bytes). The sentences come from
|
||
`ledger-store`'s `describe.rs`.
|
||
|
||
## Prompts (P5)
|
||
|
||
The server advertises the prompts capability (also in the bridge's cold
|
||
`initialize`). `prompts/list` and `prompts/get` need a token that may read.
|
||
Each prompt's text is a file in `ledger-mcp/prompts/`, with the company's
|
||
name and the arguments filled in; it names only tools that exist (a test
|
||
checks). An argument is one token (letters, digits, `-`, `_`, `.`, `:`; at
|
||
most 40 characters) of the shape the prompt asks for, so an argument can
|
||
never rewrite the text; an unknown prompt, a missing, extra or malformed
|
||
argument is refused.
|
||
|
||
| Prompt | Arguments | Drives |
|
||
|---|---|---|
|
||
| `onboard` | none | the company's facts, the chart, the policies, the opening balances, the trial-balance check |
|
||
| `reconcile` | `account` (a code), `month` (YYYY-MM) | the statement, `books.record_statement`, `books.mark_cleared`, `books.reconcile` |
|
||
| `month_end_close` | `month` (YYYY-MM) | the company's own close steps, reconciliations, drafts, schedules, checks, `books.close_period` |
|
||
| `cpa_handoff` | `year` (YYYY) | `books.verify`, the periods, 1099 work, `books.export_cpa_package` |
|
||
| `annual_compliance` | `year` (YYYY) | the year's filings, 1099s, assets, open items |
|
||
| `audit` | `scope` (`month`, `annual`, `cpa_handoff` or `full`) | `books.verify`, `books.control_check`, the digest, evidence against entries (P8 fills in its own tools) |
|
||
|
||
`migrate`, `process_inbox` and `receipts_from_email` are not built (D-46,
|
||
D-28, D-17).
|