Commit Graph
294 Commits
Author SHA1 Message Date
swaitsandClaude Fable 5.1 335a3eaa7e merge: P4b S7, bad debts (branch S7, 4b4fecb9)
The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:47:33 -06:00
swaitsandClaude Fable 5.1 be58059830 merge: P4b S6, paying on terms (branch S6, 6c4480cd)
The third of the paused branches: Ledger::settle with discounts and the
processor's fee (kernel::settle, Kani settle_is_exact, Lean
settle_posts_a_document_step), books.receive_payment and books.pay_bill
with applications, books.bills_due, the agings' detail and tie-out, the
aging exports and the CPA package's aging CSVs, the handlers in
tools/settlements.rs. Twelve files conflicted with the S4.4 and S5
merges, resolved as plan 8.0.2 says: both routers; both test appends
(the shared match arm in arap_standing.rs spliced into both tests; the
shared `.unwrap()` in store.rs); the scope table at 124; one
invoices-and-bills handbook (S5's send step 1 with S6's steps 2, 3, 5
and procedures) registered once; docs/mcp-interface.md with S4.4's
create docs and S6's payment docs; S5's law renumbered 35 and S6's 36
(S1's is 34); the parity rows and sections of both. S6's wire tests
create their bills as S4.4's contract now requires (confirmed, the
vendor's number, a line account, the vendor's invoice uploaded) and live
in their own module beside S4.4's tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:26:40 -06:00
swaitsandClaude Fable 5.1 3a3f03d884 merge: P4b S5, sent tracking (branch S5, ad649be3)
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The second of the paused branches. invoice.sent, Ledger::mark_sent (with
its Lean step), books.mark_sent in its own group, the two attention
items, schema 17. Conflicts, resolved as plan 8.0.2 says: the shared
over-the-wire test file crates/ledger-cli/tests/documents.rs (S4.4's
file kept whole, S5's test appended, its create call given the
confirmed: true the S4.4 contract now needs), types.rs (both appends
kept), docs/parity.md (the review's oracle row and S5's new row).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:21:21 -06:00
swaits 485bd4ae5f feat(attention): unsent and unreminded invoices (P4b S5)
The books record that an invoice went out but do not chase it. U0b's
one attention engine now lists two items the sent record makes possible,
in the viewer's Needs attention and in books.start's next steps alike:

- "Invoice INV-0007 was never sent": an issued invoice (a number) still
  Open or Partial, with no send recorded, once today is past its issue
  date plus invoicing.unsent_days (default 2). A legacy invoice with no
  number is exempt, and so is a void or settled one.
- "Invoice INV-0007 is overdue: remind the customer": the invoice is
  overdue and its last send is older than invoicing.reminder_days
  (default 14). An invoice never sent gets the first item, not this one.

Both are Todo, open the invoice, and offer books.mark_sent with its
invoice and the tool's defaults. The viewer maps the new
Target::Invoice to its invoice page (one line; items still come only
from ledger-package), and the two lists cannot drift.

Tests: an_unsent_invoice_is_listed_until_a_send_clears_it (the overview
draws it; attention-unsent-invoice.png; a send clears it),
a_legacy_invoice_with_no_number_is_never_listed_as_unsent,
an_overdue_invoice_with_an_old_send_is_asked_to_remind, and
an_unsent_invoice_is_on_both_lists (viewer and books.start, step
books.mark_sent on invoice:1). The existing attention parity test stays
green.
2026-10-27 13:46:43 -06:00
swaits 4b4fecb98e feat(gui): the journal says a write-off in plain words (P4b S7)
R-B: a new event type gets a describe arm, so the journal's list reads
"Invoice 7 written off, 60.00" and opens the entry, rather than printing
the type at a human. every_journal_event_has_plain_language_text scans
store.rs for the types and fails on any without text.
2026-10-27 13:27:27 -06:00
swaits adc8804f61 refactor(mcp): books.mark_sent in its own sent group (P4b S5)
S4.4 is working in tools/documents.rs, so the send tool moves beside it
rather than into it: `crates/ledger-mcp/src/tools/sent.rs`, declared like
every other group and named in docs/architecture.md's layout. The two
helpers the document read tools share (`sent_out`, `last_sent`) go to
tools/helpers.rs, which the groups share; the handler and its own
argument readers stay private to `sent`.

No tool name, schema or behavior changes: books.mark_sent is where it
was on the wire, only in a file of its own.
2026-10-27 13:24:10 -06:00
swaits 3ea008bb54 feat(viewer): the journal says an invoice went out (P4b S5)
R-B item 7: every journal event gets a plain-language sentence, so the
new `invoice.sent` reads "Invoice 7 sent by email" (or "reminder")
instead of showing its event name. A send has no entry to open, so the
line has no route: it is a document event, not a posting.
2026-10-27 13:24:10 -06:00
swaits 458ca19965 test(mcp): exercise books.mark_sent in the every-tool journey (P4b S5)
`inv1_every_tool_result_matches_its_output_schema` fails listing any
tool the happy-path journey never calls successfully; the new send tool
must be in it. The journey records one send on the invoice it already
created (the books never send), so its output schema is exercised on
every run like every other tool's.
2026-10-27 13:24:10 -06:00
swaits 75d8c23d9b feat(mcp): books.mark_sent, and the send in the read tools (P4b S5)
`books.mark_sent` records the send the human or a delegate made: the
invoice (`invoice:7` or its id), the day, who sent it (the token's name
unless given), how (email by default, or mail, portal, hand, another
channel), to whom (the bill-to email, else the customer's), whether it
was the original or a reminder (original on the first send, reminder
after), and the channel's own id. It answers with every send on the
invoice, newest last. `books.documents` rows gain `last_sent` and
`books.document` shows the sends, so "did we send it, and when" is one
read away, while a bill (which we receive) has none.

The schema lint refuses a parameter named `to` (a path-name rule), so
the recipients parameter is `recipients`; the journal event and the
result keep the plan's `to`. The tool needs `books:write`: it changes
the record, never a figure.

The handbook gains the invoices-and-bills section (S4.5 had not landed
yet) with the send procedure: the books never send, the PDF goes to the
human, and each send is recorded afterwards.

Over the wire, mark_sent_records_who_and_how: two sends (the second a
reminder with a message id), the list's `last_sent`, the document's
`sends`, and the invoice still owing its total. The P2 cutover refuses
a send before consent and takes it after.
2026-10-27 13:24:10 -06:00
swaits d1fb512e4b feat(arap): record an invoice's sends as invoice.sent (P4b S5)
The books never send invoices (A-4), but they must remember that one
went out: who sent it, how, to whom, when, whether it was the original
or a reminder, and the channel's own reference. That record is a fact
of the document's life, not a figure: a send moves no money, so it is
one journal line (`invoice.sent`) and no entry, and every derived
figure (outstanding, aging, DocState) is exactly as it was.

`Ledger::mark_sent` refuses a send on a void invoice (it should never
have reached the customer), a send dated before the invoice, no
recipients, and any recipient, reference or note that would put a full
TIN or control characters into the journal. Open, partial, paid and
written-off invoices can be sent (a copy for the customer's records, a
reminder).

The event is `documents_v2`'s (D-52): an older version refuses the
type rather than dropping the record. Replay restores it and the
projection keeps it in schema 16's `invoice_sends` table, so live,
replay and the viewer agree. The new old-books fixture `p4_s5_before`
was written by eeee9658 and opens to the same figures; a hand-written
send in `books_written_under_older_rules_still_open` proves an older
journal with the new line still opens.

Tests: mark_sent_waits_for_documents_v2, sends_replay_and_project,
a_send_changes_no_figure, mark_sent_on_void_refused, Op::MarkSent in
status_is_a_function_of_journal_and_date,
an_older_version_stops_at_invoice_sent.
2026-10-27 13:24:10 -06:00
swaits 6c4480cdc8 feat(package): aging CSVs in the export and the CPA package (P4b S6)
The CPA's first tie-out is that the aging equals the control account, so
books.export and the CPA package now write ar_aging.csv and ap_aging.csv
from one builder: one row per open document (number, party, dates, terms,
total, paid, discounted, outstanding, bucket, days overdue) and a TOTAL
line carrying the outstanding sum, the GL account and balance, and the
difference (always 0.00; B-23, B-38 INVARIANT 1). XLSX and PDF lay the
same aging out as a statement with the tie-out beneath it.

The package README states the tie-out, the gross discount method (A-8)
and the direct write-off method (A-5). The chart templates gain 6995
Purchase Discounts so no template puts another account at a control
role's code (4100 and 6060 already match). The viewer's Money out list
gains the discount-deadline column and colours a due-soon or
discount-open date; the screenshot is re-rendered.

Test: the_aging_csvs_tie_to_the_control_check in ledger-package; the
existing package file-order test and mcp_journeys see the two new files.
2026-10-27 12:59:40 -06:00
swaits 57f04dbb0c feat(mcp): payments on terms, bills due, and the agings' tie-out (P4b S6)
books.receive_payment and books.pay_bill take applications, each
document's cash with an optional discount and reason, and a customer
payment may carry the processor's fee; the result names every document it
settled and what reached the account. books.bills_due lists what to pay
and the running total by due date. The agings take detail and return the
control account's tie-out, and books.export writes ar_aging/ap_aging.

The handlers live in tools/settlements.rs (the EM's split, so S4.4's
documents.rs stays free); documents.rs keeps the documents, the void and
the control check. docs/mcp-interface.md documents every changed tool and
the new one; docs/architecture.md names the group; docs/testing.md
records the tests and P12's mutation targets.

Old names (document_id, cash_account) still work; the output keeps
remaining/status for one document. A fee's processor is named in the
description, not tagged as the entry's vendor: ledger tags are entry-wide
and tagging would move the customer's party totals and 1099-NEC.

The handbook's invoices-and-bills section gains the payment, discount,
fee and bounce-replacement procedures (S4.5 can extend it). Over the wire:
one_payment_settles_two_invoices, late_discount_needs_a_reason,
a_fee_posts_gross_to_receivables_and_net_to_the_bank,
card_paid_bill_is_not_1099_nec,
multi_document_payment_counts_once_for_1099_and_party_totals,
overpayment_refused_with_the_excess,
bills_due_orders_by_due_and_discount and the_aging_export_ties_out.

Terms cannot yet be journaled on a document (S4.3) and a bill line's
account is not an MCP field (S4.4), so bill_on_terms_with_discount,
equipment_bill_payment_is_investing_cash and the discount half of
bills_due are core tests until those land; the unit's As-built block
records this.
2026-10-27 12:59:40 -06:00
swaits 78a2385440 feat(store): journal settlements and replay their lines (P4b S6)
Store::settle now journals a settlement once, line for line, and one
payment event per application, so replay rebuilds the same tags and the
projection sees the same entry. A single legacy-shaped payment is still
journaled byte for byte; more than one document, a discount or a fee
needs documents_v2, and a payment accepted only because an earlier one
was reversed still needs document_standing.

Each event names the control line it tags (line) and, when it took one,
its discount and discount line; the fee is on the first application's
event with its line. Replay tags exactly those lines and leaves a line it
cannot match untagged, which books.control_check lists; a legacy event
without line keeps the first-untagged-control-line rule.

Tests: a_settlement_names_its_lines_and_replays (two invoices, a discount
and a fee; event indices match the plan; replay equals live),
settlement_waits_for_documents_v2 (refused with the feature remedy and no
journal line, accepted after the upgrade), and
legacy_payment_events_keep_the_positional_rule. arap_props gains a
Settle op (two documents, discount, fee), and
books_written_under_older_rules_still_open gains a hand-written payment
event of the new shape through JournalWriter (8.0.6 item 1).
2026-10-27 12:59:40 -06:00
swaits 10ee0681c1 feat(arap): settle documents with discounts and a fee (P4b S6)
One payment had to name one document and could take no discount or
processor fee, so paying several invoices from one deposit meant several
entries and the fee had nowhere to go. This adds the kernel's exact
settlement arithmetic (applications total, net deposit), a Ledger::settle
that posts one entry for a customer's or vendor's documents, and the gross
method and gross fee A-8/A-4 chose.

- kernel::settle: sum(cash)+sum(discounts)=applications, sum(cash)-fee=
  deposit, None on overflow or a negative deposit. Kani settle_is_exact
  plus the Decimal bridge.
- Ledger::settle / settlement_lines: one party and one kind per
  settlement, one application per document, each cash+discount within
  payment_room; a discount within the terms (discount_max and
  discount_by) needs no reason, a larger or later one does; a bill with
  an asset line is refused with D-49's remedy; a fee is a customer
  payment's only and posts gross.
- Rows are payment lines in application order, then discount lines, the
  fee line and the cash line, so a journaled event's line indices are
  stable; replay tags exactly the line an event names and leaves an
  unmatched line unattached (never refuses).
- receive_payment_with and pay_bill_with become one-application calls.
- Roles: sales_allowances (4100), purchase_discounts (6995) and
  payment_processing_fees (6060), constants used on demand but not added
  to control_accounts() (existing charts may use those codes for other
  things).
- Lean: settle posts through Step.document, so settle_posts_a_document_step
  and settle_unattached, no new constructor; both in Audit.lean.
- Core tests: bill_on_terms_with_discount, late_discount_needs_a_reason,
  discount_on_an_asset_bill_is_refused_with_the_remedy,
  a_fee_posts_gross_to_receivables_and_net_to_the_bank,
  a_settlement_names_its_lines_and_refuses_a_document_twice, and
  equipment_bill_payment_is_investing_cash in the cash-flow suite.
- Bookspec B-42 is stated, with its id BS-arap-6; the catalogue and index
  now record it as CONVENTION.

just check and just prove green.
2026-10-27 12:59:05 -06:00
swaits 176baa5250 test(arap): the role inventory gains bad_debts (P4b S7)
B-41 gives the write-off its own expense role (6990 Bad Debt Expense,
8.3.4), so the pinned inventory of control roles has thirteen keys, not
twelve; the new role's key, default code and type are checked with the
rest. No other role changes.
2026-10-27 12:44:45 -06:00
swaits 22cdfcf34b refactor(arap): rustfmt the unit's files, and the write-off's SOURCE line (P4b S7)
No behaviour: the files as rustfmt leaves them after the unit's edits
(the commits before this one were made before the formatter ran), and the
Ledger::write_off spec block cites A-5 alone for the direct write-off
method (A-6 is the hand-entry refusal, not this command).
2026-10-27 12:44:45 -06:00
swaits e801908a88 test(mcp): exercise the write-off in the happy path (P4b S7)
spec_mcp's inv1 walks every tool once and fails on any the books never
answered: the happy path now creates an invoice that will not be paid,
uploads the collection letter, and writes it off with the evidence in the
same call, so books.write_off_invoice's output schema is checked like
every other tool's.
2026-10-27 12:44:45 -06:00
swaits 0322778683 docs(arap): B-41, the bookspec examples, and the four registers (P4b S7)
rules.md B-41 states the write-off: the direct method the owner chose
(A-5), Dr 6990 / Cr 1200 with the receivable tagged, at most what is
owed and at most the least owed from that day on (a partial write-off as
§1.166-3(a) allows), refused on cash-basis books (§1.166-1(e)), its
evidence filed in the same call (§1.166-2(a)); bills are never written
off (ASC 405-20-40-1); and a recovery is not a new movement (D-49): the
same year, reverse the write-off and record the payment; a later year, a
plain entry to a bad-debt-recoveries revenue account, the invoice staying
written off (IRC §111).

bookspec states B-41 for the first time and gives B-38 its example: two
new examples, bad-debt-direct-write-off (two partial write-offs of one
invoice, the balances and the trial balance worked by hand) and
arap-hand-control-posting-refused (a hand line an earlier version wrote
replays, the same line written today is refused). The example schema
gains the dated write_off event, lets a refused entry say so (an entry
posts whole or not at all; older and refused never combine), and its
refusal vocabulary gains untagged_control_line, B-38's control guard.

The registers: docs/architecture.md names the new tool group;
docs/receipt-store.md lists invoice.written_off and what replay does with
it; docs/parity.md records the slice (no row changes status, the floor
stands); docs/testing.md records every test and what P12 must mutate;
docs/verification.md the two Lean theorems and what they cover.

No figure moves: no golden file changed.
2026-10-27 12:44:45 -06:00
swaits b731d7ed04 feat(arap): a write-off on the timeline, and the CPA's note (P4b S7)
The viewer's invoice page already drew a WriteOff movement; now the
fixture proves it: a written-off invoice reads Written off, owes nothing,
and its timeline's "Written off" row opens the entry (the screenshot
invoice-written-off).

Needs attention gains the one item the tax rule asks for: a write-off
made this calendar year is listed, one per invoice, as "Write-off
awaiting the CPA's note", with what was written off, the day, and the
prompt that opens the document. Whether a debt is worthless, and in
which year, is a tax judgment that stays with the CPA (plan 8.3.16); the
books flag it and keep the evidence beside it until the year ends. The
item is computed in ledger-package's one attention function, so the
viewer and books.start see it alike; view_model.rs compares the two reads
on the same books.

The handbook's invoices-and-bills section starts here (S4.5 adds the
issue, terms and payment steps around it): step 4 says when to void, to
write off, or to reverse, and the recovery procedures D-49 records — the
same year, reverse the write-off and record the payment; a later year, a
plain entry to a bad-debt-recoveries revenue account, the invoice staying
written off (IRC §111).

Tests: a_write_off_shows_on_the_timeline,
a_write_off_is_flagged_for_the_cpa.
2026-10-27 12:44:45 -06:00
swaits 0f1c7786ab feat(mcp): books.write_off_invoice (P4b S7)
One tool for the bad debt (rules.md B-41): it writes off an invoice that
will not be collected, Dr 6990 Bad Debt Expense / Cr 1200 Accounts
Receivable, the receivable line tagged with the invoice so the document
reads written_off and its figures stay derived (B-38).

The evidence is the point, and the tool takes exactly one of
upload_id (an upload, filed with the write-off) and
existing_attachment_id (one already filed, linked to the write-off's
entry and the invoice); neither or both is refused. amount defaults to
what the invoice owes and may be partial.

Without confirmed it is a preview: the engine runs on a clone (every
refusal the post would give) and the answer is the figures the write-off
would leave, entry_id 0, nothing journaled.

The tool lives in its own group file, tools/write_offs.rs (plan U0a), so
S4.4's document tools and this one land apart; it is destructive, needs
books:write to preview and books:post to post (scopes.rs), and is
documented under "Tools — A/R and A/P". The destructive-tool count, the
tool tables and the journey test's tool count each move by one.

Tests over the wire: write_off_then_recovery, write_off_needs_evidence.
2026-10-27 12:44:45 -06:00
swaits 4e71cf6320 feat(store): journal a write-off with its evidence (P4b S7)
A bad debt is deducted on evidence it is worthless (Treas. Reg.
§1.166-2), so the store will not journal one without it:
Store::write_off_invoice takes the evidence as bytes to file or as an
attachment already filed, and files it in the same call — a
receipt.attached on the write-off's entry and an attachment.linked to
the invoice (rules.md B-41). With no evidence it refuses, journaling
nothing.

The entry (Dr 6990 Bad Debt Expense / Cr 1200) and its evidence are
journaled after the engine's whole check and after the feature gate: the
new event type invoice.written_off needs documents_v2, so an older
version stops at the declaration rather than reading a written-off
invoice as still owed (B-27, D-52). Its payload is the invoice, the
entry, the day, the amount and the reason; replay tags the receivable
line WriteOff, and the projection keeps no written-off figure — it is
the tagged lines' sum (B-38).

`meta::invoice_written_off_of` reads and writes the payload; the replay
fuzzer draws the type.

8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes the
new event and its entry through JournalWriter and still opens, with the
figures tailing; (2) a new fixture old-books/p4_s7_before was written by
main 4cad2cfc (the version before this unit) through the old-books-writer
harness — a numbered invoice with terms and snapshots, a partial payment,
a voided invoice and a paid bill — with its expected.json; this version
opens it and reports the same figures, the viewer included; (3) the
cut-over entry is in format_upgrade.rs's books_p2_served_cut_over_whole;
(4) documents_v2_is_its_own_feature is green and the event gate's arms
pass; (5) no figure moves: no golden changed (the previous version's
expected.json is the comparison); (6) merge does not need the owner's
go-ahead; the live books refuse the new write until he gives it.

Tests: write_off_waits_for_documents_v2, write_off_replays_and_projects,
write_off_without_evidence_is_refused; arap_props.rs gains WriteOff.
2026-10-27 12:44:45 -06:00
swaits d5af44b9cb feat(arap): write off a bad debt in the engine (P4b S7)
An invoice that will not be collected comes off the receivables and is
deducted, the direct write-off method the owner chose (A-5; IRC
§166(a); Treas. Reg. §1.166-1(e), -2(a), -3(a); rules.md B-41).

Ledger::write_off posts one balanced entry, Dr 6990 Bad Debt Expense /
Cr 1200 Accounts Receivable, its receivable line tagged WriteOff so the
figures stay derived: what the invoice owes falls and what was written
off rises, and a reversal re-instates it without a new movement (D-49).
The amount is at most what is owed, and at most the least owed on any
day from the write-off's on: a later payment can't leave the invoice
owing less than nothing (B-38 INVARIANT 2). A partial write-off is
allowed, as §1.166-3(a) allows to the extent charged off. Cash-basis
books refuse it: an invoice never counted as income has no basis to
deduct (§1.166-1(e)). The bad_debts role (6990) is a control role, so
the account is created on demand and can be assigned like the others.

The same-year recovery D-49 records is a reversal of the write-off and
then a payment; a later year is a plain entry, and needs no movement.

Tests: half_paid_rest_written_off_is_written_off,
write_off_refused_on_cash_basis, write_off_above_outstanding_refused,
write_off_then_recovery_same_year.
2026-10-27 12:44:45 -06:00
swaits d5b9e5cf99 feat(mcp): the create tools preview and confirm, and the party keeps its contact block (P4b S4.4)
An invoice is issued, not edited. Without `confirmed` the create tools now
preview: the posting (with the accounts' codes, which the live chart may
not hold yet), the number it will take, its due date, terms and early
discount, warnings, and for an invoice the DRAFT copy of the PDF, handed
over as a file. Nothing is written and no number is used up. With
`confirmed` the invoice posts, takes the number the preview showed, and
files the issued copy with itself in the same call: `receipt.attached` on
its entry plus the link to the invoice, its SHA-256 in the event (A-1,
A-7, B-40). The copy the customer received is therefore the copy in the
books, and `books.document`'s re-created page can never be mistaken for
it. The result also carries a `mailto` link for a human to send it; the
books never send (A-4).

A bill keeps what the plan says a bill is: the vendor's own invoice
number, the vendor's invoice filed on its entry as its evidence, and, in
books that have declared `documents_v2`, an account on every line, so no
bill can land in General Expenses by default any more (A-9, A-10, A-13).
A vendor number entered twice is refused unless the caller says why, and
the reason is journaled with the second bill (B-40 item 4). Books that
have not declared the feature keep recording bills exactly as before, so
the owner's books keep working between units (D-39, 8.0.4).

Terms are resolved in one place and in one order: the due date given,
else the terms given, else the customer's or vendor's own default, else
the books' `invoicing.default_terms`; a document given only a due date
stores no terms, so an old-shape invoice is still written byte for byte
as before (B-39, A-13).

D-55: a party carries the owner's full contact block (phone, contact
person, billing and shipping addresses, billing email). It rides the same
`party.updated` event behind `documents_v2`, and an invoice's bill-to is
snapshotted at issue with the billing address and billing email in place
of the general ones when the party has them — `PartySnapshot::from_party`
is the one place that rule lives, so a later edit cannot change what was
sent, and the next invoice takes today's details.

Tests: the wire tests are the new `crates/ledger-cli/tests/documents.rs`
(preview writes nothing, the DRAFT copy, the issued copy's SHA-256 equals
`books.get_receipt`'s, numbers run, a bill's file and terms, the duplicate
refusal, the line-account rules, the terms fallback, the `party_id` alias
and the ambiguity it refuses, the contact block's set and clear, and the
bill-to at issue); `format_upgrade.rs` gains
`documents_v2_refused_until_consent` and the cut-over test now refuses and
then makes a document carrying a new field (8.0.6 item 3); the store
tests are `the_contact_block_round_trips_and_replays` and
`an_invoice_keeps_the_billing_details_it_was_issued_with`, and
`books_written_under_older_rules_still_open` gains a hand-written
contact-block event (8.0.6 item 1).

Merge does not need the owner's go-ahead: the live books refuse a write
that needs `documents_v2` until he gives it, and take every other write as
before (8.0.6 item 6). No figure moved: S4 moves none.
2026-10-27 12:23:32 -06:00
swaits 0e5aebda99 feat(store): journal the document fields and file the issued PDF (P4b S4.3)
S4.1 gave documents a number, terms, line accounts and snapshots, and S4.2
gave parties an email and default terms, but the store journaled none of
them: a document carrying one was refused by a temporary guard, because a
public write that replay loses would break I-3. This slice makes the
journal carry them.

- `invoice.created` and `bill.created` write a number, terms, bill-to and
  seller snapshots, references, memo and a duplicate's reason, and each
  line its account, quantity and unit price, every key only when set. A
  document naming none is journaled byte for byte as every earlier version
  wrote it (`no_new_fields_journals_byte_for_byte`), so the owner's live
  books keep working.
- All of it is behind `documents_v2` (B-27): a write that names one waits
  for the owner's upgrade, refused with `format_upgrade_pending` and
  journaling nothing; a write that names none never waits
  (`documents_v2_waits_for_the_upgrade`).
- `invoicing.first_number` is settable only while no numbered invoice
  exists (A-2, B-40): the series has begun, and a number is never reused or
  renumbered. Refused at `set_setting`, before anything is journaled.
- The issued copy is filed in the issuing call (A-7, B-40 item 3):
  `create_invoice_issued` journals the entry, the invoice, then
  `receipt.attached` on that entry (`INV-0007.pdf`, `application/pdf`,
  "Issued invoice") and `attachment.linked` to the invoice. There is no
  `issued_pdf` field: the attachment id is allocated after the entry exists
  (CD-A6), and the link is how the copy is found and how its SHA-256 is
  checked. Every refusal happens before the first journal line; the PDF's
  bytes are stored before it, so a refused issue leaves an unreferenced,
  deduplicated blob at worst.
- A preview runs the issue on a clone and journals nothing: the posting,
  the total, the number it would take and the due date (`preview_invoice`,
  `preview_bill`). Books that haven't upgraded keep the legacy shape: the
  copy is filed as "Invoice {id}.pdf" and no number is taken (migration
  item 5).
- Schema 16 adds the document columns; a line's new fields ride
  `lines_json` as its project does. `differs_from` already compares whole
  invoices, bills and parties, so it sees every new field.

8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes both
new document shapes through JournalWriter and still opens; (2) a new
fixture `old-books/p4_s43_documents` was written by main d5385a7e (S4.2,
the version before this slice) through the old-books-writer harness, with
its expected.json; this version opens it and reports the same figures;
(3) see the note on R-A step 4 in the PR (the over-the-wire document write
needs S4.4's arguments; S4.2's invoicing-setting entry already covers the
feature's cut-over); (4) `documents_v2_is_its_own_feature` is green; (5) no
figure moves: no golden changed; (6) merge does not need the owner's
go-ahead; the live books refuse the new writes until he gives it.

just check green (no kernel function, Lean theorem or Ledger command, so no
`just prove` change).
2026-10-27 10:37:03 -06:00
swaits c8541ea9fc fix(deploy): the tunnel's door binds loopback only (P9a)
Review finding: `--http` parsed whatever address it was given and bound
it. The door is the plaintext MCP service with a static bearer token;
plan 3.9 and the P9a model put a TLS proxy or tunnel in front of a
loopback listener, and no decision authorises exposing the books
unencrypted. `--http 0.0.0.0:7411` would have done exactly that.

`local::tunnel_address` now accepts only a loopback address
(127.0.0.0/8 or ::1; `127.0.0.1:7411` stays the default) and refuses
anything else with a message naming loopback and why. `server` refuses
while parsing, before it takes the lock or binds a socket; `serve`
refuses before it starts a host, so nothing is left half-open. The
bind-failure behaviour for a loopback address is unchanged (said, not
fatal, 3.9), as is the unit's hardening.

The test `the_tunnel_door_binds_loopback_only` refuses 0.0.0.0, a public
sample address and [::] through `server`, checks no socket or host was
left behind, refuses through `serve`, then starts a loopback listener and
reaches `books.start` with the bearer.

No accounting code, report or figure changes.
2026-10-27 09:35:36 -06:00
swaits 1441dabcca feat(deploy): self-host recipe, the enforced isolation flag, and the tunnel's door (P9a)
The books can live on a machine of the owner's own (plan 3.8, 8.2 P9a,
D-51). Three things make that honest instead of nominal:

- `--isolation same_user|enforced` sets `server::Isolation`, carried
  through `Served` and `BooksServer` to `books.start`. Locally the books
  stay `same_user (advisory)`; under the self-host unit the server is told
  `enforced` and reports `service_user (enforced)`. The server never
  guesses, and the literal it used to report is gone.
- `--http [ADDR]` binds the same MCP router on loopback (default
  127.0.0.1:7411) beside the Unix sockets, before the ready line, for the
  TLS proxy or tunnel in front of the server. It is the same bearer door
  and the same graceful close; a failed bind is reported and is not fatal
  (3.9). `serve` carries both flags through the host, so the system unit
  can ask for them once.

`deploy/wanderbooks-server.service` is the unit: a dedicated account, a
0700 data root, `ProtectSystem=strict`, no seccomp or Landlock (D-32), and
loopback-only networking. `deploy/README.md` is the recipe: install, setup,
move the books by `books adopt` with its head check (D-31: a copied book
is the transport), mint a bearer token, put Caddy or Tailscale Funnel in
front of the loopback door, and point Claude Code and Codex at it.

The two-account CI job (`.github/workflows/two-user.yml`) makes the
deployment and runs `crates/ledger-cli/tests/two_user.rs` as another
account: `agent_uid_cannot_read_tenant` proves the tenant root and its
journal are refused, then reads, writes, uploads, files, exports and
verifies over HTTP with the bearer (in a second book, so the moved books'
head is not advanced); `self_host_adopt_same_head` compares the moved
books' head and event count with the source clone's.
`isolation_label_is_enforced_under_the_unit` runs everywhere, including
the unit-file read that keeps the recipe from drifting.

Accounting reason for the shape: nothing here changes an entry, a report
or a figure. It changes who can reach them: the move is lossless and
verifiable at the head, and the boundary is the operating system's, not a
promise in our text. No journal code, kernel function, Kani harness or
Lean step is touched, so `just prove` is not required.

No go-ahead needed: no new journal feature or event; the live books are
unaffected.
2026-10-27 09:35:36 -06:00
swaits 26d3a9941b fix(package): books.start runs the real integrity check (U0b)
books.start passed Integrity::default() to the attention engine, so it
asserted "database matches, no file failures". On a book whose database
differs from the journal, or whose stored file is missing or corrupt, the
viewer showed the database/stored-files items and the server did not: the
two lists diverged exactly where they matter most.

Call find, which computes the check: every distinct stored blob is read
and hash-checked once (a damaged blob is still reported per attachment
filed from it), and Store::disk_check now makes the viewer's comparison
through a snapshot of its data directory instead of reporting a match.

The parity test now damages its own fixture (removes the receipt's blob,
then changes a projected account row) and asserts both lists still agree
item for item and both name the damage. On the demo books (138
attachments, 138 distinct blobs, debug build) books.start takes about
0.26 s warm, 0.29 s cold, so the real check costs no decision.
2026-10-27 06:12:55 -06:00
swaits 0cf2f8f5c5 feat(package): one attention engine over PackageBooks (U0b)
Needs attention was computed by the viewer alone, while books.start
hand-picked a handful of next steps. The human's list and the agent's
could disagree, and a new item had to be written twice (or, as
books.start showed, not at all).

Move the engine to ledger-package over PackageBooks: one function,
find/find_with(books, today, settings, integrity), reads the ledger, the
journal's event types (an entry's origin), the attachments and their
links, the statements, the reconciliations, and the database check. The
viewer's attention module now only maps Target to Route; books.start
turns the same items into its next steps (why = the item's title, top 12),
so the list the human sees and the steps the agent gets cannot drift.

PackageBooks gains settings, journal, statements, reconciliations and
disk_check, implemented for Store and Snapshot, so both views answer
alike. The W-9, reconciliation and 1099 predicates move beside the items
to keep one copy. ledger-package gains ledger-config and serde_json (the
receipt policy and the tool arguments the items carry).

Tests: view_model.rs and spec_kept.rs pass unchanged; the new
attention_parity.rs compares the viewer and books.start item for item on
the demo books and on a book written entry by entry. just check green.
2026-10-27 06:12:55 -06:00
swaits 3cab75c494 refactor(mcp): split server.rs into a file per tool group (U0a)
server.rs had grown to 12,566 lines: every tool handler, every helper and
the request machinery in one file, so every unit that adds a tool touched
it and parallel worktrees conflicted on every merge (plan/served.md 8.2
U0a, 8.0.3). The 98 handlers still in it now live one file per group under
tools/, following the pattern tools/kept.rs and tools/projects.rs already
use (`#[path]` declaration, `use super::*`, one
`#[tool_router(vis = "pub(super)")] impl` per file):

  documents (invoices, bills, payments, voids, books.documents,
  books.document, the control check, the agings), entries (draft, line,
  post, reverse, get, search, the bank CSV import), reports (the
  statements, books.show, verify, digest, export, the CPA package, the
  P&L by class, with the export builders), assets, schedules, periods,
  reconciliation, parties (parties, vendors, customers, W-9s, the TIN
  vault, 1099s, entry parties), attachments (receipts, filed documents,
  links), settings (the handbook, books.settings, FX rates), chart,
  payroll, payouts, tax; add_class and list_classes join the existing
  projects file, since a project is a class with its facts.

The helpers only one group uses moved into that group's file; the ones
several groups share moved to tools/helpers.rs, re-exported by server.rs
so every group reaches them through `use super::*` and no group file
reaches into another's (resolve_instrument and the attachment/statement
shapers moved there too, since the payment, entries and attachment tools
all use them). server.rs keeps BooksServer, the store and its lock, the
request machinery, call_tool/get_info, tool_router (which now composes
the group routers; core_router is gone), and the shared helpers the plan
names (as_of, hand_over, resolve_vendor, parse_doc_ref, preview_lines_for).

This is a move, not a change: no accounting behaviour, tool name,
description, annotation or schema changed. A dump of
tool_router().list_all() — every tool's name, title, description, input
and output schema and annotations, in order — is identical to main's
(122 tools; the plan's "118" is stale). server.rs is 1,909 lines and has
no #[tool] handler. The server's cross-group tests moved to
server_tests.rs beside it (a #[path] module; fixtures in
server::test_support) and each group's own tests into its group file, so
server.rs stays under the 3,000-line bar. cargo test -p ledger-mcp: 99
passed; cargo test -p ledger-cli: 25 suites, 0 failed.
2026-10-27 04:15:47 -06:00
swaits bf91c5a7c2 test(settings): S4.1's Terms canonicalizes a short custom date (P4b S4.2)
The landed Terms::parse reads a date chrono reads and prints it padded, so
"Due 2026-1-5" is one value with "Due 2026-01-05", not a refusal. The
settings key still keeps one spelling; the test now asserts that
canonicalization instead of refusing the short form.
2026-10-27 03:44:24 -06:00
swaits 98320b3018 test(store): S4.2's shapes open old books and wait for consent (P4b S4.2)
The 8.0.6 checklist for this slice, which adds fields on party.updated
(an email and two default terms) and the invoicing settings it guards:

1. books_written_under_older_rules_still_open now hand-writes a party's
   email and terms and an invoicing setting through JournalWriter, and the
   books still open, verify and replay them.
2. A new old-books fixture, p4_s42_settings_parties, was written by the
   version before this slice (1bfce915, through the old-books-writer
   harness) with a party, a vendor, two posted entries and three settings,
   and its expected.json from that version's code. This version opens it,
   verifies it and reports its figures identically.
3. books_p2_served_cut_over_whole gains a refused invoicing setting before
   the go-ahead; the same call succeeds after it.
4. documents_v2_is_its_own_feature: books that declared every earlier
   feature stop at its declaration as newer, wait for the upgrade, and
   name exactly it in the upgrade.
No figure moves (item 5): settings and party facts enter no report.

Merge does not need the owner's go-ahead; the live books refuse the new
party and setting writes with the upgrade remedy until he gives it.
2026-10-27 03:44:24 -06:00
swaits 962b52c991 refactor(settings): the slice's contracts and notes (P4b S4.2)
invoicing.first_number's refusal (LedgerError::FirstNumberLocked) lands
as the contract the store's check raises once invoices carry numbers
(S4.3): a series that has begun is never renumbered (B-40, A-2).

Also: the settings invariants name the number prefix's bound, update_party's
doc says which facts wait for documents_v2, and the email check no longer
reads as a single-arm match. Terms is S4.1's as it landed; this slice no
longer carries a copy.
2026-10-27 03:44:24 -06:00
swaits 02cb7f0254 feat(parties): an email and default terms per side (P4b S4.2)
A customer's email is copied onto an invoice's bill-to snapshot at issue,
and a party's default terms (one as our customer, one as our vendor) are
the fallback a document's due date takes when the document itself names
none (A-13, rules.md B-39). Both are journaled as party.updated facts and
guarded by documents_v2: in books that have not declared it the write is
refused with the upgrade remedy and journaling nothing, and replay gives
the same directory back.

An email must be one plain address: one @, no spaces, no full TIN (the
party text rule already refuses nine digits). Terms are typed and already
canonical, so there is no second spelling to check. A party.updated naming
a field this version does not know is now NewerJournal, never corrupt
(B-27): an older version must say update, not damage; a known field with a
malformed value is still corrupt.
2026-10-27 03:44:24 -06:00
swaits 1fa77cff02 feat(settings): invoicing, bills and contact settings (P4b S4.2)
Eleven settings the invoice and bill workflow reads: the number prefix and
first number, default terms, payment methods with their bank details and
QR links, a footer, the unsent and reminder day counts, the bill reminder
and discount-warning day counts, and the company's email and website that
the seller snapshot prints.

Three new kinds keep them exact: a count is a positive whole number, terms
are canonicalized through Terms so "net 30" is stored once as "Net 30"
(B-39), and a payment method list admits at most eight methods whose lines
are at most 120 characters. A routing number is nine digits, so an ach or
wire method's bank-detail lines are the one text exempt from the full-TIN
check (A-3); every other text in a method is scanned.

The keys need the documents_v2 feature (D-52): an older version doesn't
know them, and the settings replay refuses an unknown key as newer. The
constant, the known-features entry and the gate test land with this slice
(its 8.0.6 work); S4.3 adds the document write call sites and must not
redeclare them.

A settings file now exports the [invoicing] and [bills] tables, a payment
method as an inline table (never a [[payment_methods]] section that would
land outside its table), and imports them back to the same values. The
unknown-key test uses fax instead of website, which is now a setting.
2026-10-27 03:44:24 -06:00
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00
swaits b7be5a5472 feat(bookspec): the checker crate, the Engine trait and the runner (P13)
bookspec is a specification first: every rule normative and sourced,
every rule demonstrated by concrete examples, and any accounting engine
checked against them through one interface (D-34). This moves the
examples' schema out of the test that happened to parse them and makes
the interface explicit.

- `bookspec/checker` (its own workspace, inside this repository until
  the harness is stable): `Example`, `Setup`, `Event`, `Expect`, `Query`,
  `Refusal` and the TOML parser, every structure `deny_unknown_fields`,
  with `Example::from_toml` checking the cross-field shape (closed lists,
  exact decimal amounts, dates, one event kind, `reverses` counting from
  1, the shares summing to 1, commands accepted or refused and not both).
- `Engine` (`load`, `apply`, `query`, `claims`) and `Refusal { rule,
  reason }`; the refusal's rule is the bookspec identifier, so a refusal
  names the rule it belongs to. `Claims` also carries the option an
  engine picks for a CHOICE rule.
- `bookspec/runner` drives any `Engine` through the examples and reports
  per rule: pass, fail, not claimed. It compares the answer itself; an
  engine is never asked whether it agrees with itself.
- `crates/ledger-core/tests/bookspec.rs` is the wanderbooks adapter over
  `Store`: the loader parses through the checker, its own checks are
  unchanged, and the new `the_runner_reports_every_claimed_rule_green`
  runs all 56 examples through the trait. The IIF appendix is answered
  through `ledger_package::export_iif`, the code its own test checks.
- `just check` runs the bookspec workspace's fmt, clippy, rustdoc and
  tests too, so an example cannot rot (D-34, Z-17).

The plan's trait has four methods and no path for an example's
`[[changes]]` (a rename, the policy, an asset correction), which post no
entry: `change` is the fifth, required so an engine cannot skip those
examples silently. This is reported to the EM as the one departure.

One money rule holds here too: an answer's figures are exact decimal
strings, never JSON numbers, so no engine can round a cent through a
float.
2026-10-26 21:22:46 -06:00
swaits 9e67c8819d fix(tin): mask and refuse a comma-separated TIN (P7)
The scanner now finds a comma-grouped TIN, so the two places that hand
raw text out or let it in must follow: the access log's digest and its
64-character client texts masked hyphens but not commas, and a filed
file's printed-shape check refused 123-45-6789 but let 123,45,6789 in.

The access log uses the shared redactor, so it inherits the fix; the
filing scan keeps its own deliberately narrow shapes (a bank export's
routing numbers and amounts must still be filed, B-26) and gains the
comma renderings of an SSN's 3-2-4 and an EIN's 2-7. A comma-grouped
amount is still no TIN and still files.

No book, journal line or figure changes.
2026-10-26 20:28:41 -06:00
swaits c6527280e7 fix(parties): read every separator a TIN can be written with (P7)
A taxpayer identification number can be written 123,45,6789 or
(123) 45-6789 as easily as 123-45-6789, and the scanner only knew the
second: a comma-grouped number slipped through every party field, every
refusal that quotes its input and the access log's masking, which is how
a full TIN could reach a result the books promise never shows one
(rules.md B-26, I-7).

The scanner now reads a comma or a parenthesis between a TIN's 3-2-4 or
2-7 groups, and any run of spaces counts once however long it is, so
12    3456789 is an EIN. Commas and parentheses join only those groups:
an amount's thousands separators (1,234,567.89 is 1-3-3-2) still read as
an amount, and a nine-digit D-U-N-S number stays its own setting's kind
(company.duns). The old run reading is unchanged for everything it
found, which the property asserts; replay still reads a journaled line
as P2 did (B-28).

The truth table and the ASCII-equality property pinned the old separator
set, so they were updated with it: the property is now
has_full_tin_never_forgets_a_tin (what was found before is still found).
No journal, entry or figure changes: this is a text scanner only.
2026-10-26 20:28:41 -06:00
swaitsandClaude Opus 5.5 c1f9001cfe feat: the invoice PDF, with stamps and each payment (P4b S3)
check / check (push) Canceled after 0s
ledger-import invoice_pdf (templates/invoice.typ): seller, bill-to,
number, dates, terms, lines, total, each settled line and the balance
due; money formatted in Rust (the template does no arithmetic), text as
data, deterministic bytes. Stamps as a rubber stamp would mark it, in
red: PAID (with the day it was paid in full: the invoice is then the
customer's receipt), PARTIALLY PAID, PAST DUE, VOID; DRAFT in gray for
S4's preview. ledger-package invoice_document builds it from the books
as of a day: the derived figures (B-38), each payment that stands on its
own line with its date, payment method and reference; re-created copies
say so (the issued copy is kept from S4 on and never changes).
books.document pdf hands it over as a download; the viewer's invoice
page saves or opens it. The owner asked for the paid stamp with the
payment's date and details (2026-10-24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 18:20:55 -06:00
swaitsandClaude Opus 5.5 b7b7f17163 feat(viewer): Invoices & Bills, a document's page, overview tiles (P4b S2)
check / check (push) Canceled after 0s
One sidebar item, Invoices & Bills (the owner's choice, 2026-10-24),
with Money in (invoices) and Money out (bills) tabs: what is owed and
overdue, and the list tied to its receivables or payables account
(Agrees, or what to look at); filters Open, Overdue, Paid, Void, All;
rows by due date, an overdue one saying how many days late, each named
in full for assistive tech. A document's page: total, received or paid,
still owed, status; its lines with their projects; its timeline, a
payment later reversed marked so, each row opening its entry. The
overview adds Owed to you and You owe on bills once the books have
documents. Every figure is the document's derived state (rules.md B-38).
The sidebar's rows (and the rail's) give way to 22 points, not 24, so
13 links still fit the owner's 1479x519 window unscrolled; the
sidebar's tests compute heights from the link count; the 1440x920
golden is re-blessed for the new link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 15:33:04 -06:00
swaitsandClaude Opus 5.5 6f0fd97db0 test: the burst-close test closes once every client has posted
check / check (push) Canceled after 0s
closing_during_a_burst_of_posts_loses_nothing slept a fixed 1.5 s before
closing and asserted every client had posted; under load one hadn't (a
false failure of just check on 7d2bf0b). It now closes once each client
has posted at least once (a minute's deadline), then 300 ms more so the
close still lands mid-burst; what it checks is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 13:57:58 -06:00
swaitsandClaude Opus 5.5 7d2bf0b75f feat(mcp): books.documents and books.document (P4b S2, read tools)
check / check (push) Canceled after 0s
books.documents lists invoices or bills, due date first, with what each
still owes and its status, all derived from its ledger lines that stand
(B-38): status open (default), overdue, paid, void, written_off,
overpaid or all; party and due_by narrow it (what to pay this week).
books.document shows one (ref invoice:N or bill:N) as of a day: lines
with projects, total, paid, discounted, written off, outstanding,
status, days overdue, and its timeline of every tagged ledger line, each
saying whether its entry still stands. The over-the-wire test
documents_list_and_timeline_over_the_wire covers a reversed payment;
the happy-path journey covers both output schemas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 13:55:16 -06:00
swaitsandClaude Opus 5.5 07f213c9e5 feat(mcp): void, control check, payment method and reference (P4b S1, part B)
check / check (push) Canceled after 0s
books.void_document voids an invoice or bill that should never have
existed by reversing its own entry with the reason (refused while a
payment stands on it, naming that payment). books.control_check ties
receivables and payables to their documents (B-23, B-38 INVARIANT 1):
balance, documents' total, lines that are no document's, the documents
needing attention; books.verify checks the identity and books.digest
lists its problems (control_problems). books.receive_payment and
books.pay_bill take instrument (tagged in the same call) and reference
(in the description), and answer the document's derived remaining and
status. The books record payments; they never collect or make them
(D-45). Docs, handbook and STATUS; the over-the-wire test
documents_void_and_tie_out_over_the_wire; the happy-path journey covers
both tools' output schemas. The processor fee waits for S6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 12:22:53 -06:00
swaitsandClaude Opus 5.5 ebb5fcb46f refactor!: files only by handle: uploads in, downloads out (D-46)
check / check (push) Canceled after 0s
The owner (2026-10-23, via the question tool): "Uploads only"; and
before it: "What we would DEFINITELY want to avoid is passing a bunch
of BLOBs through AI context windows. That's just a pure waste." No file
passes through an agent's context, in either direction.

In: the inline arguments are gone with the 256 KiB limit (INLINE_MAX,
take_file's inline branch): content_base64 and its sha256 on
books.attach_receipt and books.file_document, w9_content_base64 and
w9_sha256 on books.set_w9, csv_text on the two CSV imports. A file comes
only by books.begin_upload and `wanderbooks upload`, named by its
upload_id; existing_attachment_id still names bytes stored already. The
five filing tools deny unknown fields, and any inline argument to any
tool is refused before it runs as an unknown argument pointing to
books.begin_upload (access log: refused inline_file).

Out: books.export (every format), books.asset_labels and
books.get_receipt view bytes hand the file over (HandedFile: download
command and expiry, D-29), as the CPA package and the W-9 do. Resource
reads stay text. A token keeps up to 64 handed files (was 16). The
access log's digest no longer exempts file bytes.

Docs: handbook files-in-out and parties-and-tins, mcp-interface.md,
rules.md, receipt-store.md, parties-and-attachments.md, plan/served.md
(D-46; the inline decisions marked superseded), STATUS.md, testing.md
(D-46: tests migrated to uploads and downloads, rewritten, removed,
added).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 09:18:14 -06:00
swaitsandClaude Opus 5.5 d7fa16e974 feat(arap)!: documents read their figures from standing lines; control accounts
check / check (push) Canceled after 0s
refuse hand entries (P4b S1, part A)

Plan P4b (D-45), slice S1, the engine half: rules.md B-38.

- ledger-core: every receivables or payables line carries its document
  (Ledger.line_docs: DocRef, Movement, DocTag), written by the document
  commands in the step that posts it, derived on replay from the existing
  document events (the role's account as it stood then; payments by the
  ordinal rule), copied onto a reversal's mirror. Ledger::stands_on;
  doc_state / invoice_state / bill_state (total, paid, outstanding,
  status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day;
  ar_aging / ap_aging read them, so reversed payments and voids are right.
  The control guard (add_line, add_line_fx, post_entry,
  register_recurring) refuses untagged lines on receivables/payables,
  exempting a reversal's mirror of a legacy line; replay never refuses.
  Bounds (kernel::running_bounds, bounds_kept) on payments and reversals;
  a document reversal never dated before its entry; deposit/payer accounts
  must be cash-like and hold no role. New API: receive_payment_with /
  pay_bill_with (PaymentDetails: instrument, reference), void_document /
  check_void_document, control_check, payments_recorded,
  standing_payments, replay_reverse_entry. Invoice.amount_paid/status
  remain only as derived caches for the MCP layer until part B.
  cashflow bill_splits reads payments from the tags.
- ledger-store: journal feature document_standing (D-39 gated) for a
  payment accepted only because an earlier one was reversed;
  receive_payment_with / pay_bill_with (instrument journaled as
  entry.instrument_set, reference in the description), void_document;
  replay uses replay_reverse_entry; schema 15 drops the projection's
  amount_paid/status. Fixture p4-arap-before + golden figures written by
  the version before S1, adjudicated in arap_standing.rs.
- ledger-gui: party card, cash "coming up" and company owed totals read
  doc_state as of today (late from the derived state).
- Lean Documents.lean (control_agrees and friends); Kani
  bounds_check_matches_definition,
bounds_kept_is_the_definition_on_books_in_bounds;
  rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand
  lines; tests that hand-post to 1200/2000 post as replay or renumbered.

Review fixes (both reviews of part A):
- control_role_of guards only an account the role is assigned to or one
  holding its documents' lines (a chart's own 1200 Equipment or card at
  2000 is free until a document posts there); payment accounts check
  other roles by holds_role (type-checked). The MCP tests that failed on
  the guard (oracle_mcp, spec_projects) pass unchanged.
- standing_payments leaves out reversals (sorted, deduped); replayed
  tags match the movement's side; payments_recorded counts every payment
  event (tagged or not, Ledger.payment_events) and refuses overflow.
- The record caches settled (paid + discounted + written off) and owed
  (Invoice/Bill.owed, never serialized): a void never reads as paid.
- assign_role: receivables/payables refuse moving onto an account with
  untagged lines (RoleAccountHasHandLines) and, on their control account
  holding a balance, say DocumentRoleHoldsBalance (no hand entry named);
  B-23 reworded. The reversal-mirror exemption only takes a hand line
  off (even chain depth); putting it back is refused.
- DocStatus Overpaid and Unattached; aging leaves out credit balances;
  ControlCheck.attention lists out-of-bounds and unattached documents.
- stands_on counts a chain in order; indexes (doc_index, doc_accounts,
  order_pos, reversed_by) keep the derived figures linear.
- Viewer reads a post-dated document as of its own date; one state per
  row. bounds_kept's side-crossing documented as intended.
- Tests: ten new spec_documents scenarios, stronger proptests, store
  proptest arap_props (live = replay = snapshot at every date, closed
  months), fuzz ops void/payments with details, a GUI post-dated test;
  Lean Step.assign / Step.release with unattached_zero kept; model
  limits stated in docs/testing.md and B-38.
Review fixes (CPA and engineering lenses): only an assigned role's
account or one holding document lines is a control account (a chart's
own 1200 asset stays free); a role can't move onto untagged lines or off
document lines (Lean Step.assign); a void's cache never reads as paid;
no Decimal sum panics; per-document indexes keep figures linear; a
replayed tag matches its side; the feature gate counts payment events;
a payment's reversal is never listed as standing; an overpaid document
has its own status; the viewer keeps post-dated documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-24 09:17:42 -06:00
swaitsandClaude Opus 5.5 7741e96eb3 refactor!: one way to serve books: the stdio server is gone (D-44)
check / check (push) Canceled after 0s
The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 19:45:18 -06:00
swaitsandClaude Opus 5.5 a9de57d6cd feat(viewer): thumbnails drawn at load, kept on disk by hash; no picture crashes it
check / check (push) Canceled after 0s
Tiles draw a thumbnail, not the file: the top of the first page, 560
pixels wide (never scaled up) and at most 420 tall, rendered by two
workers of their own so opening a file never waits behind them. Every
file's thumbnail starts when the books load, before the documents page
is opened, and the live viewer keeps each as <sha>.png in
$XDG_CACHE_HOME (else ~/.cache)/wanderbooks/thumbs/v1-560 (private to
the user, written whole and renamed into place), so the next launch
reads small PNGs, not PDFs. A file's name is its content's hash, so a
thumbnail always matches the file the books hold; the blob is still read
through the verifying store each time, so a corrupt or missing file is
an error, never a kept picture.

From review: a picture or page past the device's largest texture is
scaled to fit (egui panicked on it, in the full view too); a renderer
panic is that file's error, not the end of its worker; a thumbnail asked
for before the cache moved is dropped, never shown over a newer one;
text thumbnails keep 2 KiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 09:58:25 -06:00
swaitsandClaude Opus 5.5 7cc08a0039 feat(viewer): Markdown and plain-text documents are drawn as text
check / check (push) Canceled after 0s
A text/markdown file (or text/x-markdown) is drawn as formatted text by
egui_commonmark, with no image loading, so a document never makes the
viewer read a file or the network; any other text/* type as its
selectable text. UTF-8 only (a leading byte-order mark dropped), at most
2 MiB; past that or not UTF-8, the viewer says why. The documents page's
tile shows the text's first lines. The SHA-256 row is cut to its column
(it ran off the panel and widened the page under it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 09:05:32 -06:00
swaitsandClaude Opus 5.5 d64e0e9cdf fix(k1 tax basis): odd cents of property basis run cumulatively (B-36, D-38)
check / check (push) Canceled after 0s
The tax-basis K-1 shared each entry's built-in gain, property without a
basis, prepaid at book and property distributed among the members entry
by entry, every tied odd cent to the first member: three equal odd-cent
properties contributed 50/50 left the members three cents apart.

Apply D-38's rule (the closes' cumulative largest remainder, through
kernel::apportion_cumulative): tax_capital::RunningSplit keeps each
member's exact running share, rounds the running shares afresh to the
cent and hands out the change, so no member's running share is ever a
cent from exact and tied cents alternate. A negative running whole is
rounded as its magnitude, a zero one by the sign of the whole just
split, so a run of losses mirrors its gains part for part. Exact running
shares are taken to ten places before rounding: a sum of Decimal thirds
can sit a hair under its cent (the law found 399.93 as 399.9299...).

tax_capital::entry_parts runs the splits over every posted entry from
the books' first, in date order, whatever span is asked for, with one
count per set of members with a part in the entry: a member never takes
a cent of an entry they had no part in (Item L's capital contributed is
the basis of the property that member contributed), and the count never
restarts, since its weights are each entry's own amounts, not shares.
An entry's reversal takes its original's split back out of the count
(RunningSplit::unsplit), so an entry's cents are the same in every span
and next year's beginning is this year's ending. The K-1's per-entry
lines come from k1::entry_lines, which k1_detail now uses too.

The book property each member contributed in an entry stays split entry
by entry (each member's property must stay within their own credit);
B-36 says so.

Tests: tied_odd_cents_of_property_basis_run_cumulatively (fails on the
per-entry split: 665.86 / 665.83); a_member_outside_an_entry_never_
takes_its_cents (three members; fails with one count for all members),
an_entrys_odd_cent_is_the_same_in_every_span (a reversal across the
span's start; fails with the count carried span by span or the reversal
not taken out), the_split_runs_in_date_order (fails in posting order);
the law a_running_split_never_drifts_a_cent (5,000 cases: the
largest-remainder rounding of exact integer shares, ties to the first,
and the negated run mirrored; fails with ties to the later member or a
zero running whole rounded as a gain); unit tests for one split,
alternating ties, a member outside a split, the mirror at zero, unsplit
and the thirds case; bookspec example partnership-k1-tax-basis-odd-cents.
rules.md B-36, the handbook (tracing figures), STATUS, testing.md and
verification.md updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:31:18 -06:00
swaitsandClaude Opus 5.5 535874e0f3 fix(cash flow): a payment to a government is other operating, by its payee
The P3 cutover review's open point 2: a state's LLC filing fee, booked
to an organization-costs account (tax category organization_startup),
read as "Cash paid to suppliers and employees". B-14 rule 5 sent a
payment to class (g) only when every operating line's account was
taxes_licenses, so the account, not the payee, decided.

ASC 230-10-45-17, read in EY's guide that reproduces it (Statement of
cash flows, July 2026, p. 43; the PDF re-fetched and its SHA-256
recorded in docs/spec/sources/fasb-asc.md), lists apart "(b) Cash
payments to other suppliers and employees for other goods or services"
and "(c) Cash payments to governments for taxes, duties, fines, and
other fees or penalties". 45-25's classes name only income taxes (f)
among the latter, so the rest are (g), other operating cash payments.
A formation filing fee is rightly an organization cost (its account and
tax line), and still a payment to a government: the payee is what
45-17(c) is about.

So rule 5 now sends a payment's government part to (g): of an
operating payment the structure classes (d), what went to a party with
the government_agency or tax_agency role (the chain root's parties, so
a reversal keeps its original's class) is (g), on any account; and, as
before, a payment whose every operating line is on a taxes_licenses
account is (g). Every party a government: the whole payment. A
government and a supplier named together: the payment is split by their
resolved shares (B-35), the government's part (g), the rest (d), since
ASC 230-10-45-22 has "each separately identifiable source or each
separately identifiable use" classed "on the basis of the nature of the
underlying cash flows" (EY p. 44; quoted in fasb-asc.md; it names the
three activities, read for 45-25's classes too as a CONVENTION). The
government's part is the payment x its share / the whole, rounded half
to even to the base currency's places, the rest exact, so the line still
sums to the payment and a reversal mirrors it. Shares that don't
resolve leave the whole payment (d). One naming no party is decided by
its accounts. Receipts are untouched (a refund from a government stays
(c)). No total changes, only which operating row a payment is in.

Two limits, from the review of this change: a government selling goods
or services (postage) is 45-17(b)'s supplier "for other goods or
services", so the rule says, and the handbook tells the agent, to add
such a payee as a vendor, never with the government_agency role (the
books can't tell what a payment bought; the role says it). And payroll
keeps its structure (CONVENTION): an entry with a line on the salary
expense or a payroll payable control account, or on a pay tax category
(salaries_wages, officer_compensation, employee_benefits,
pension_profit_sharing), stays (d) whoever it names: withheld taxes
remitted are the employees' pay, and the payables mix them with the
employer's taxes, which a remittance can't tell apart. So a remittance
to the IRS is (d) as it was before this change; the employer's payroll
tax paid on its own expense account is a tax, (g).

The first company's books have this case: their state filing fee moves
from "Cash paid to suppliers and employees" to "Other operating cash
payments" at every date once its entry names the state's agency with the
government_agency role and its own entry is the one whose cash the
statement lists (paid from the bank, or on a card that is a lender's
credit, D-42, or by a member through the members' account while it is
a financing account, B-14 rule 11, the way that fee was paid);
charged to an operating-payable card, its cash leaves in the card
payment, whose payee is the bank, and it stays with suppliers.
STATUS says how to check both, and that the cutover goldens' cash-flow
snapshots will differ on the two class lines only (totals the same),
so the next cutover diff isn't read as a regression.

Docs: rules.md B-14 rule 5 (the rule, its source quoted, the date and
what it was before); fasb-asc.md (45-17(b) and (c) quoted exactly, with
the guide's SHA-256); the handbook's entries section (name the agency as
the party on a tax, fee, fine or penalty paid to a government; a shared
payment named with both amounts; a government selling services added as
a vendor; payroll remittances stay with pay; a fee on a card follows the
card payment); fasb-asc.md also quotes 45-22 and 45-22A; bookspec
example cash-flow.government-fees.toml (a filing fee and a lawyer on
one organization-costs account, a license on taxes and licenses with no
party, a penalty to a tax agency on the wrong account, paper from a
supplier, an annual report paid through a registered agent, 37.00 the
state's and 100.00 the agent's; every figure by hand) with a new `class.<a..g>`
key in the
cash_flow query (bookspec README; the runner checks every class the
statement has is given); testing.md; STATUS.

Tests: ledger-core tests/spec_cash_flow.rs
`a_payment_to_a_government_is_other_operating` (the filing fee and a
penalty to a tax agency (g); the same account paid to a lawyer, or with
no payee, (d); its reversal next month keeps (g); a refund (c)),
`a_government_payee_moves_a_fee_on_any_account` (a taxes_licenses
license with no party (g); a payment shared half and half between a
government and a printer split 50.00 (g) and 50.00 (d), the government
alone all (g)), `a_shared_government_payment_splits_to_the_cent` (a
third by percent of 100.01: 33.34 (g), 66.67 (d); amounts split as
given; the reversal mirrors to the cent),
`a_payroll_remittance_to_a_tax_agency_stays_pay` (withheld taxes
remitted from a payable holding its payroll role, and pay on a
salaries_wages account, stay (d) with the IRS named; the employer's tax
on its own expense account is (g)),
`a_government_selling_services_is_a_supplier` (postage to a vendor (d);
given government_agency, (g): the limit the handbook guards); src
cashflow.rs `tests` `a_government_part_rounds_half_to_even` and the
proptest `a_government_part_sums_and_mirrors`; the bookspec example;
from the P4 review, `a_reversal_takes_its_originals_payee` (a fee and
its reversal next month land in one class, the chain's payee deciding:
the books keep a chain's parties one, so reading the reversal's own
parties is an equivalent mutant) and
`a_shared_government_payment_a_member_paid_splits_both_ways` (a shared
payment a member paid: each part with its own on_behalf; fails with
the on-behalf part unsplit). docs/mcp-interface.md: books.cash_flow
and the Roles paragraph say a government or tax agency payee makes a
payment other operating, and a shared one two operating lines.
Broken to confirm: without the payee check the five spec tests and the
example fail; without the split for mixed payees the two shared-payment
tests and the example (class.d -549.50) fail; without the payroll
exclusion the payroll test fails; rounding toward zero fails both unit
tests; without the tax-category route
`operating_classes_follow_asc_230_10_45_25`, the second test and the
example fail. The viewer's cash flow page,
rendered on made-up books before and after: the fee moved from "Cash
paid to suppliers and employees" to "Other operating cash payments",
every total the same; and on made-up books with an annual report
shared between the state and an agent: each part on its own class
line, every total the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:31:18 -06:00