Compare commits

...
549 Commits
Author SHA1 Message Date
swaitsandClaude Fable 5.1 ad2ac82491 test(store): the last two root answers compared as the system spells them (macOS)
check / check (push) Waiting to run
portable-macos / portable (macos-latest) (push) Waiting to run
portable-windows / portable (windows-latest) (push) Waiting to run
two-user / two-user (push) Waiting to run
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-30 03:14:59 -06:00
swaitsandClaude Fable 5.1 8f0e5f0841 test(store): two setup answers compared as the system spells them (macOS)
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-30 02:17:36 -06:00
swaitsandClaude Fable 5.1 ccc5d21941 test(cli): the staged-update test no longer asserts the staged folder is gone
The gate on d326894c (twice green before): after the apply, the new
host's next look staged 9.0.0 again within the test's window on the
runner, since the test's newer program is this binary with bytes
appended and its compiled version never catches up with the feed's.
The program in place and the kept previous copy are the apply.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-30 01:44:55 -06:00
swaitsandClaude Fable 5.1 170af163bc docs(status): the CI day, 2026-10-30 early
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-30 01:24:16 -06:00
swaitsandClaude Fable 5.1 d326894c6e fix(root): setup reads the default root as the system spells it
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The portable macOS job: a root chosen at the default (`/tmp/.../Library/
Application Support/wanderbooks`) resolved to `/private/tmp/...` and
read as somewhere else, so setup kept a record that should have gone.
The default is taken from the canonical home, as a chosen root is.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-30 01:11:51 -06:00
swaitsandClaude Fable 5.1 a08f7b5fac test(host): a books home's files are listed with one separator on every OS
P11c run 7 on Windows: the walk spelled `.claude\settings.json`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-30 00:36:55 -06:00
swaitsandClaude Fable 5.1 cc6bb25d5e test(store): two more root answers compared as the system spells them (macOS)
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 23:52:55 -06:00
swaitsandClaude Fable 5.1 659a426631 test(cli): the demo-books start test needs the viewer; not in the server-only build
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The gate on 8ba104a1 passed every workspace test and failed only the
server-only build: P5's `books_start_fits_8_kib_on_the_demo_books`
builds the demo books with `ledger_gui`, which the no-viewer build
does not carry. Gated on the `gui` feature like its neighbours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 22:55:59 -06:00
swaitsandClaude Fable 5.1 66d3e5b2c4 test(store): the root tests ask the platform where the data folder is and compare roots canonically
The portable macOS job: five root tests spelled out `.local/share`
or compared a chosen root against its /tmp spelling where macOS keeps
/private/tmp. They use `platform_data_dir` and canonical paths now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 22:49:20 -06:00
swaitsandClaude Fable 5.1 8cd2d2f80f fix(settings): a repository on this computer may be a Windows drive path or a rooted Unix path on any system
The portable Windows job (P11c run 6) failed ledger-core's
`where_backups_go_is_checked`: `/srv/books.git` is not an absolute path
to Windows, and `C:\books` was read as the host `C` of a networked
address. `backup.git_remote` now reads a drive path (one letter, a
colon, a slash or backslash) as a path on this computer, as git does,
and a path from `/` as rooted on every system git runs on; the test
holds the three forms on every OS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 22:36:17 -06:00
swaitsandClaude Fable 5.1 8ba104a15a test(store): the unknown-event example is one no version knows (P8 wrote period.attested)
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The gate on 88874216: `every_known_event_type_has_replay_and_projection_arms`
still used `period.attested` as the type a later phase adds; P8 added
it, so the line replayed instead of reading as newer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 21:55:10 -06:00
swaitsandClaude Fable 5.1 1d3292678a ci(check): the temporary CLI-crate step goes; the update tests' races are fixed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 21:02:35 -06:00
swaitsandClaude Fable 5.1 584ec9390d test(viewer): the book a viewer finds is compared as the system spells it (macOS: /private/tmp)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 20:43:42 -06:00
swaitsandClaude Fable 5.1 888742166b test(cli): the update tests wait for the host's asynchronous work, and compare programs by hash
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Two tests raced the host: one asserted the staged copy was applied the
instant the books reopened, the other that a dropped tampered copy's
folder was gone, when the new host may look at the feed again and stage
a fresh, true copy in its place. Both wait for what they mean now (the
apply; the tampered bytes gone). Every whole-program comparison is on
the sha256, so a failure's message is a line, not two binaries (GitHub
kept nine lines of a step's log on one such message).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 20:42:59 -06:00
swaitsandClaude Fable 5.1 a944d0f51b test(viewer): the fake agent waits a whole second (macOS's bash 3.2 takes no fraction)
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The portable macOS job: `read -t 0.1` is "invalid timeout
specification" in /bin/bash 3.2, so the fake agent never said ready.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 20:06:46 -06:00
swaitsandClaude Fable 5.1 134e1424cf ci(check): the CLI crate's tests run in their own step (temporary)
Alone, the update tests passed on the runner; inside the whole run one
fails and the step log loses its message. The CLI crate's suites run
first in a step of their own so the message survives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 19:40:02 -06:00
swaitsandClaude Fable 5.1 3490ab6fa8 ci(check): the update tests run alone first, with output (temporary)
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The staged-update test fails on the runner on two runs in a row
(main a8ce2e7d, branch P11c), passes locally even on one slow core,
and the gate's step log keeps only its last lines. A step of its own
shows the message; it goes once the cause is fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 19:25:20 -06:00
swaitsandClaude Fable 5.1 59ae40aac5 test(cli): the terminal helpers quote each word (a macOS root has a space)
The portable macOS job: the command line handed to `script` was the
words joined by spaces, and `Library/Application Support/wanderbooks`
split into two arguments ("unknown argument: Support/wanderbooks").
Each word is single-quoted now, on every OS; a word that is a shell
operator a test means as one (the consent test's `>`) stays bare.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 19:05:41 -06:00
swaitsandClaude Fable 5.1 a8ce2e7d6c test(cli): the script helper's two spellings share one type
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
932c0517 pushed a helper whose branches were arrays of different
lengths; the three test binaries did not compile. A slice now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 18:37:38 -06:00
swaitsandClaude Fable 5.1 932c05177f test(cli): the terminal tests speak BSD script on macOS; a noted root is compared as the system spells it
check / check (push) Canceled after 0s
portable-macos / portable (macos-latest) (push) Canceled after 0s
portable-windows / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The portable macOS job, now past the server start: `script -qefc` is
util-linux's spelling; macOS's BSD `script` has no -f and no -c, so the
three pseudo-terminal helpers choose the spelling for the OS. And the
root noted "for next time" is the canonical path (macOS writes /tmp as
/private/tmp); the test compares it canonically.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 18:36:34 -06:00
swaitsandClaude Fable 5.1 1353be0c10 ci: one workflow per portable OS, so each job's log is readable when it ends
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 18:27:54 -06:00
swaitsandClaude Fable 5.1 74f1691f77 fix(store): a root too deep for a socket's address is served on macOS too
portable / portable (macos-latest) (push) Waiting to run
portable / portable (windows-latest) (push) Waiting to run
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
A Unix socket's address holds about 104 bytes. Linux reached a deep
root's sockets through /proc/self/fd; every other Unix refused the
root ("too long for its sockets"), which the portable macOS job met
with a test home under the runner's temp folder, and a Mac with a deep
home would meet too. On those systems the socket path now goes through
a short link of this account's, /tmp/wanderbooks-<uid>/<hash of the
dir>, in a folder made 0700 and refused unless it is this account's
own; a stale or foreign link in its place is replaced or refused. The
server and its clients compute the same link, so nothing else changes.

Tests (tests/socket_path.rs, every Unix): a deep root gets a path that
fits and reaches the dir; the link is private, stable, and replaces a
stale one of ours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 18:09:00 -06:00
swaitsandClaude Fable 5.1 1da01c3123 test(cli): the spec script's package export gives the audit override; macOS tests keep short paths
check / check (push) Canceled after 0s
portable / portable (macos-latest) (push) Canceled after 0s
portable / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
GitHub's check on 62d42b94: the spec script now runs `books.audit`
(P8), so the package gate is live when the script exports and the
run is behind the head; four tests failed on `audit_required`. The
script gives `without_audit_reason`, as a human would.

The portable macOS job, with the server's log in hand: "the books'
private folder's path is too long for its sockets". A Unix socket path
holds 104 bytes; the runner's temp folder plus a test home plus
`Library/Application Support/wanderbooks/run/` is longer. The job sets
TMPDIR=/tmp, so every test home is short there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:49:11 -06:00
swaitsandClaude Fable 5.1 db7910f937 ci: the portable macOS and Windows jobs run as their own workflow
GitHub serves a job's log only when its whole run has completed, and
the Linux `check` takes over an hour, so a macOS failure's reason was
unreadable for that long while the portable jobs sat in the same run.
Each OS's run now completes on its own; the jobs are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:37:39 -06:00
swaitsandClaude Fable 5.1 62d42b9458 test(cli): every test asks the platform where the data root is
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The four other test files that spelled out `.local/share/wanderbooks`
(setup, books_home, format_upgrade, backups) use `default_root` and
`platform_data_dir`, so they hold on macOS as on Linux.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:22:37 -06:00
swaitsandClaude Fable 5.1 2a6bac02d5 test(cli): the IIF warning count ignores P8's audit notice; the harness asks the platform where the root is
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
GitHub's check on 880278ea: `cpa_package_holds_the_year_and_its_evidence`
counts the IIF transliteration warnings, and P8's "No counted audit
stands behind this package" notice (a warning while the books hold no
audit run) made four into five; the count now excludes it.

The portable macOS job, now that the harness prints the server's log:
"there is no books' private folder yet: run `wanderbooks setup`
first". `Home::root()` spelled out `.local/share/wanderbooks`, the
Linux data folder; `setup --home` on macOS makes the root under
`Library/Application Support`. The harness asks `default_root` now,
the same answer on every OS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:21:43 -06:00
swaitsandClaude Fable 5.1 2774680d65 docs(plan): P11b and P13 step 6 landed; D-65 amended (Gitea only); the night's status
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:11:37 -06:00
swaitsandClaude Fable 5.1 f625848acb merge: P11b, the agent dock, books homes, the first-run page (branch P11b, abcb2d6b; D-27)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:10:58 -06:00
swaitsandClaude Fable 5.1 abcb2d6b27 docs(plan): P11b as built (P11b)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:06:14 -06:00
swaitsandClaude Fable 5.1 8f2e3378e8 feat(viewer): the agent dock and the first-run page (P11b)
D-27: a terminal panel along the bottom of the viewer (alacritty_terminal over
a PTY) runs Claude Code (default) or Codex in the book's books home, resizable,
collapsible, the choice remembered. The agent is an ordinary child process the
owner describes (agent_start); the viewer stays read-only: the agent writes
through the server like any other. A missing agent is offered through its
vendor's installer (a command to copy), never bundled; no folder is marked
trusted (Q-7, recommended answer: let Codex ask). Windows is not offered until
its ConPTY checks are recorded.

P11a found that onboarding with no terminal was false: the viewer opened only an
existing book. With no books and an owner, wanderbooks gui now shows a first-run
page: the private folder, the owner passphrase (typed in the viewer, optional),
the first books, an agent, then the books open. connect and New books... make
the books home. Tests: dock.rs, welcome.rs, books_home.rs (host and cli).
Docs: testing.md record and For P12, architecture, parity, gui.md, README.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:06:14 -06:00
swaitsandClaude Fable 5.1 0442ab804e feat(host): books homes and starting an agent in one (P11b)
D-27: each book gets a visible folder, <Documents>/Wanderbooks/<id>, where an
agent works. It holds AGENTS.md and CLAUDE.md (three lines), the project-level
.mcp.json entry (wanderbooks mcp --book ID) and .claude/settings.json allowing
mcp__wanderbooks-ID__*, and nothing of the books: no data, key, token or path
into the data root (invariant I-1 stays: the books are hidden, not here). The
two JSON files are merged into and the text files written only when absent, so
a person's edits stay. Named by the book id, which never changes, not by the
company name, which is set after the books are made.

The viewer's New books... and wanderbooks connect make the home; connect_agent
is unchanged. start_agent says how to run Claude Code or Codex in it, or hands
back the vendor's installer (never bundled). Nothing is marked trusted for any
agent (Q-7: recommended answer, let Codex ask). First-run helpers (state, set
up the private folder, first owner passphrase) let the viewer onboard with no
terminal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 17:06:08 -06:00
swaitsandClaude Fable 5.1 880278ea0e fix(mcp): a close still offers books.verify; the test harness says why a server didn't start
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
GitHub's check on 794807ab: P8's fit-up replaced the step after
`books.close_period` (`books.verify`) with the audit and the
attestation, and P5's month_end_close eval, which follows only what
the books offer, lost the step it follows. The close now offers the
chain check first, then the audit the attestation rests on.

The portable macOS job failed every over-the-wire test with "the
server didn't say ready" and nothing else: the harness sent the
server's stderr to a file in the temp home and never showed it. It
now panics with that log, so the next run says what macOS refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:53:56 -06:00
swaitsandClaude Fable 5.1 d738c445cc merge: P13 step 6, the specification lives at git.swaits.com/swaits/bookspec, pinned (branch P13, b77a25fd; D-65)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:52:09 -06:00
swaitsandClaude Fable 5.1 b77a25fd7b refactor(bookspec): the specification lives in its own repository (P13 step 6)
bookspec/ (spec, examples, checker, runner, Lean, GnuCash adapter) moves to
https://git.swaits.com/swaits/bookspec, history kept by git subtree split
(public home; CC BY 4.0 for spec/ and examples/, MIT OR Apache-2.0 for the
code). ledger-core and ledger-package depend on bookspec-checker and
bookspec-runner as git dev-dependencies pinned to
74f2853d5f0db0066147c17fed8033cfe3d83a24, and read the examples through
bookspec_checker::examples(). just check still runs every example via
cargo test --workspace. The pin check against docs/spec/sources and the
rules.md id check move into tests/bookspec.rs (they need this repository's
files). The accounting is unchanged: no example or rule moved.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:51:18 -06:00
swaitsandClaude Fable 5.1 794807ab90 docs(plan): P10 landed, P11a through step 4 and the macOS job; D-64 (signing deferred), D-65 (bookspec public repository)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:29:21 -06:00
swaitsandClaude Fable 5.1 6f948592f6 merge: P11a, the AppImage restart, the daily staged update, the unsigned macOS bundle in CI (branch P11a, 310fa105)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:29:21 -06:00
swaitsandClaude Fable 5.1 5d49fe8f0d merge: P10, the re-audit runbook and its CI mirror (branch P10, 7fe8570a)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:29:21 -06:00
swaitsandClaude Fable 5.1 310fa105a6 docs(plan): P11a step 4, the AppImage restart and the macOS bundle as built
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:25:35 -06:00
swaitsandClaude Fable 5.1 bc9aa57380 feat(packaging): the unsigned macOS .dmg built in CI, and the docs for steps 3 and 4 (P11a step 6, macOS half)
cargo-packager's dmg format (it makes the .app first) from
deploy/packaging/packager-macos.toml, an Info.plist merged into the
generated one, build-macos.sh in portable shell (BSD sed) and a
package-macos recipe; the release workflow gets a macos job on
macos-latest that uploads the .dmg as a run artifact and attaches it on a
tag. Unsigned and not notarized: that is step 5 (the owner's Developer ID,
Q-4). The config was parsed under cargo-packager 0.11.8's strict schema on
Linux; the build itself needs the CI run. No new dependency.
docs/packaging.md (macOS rows, finding 7 resolved, the staged update),
architecture.md and testing.md describe steps 3 and 4.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:25:34 -06:00
swaitsandClaude Fable 5.1 6e7deb15c7 feat(host): the daily update check and the staged update (P11a step 4)
While the books are open the host looks at the release feed at most once
a day (knob update_check, noted in updates/checked so a restart does not
look again), and only downloads and verifies a newer release into
<root>/updates/staged/ (sha256 AND minisign signature, as for update).
It never swaps: the staged program is applied (a) by an opener just
before it starts a host, when no host runs, so no call is cut off, after
a second check against the key (a staged file that fails or does not
start is dropped), and (b) by wanderbooks update, the owner's say-so,
which reuses the staged copy instead of downloading again. A local
release can be named in <root>/updates/feed (file:// only, with its key
file, same rule as --trusted-key). The program's version is set by main
since packages rewrite only the cli's. No new dependency.

Tests (crates/ledger-cli/tests/update.rs): the_host_stages_a_daily_update_and_the_next_start_applies_it,
a_daily_update_that_is_not_ours_is_not_staged,
update_installs_the_copy_the_host_staged_without_downloading_again,
a_staged_update_that_was_tampered_with_is_dropped_not_applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:23:40 -06:00
swaitsandClaude Fable 5.1 7fe8570acd docs(plan): P10 as built
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:16:28 -06:00
swaitsandClaude Fable 5.1 01aff4c04e test(audit): every defect keeps a named regression test (P10)
The audit's 24 defects must not lose their reproductions quietly; the table
names the test guarding each, checks the runbook names only tools that
exist, and records the mirror and its For-P12 targets in docs/testing.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:16:28 -06:00
swaitsandClaude Fable 5.1 a2f013955c docs(audit): the re-audit runbook (P10)
A re-audit is only worth its name if every figure is traced and every
receipt and entry carries an attestation that counts, so the runbook drives
the audit tools in the order the books need: audit, correct by reversal
(never edit), attest at the bar, attest the periods, then compare both CPA
packages and sign off in the journal. D-12's evidence link uses
link_attachment: the books have no inherit_evidence tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:16:28 -06:00
swaitsandClaude Fable 5.1 562b893c64 feat(host): an AppImage's host learns its image and restarts onto a replaced one (P11a step 3 gap)
An AppImage runs the program from a mount that goes away with the image
it came from, so a host held open past an update kept running the old
mount and never noticed the replaced image. The host now reads APPIMAGE
(added to its environment allow-list only when it names an absolute
file; it is a path the host reads about itself and never tells an agent,
so I-2 holds), runs and restarts from that file, and tells a replaced
program by its file's inode, size and last write. Test:
a_replaced_appimage_restarts_the_books (the programs run from one file
while APPIMAGE names another, as in a mount).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:15:11 -06:00
swaitsandClaude Fable 5.1 5ecc01b682 docs(plan): P5 and P8 landed; P11a through step 3
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:05:30 -06:00
swaitsandClaude Fable 5.1 3ee2020bb2 merge: P8, audit and attestations: the checks, journaled runs and attestations, the workpaper PDF, the CPA gate (branch P8; schema 20)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:05:30 -06:00
swaitsandClaude Fable 5.1 4774bd9abd feat(audit): fit the audit tools into the agent surface (P8)
After P5 merged first: a close leads to the audit and the period's
attestation, an audit run to its workpaper and the claims behind it, the
attest tools to the attestations and the CPA package; a refusal for want of
a standing audit is the guard audit_required with books.audit as its
remedy; the audit prompt runs the audit tools. policy.cpa_package_requires_audit,
unset, refuses once the books hold an audit run and warns before, so a
book that has not begun the programme is not stopped from its first handoff
by a rule it has not met; set to refuse, it holds from the start.
bookspec states B-45 (convention) in its checker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 16:03:53 -06:00
swaitsandClaude Fable 5.1 45bcce679b feat(audit): books.audit, the attestation tools and the CPA gate (P8)
The whole re-audit through the tools: books.audit previews its checks, then
with the preview's digest runs them, files the workpaper and journals the
run; the same books, day and scope return the run that exists.
books.attest_receipt, books.review_entry and books.attest_period record a
named client's claim with its self-reported method; books.attestations
weighs each against policy.attestation_bar as it stands. Under
policy.cpa_package_requires_audit = refuse, books that declared audits
cannot export the CPA package without a counted audit for the handoff at the
current head unless a reason is given, which is journaled as a decision
(D-9). Needs attention asks for the month-end attestation, the annual audit
and the audit before the handoff. Rules B-45, the handbook section, the
interface, parity, receipt-store and testing records, and the As-built
block. Merge needs no go-ahead: the live books refuse the new writes until
the owner gives it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:54:26 -06:00
swaitsandClaude Fable 5.1 d45e842873 feat(workpaper): the audit workpaper as a deterministic PDF (P8)
One page set a CPA can read: what was checked, through which day and state
of the books, every finding with its code and severity. No clock and no
random id, so the same findings give the same bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:54:08 -06:00
swaitsandClaude Fable 5.1 6b7dccd1ee feat(audit): audit runs and attestations are journaled (P8)
A claim that someone checked the books is evidence the CPA handoff rests on,
so it is a journal fact and replays: audit.run (a programme run through a
day at the books' figure head, with the hashes that make it reproducible and
the two filed workpapers), receipt.attested, entry.reviewed and
period.attested. Behind the journal feature audits, schema 20; no figure
moves. The figure head is the last line that bears on a figure: filing a
workpaper, a run or an attestation never makes an audit stale, an entry,
document, close, settings or role change does, so a period is attested only
on an audit that still describes the books. A receipt reading that differs
from the entry is recorded as disagreeing and never counts; whether a claim
counts is derived against the bar when asked, never stored.

The store also reads what a ledger cannot: the chain, the database against
a replay, stored and extracted files, the access log, duplicate files, and
the backups' standing handed in by the server. Fixture old-books/p8_before
was written by the version before this one and opens to its figures.
period.attested was the tests' placeholder for an unknown event type; they
use ledger.not_yet_written now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:54:08 -06:00
swaitsandClaude Fable 5.1 843bcc421f feat(audit): the statements checks, the bar and the audit settings (P8)
An audit is the books read as an auditor reads them, through a day. The
pure checks over a ledger are one function per finding code in
ledger_core::audit: the trial balance balances, assets equal liabilities
plus equity, the P&L articulates to equity (net income since the first
entry is current earnings plus what the closes moved), the cash flow
foots, the control accounts tie to their documents, a members close stays
within a minor unit of each member's exact share, schedules and
depreciation are posted, the register holds the cost accounts' cost,
every posted entry meets evidence::requirement (a reversal resting on its
original's receipt is listed as a note, not a failure), no draft is open,
every revenue and expense account has a tax category, and the 1099 and
settings problems the caller reads are findings. A check that cannot be
made is itself a finding. Findings come in one fixed order, so the same
books give the same list. The caller hands in what a ledger cannot know:
the receipts filed, the cash flow's footing, the settings problems.

The attestation bar (D-9) is a value, not a table: reasoning effort high,
two verifier agents and an adversarial check, with counts() deciding
whether a claim meets it. Every claim needs the effort; one on a
year-level audit needs the whole bar; a reading that disagrees with the
books never counts. policy.attestation_bar (a new Kind::AttestationBar,
one canonical text) and policy.cpa_package_requires_audit are the two
settings; both wait for the journal feature audits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:53:35 -06:00
swaitsandClaude Fable 5.1 d9d63b16dc merge: P5, the agent surface complete: the envelope, source corrections, journal history, prompts, books.start v2, evals (branch P5, d43a7b4e)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:52:45 -06:00
swaitsandClaude Fable 5.1 d43a7b4e1b test: a preview's confirm step repeats the arguments, so id and tag previews differ there (P5)
dispose_asset_takes_the_tag_and_the_tag_stays_reserved compares the preview
a disposal gives by asset id and by tag and says they show the same, apart
from the digest, which names the arguments as sent. The envelope's confirm
step repeats the arguments as sent too, so next_steps is set aside beside
the digest; everything the preview shows is still compared.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:42 -06:00
swaitsandClaude Fable 5.1 ad1afb478d fix(mcp): a read says only what follows; a success adds no empty warnings (P5)
Two existing contracts the envelope broke when every result got every field:
a resource and its tool say the same (books://settings is books.settings,
checked by the_kept_resources_read), and a tool with no warnings has no
warnings key (books.add_account, defect 8). So head and next_steps are on
every tool that can change the books, next_steps is on a read only when
something follows it, guards is on a refusal, and the envelope adds no
warnings of its own (a tool's stay as they are).

The happy-path spec test now exercises books.journal_history (it fails on
a tool it never called), the demo company gets its books.start size check
(Done when 3), and the As-built block of P5 is written.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:42 -06:00
swaitsandClaude Fable 5.1 8e683942a2 test: the alias sweep reaches the two tests that built their arguments in a closure and a loop (P5)
projects_parity built both its invoices and its bill through one closure that
passed party_id, and spec_projects looped over (invoice, bill) with one
spelling; with the aliases gone each names customer or vendor as its tool
takes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:42 -06:00
swaitsandClaude Fable 5.1 632938fa97 style: rustfmt and one clippy lint over the P5 files
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:42 -06:00
swaitsandClaude Fable 5.1 7bac25496e test(agent): scripted evals follow only next_steps; the surface over the wire (P5)
Agent reason: the envelope is only worth having if an agent can do its work
by following it. crates/ledger-cli/tests/agent_evals/ holds six scripted
transcripts, no model: onboarding to a first post, a receipt-backed post, a
draft that waits for its receipt and posts when it arrives, a reversal, a
reconciliation, a month-end close. agent_evals_follow_only_next_steps plays
each against a real books server: after books.start a step may call only a
tool (or fetch only a prompt) the previous result's next_steps named, with
the arguments the step gave and the ones the agent decides; setup calls (the
chart and the books an agent finds) are not the agent's moves.

agent_surface.rs holds the over-the-wire tests of the rest: the journal in
words (order, subject and type filters, paging, a bad subject refused),
source corrections through the three tools, the prompts rendered against
their files and refused when malformed, the resource templates advertised
and read, books.start v2 whole and under 8 KiB with the self-report in the
access log only, a new book told to onboard, and a mutating call answering
with the head the journal has and a refusal naming its remedy.

Docs: testing.md's P5 section (every test, and the For-P12 paragraph naming
what a mutant would flip), parity.md (no feature moved; one row), and
architecture.md (the envelope and the guards); mcp-interface.md's overview
sections now describe the surface as built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:42 -06:00
swaitsandClaude Fable 5.1 a319a64c3a refactor(mcp): the party_id, document_id and cash_account aliases are removed (P5)
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
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 70a3f795f4 refactor(mcp): the eight long tool descriptions move into the handbook (P5)
Agent reason: a tool description is read every time the tool list is, so
eight descriptions of 800 to 2,000 characters cost every session for text
only a few calls need. books.export, books.export_cpa_package,
books.import_bank_csv, books.reconcile, books.set_entry_party, books.set_w9,
books.show and books.vendor_1099 now say what the tool is for, what it
requires and where the rest is, in at most 700 characters; the rest is in
the handbook, verbatim: the CPA package's contents (cpa-handoff), the share
rules of a multi-party tag and the W-9 procedure (parties-and-tins), the
worksheet's construction (1099), the CSV import and the report list with
the IIF limits (files-in-out), the reconciliation's answer (reconciliation)
and the targets books.show opens (viewer).

tool_descriptions_fit_700_characters now pins zero and its allowance list is
deleted: no description may exceed the cap, and none is excused. The period
test keeps its sentence about omitted dates in books.export's description.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 453abb62ea feat(mcp): books.start v2 and the agent's self-report (P5)
Agent reason: the first call of a session should say who is asking, what is
waiting and what to do, in under 8 KiB, from the same lists the viewer
draws. books.start now carries book.id and book.url (the book's path on the
server, never a path on a disk), you {principal, kind, scopes, client_id},
drafts {building, awaiting_evidence}, reconciliation {tracked,
never_reconciled, behind}, backup {last_at, through_seq, stale}, the
prompts, and needs_attention (the twelve most urgent items, problems first,
with every item counted by kind), beside next_steps now in the envelope's
shape (tool or prompt, why, required; attention problems are required). A
new book is told to run the onboard prompt first, then to record the
company's facts and set up the chart; an active one is offered a draft
entry. The tool carries _meta anthropic/alwaysLoad, so a client that defers
tools loads it up front. The size fit shortens attention items and waiting
drafts after the convention summaries and before the lists, as before.

agent {model, reasoning_effort, harness} is accepted and written to the
call's access record (the access log shows it as agent), self-reported and
never checked, and used for nothing else: not in the answer, not in any
decision. Who asked is the request's alone: it is cleared when the request
ends, so a later resource read on the same thread cannot see it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 996c5f40f4 feat(mcp): prompts, resource templates and report dates on resources (P5)
Agent reason: the procedures an agent follows (onboarding, reconciling a
month, closing a month, the CPA hand-off, the year's compliance, an audit)
should be one name away, written against this server's own tools, instead of
rebuilt from the handbook each session. The server now advertises the
prompts capability (also in the bridge's cold-start initialize, which uses
the same capabilities function) and serves prompts/list and prompts/get to a
token that may read: onboard, reconcile {account, month}, month_end_close
{month}, cpa_handoff {year}, annual_compliance {year}, audit {scope}. Each
text is a file in crates/ledger-mcp/prompts/ with the company's name and the
arguments filled in. migrate, process_inbox and receipts_from_email are not
built (D-46, D-28, D-17). audit points at books.verify, books.control_check
and the digest; P8 fills in its own tools.

An argument is one token (letters, digits, - _ . :, at most 40) of the shape
the prompt asks for (a month, a year, a word from a list), so what a caller
passes can never rewrite the text; an unknown prompt, a missing, extra or
malformed argument is refused. A test checks every prompt names only tools
that exist.

The five resource templates of plan 4.5 are advertised (resources/templates/
list): books://entries/{id}, books://attachments/{id}, books://journal/{seq},
books://reports/{kind}{?start,end,as_of} and
books://digest{?since,since_seq,as_of}; the reports now take their dates
from the query (they read only today's or the year to date before), and the
digest reads since_seq (what was posted since a journal line, backdated
entries included).

Tests: the_prompts_name_only_tools_that_exist,
an_argument_is_one_token_of_the_right_shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 61da9e57cd feat(mcp): books.journal_history, the journal in words (P5)
Agent reason: an agent traces a figure back to what produced it by reading
the journal, but it must never read the file. books.journal_history is the
one way: each line in order with its seq, time, type, a sentence saying what
it did to what, its subject and the entry it belongs to, filtered by
after_seq, type (exact, or a group like entry.) and subject (entry:12,
invoice:3, bill:3, account:10, party:5, asset:2), paged with more, with the
torn tails set aside on open listed beside them. It reads the verified
journal through read_journal_and_format, so a damaged chain is an error, not
a story.

The sentences come from ledger-store's describe.rs, which quotes only
identifiers, dates, amounts and the short names a person typed: never a
setting's value (settings.set names its keys) and never a full TIN (masked,
B-26). Every event type the store writes has a sentence (tested against
EVENT_TYPES). The tool is read-only (books:read); the books://journal/{seq}
and books://entries/{id} resources read through it.

Departure from the plan: the viewer's own sentences (its describe arms in
ledger-gui, which carry icons, routes and today's party and account names)
stay where they are; moving a thousand lines of egui text behind a
formatting trait was not worth the risk to the viewer's tests, so the server
has the shared describer and the viewer keeps its own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 4236f03744 feat(store): a record's source can be corrected without touching its words (P5)
Accounting reason: a convention, a decision or an open item carries who
decided it, when and on what authority. When that is recorded wrongly the
honest fix is to say so on the record, not to restate the policy: a policy
revision bumps the convention's revision and reads as a change of the rule,
and a new decision supersedes the old one. A source correction changes only
the authority and says why, so the audit trail shows the words were never
altered.

New event types convention.source_revised, decision.source_revised and
open_item.source_revised {id, source, reason} under the journal feature
source_revisions: an older version would refuse the types, or show the old
source as the record's authority, so it stops at the declaration instead
(rules.md B-27) and books that have not had the owner's upgrade refuse the
write with format_upgrade_pending, journaling nothing. Replay sets the
source and nothing else (the text, the revision and the journal line stay);
the projection updates the rows' existing source columns, so no schema
changed. A correction must be one: other words, the same source, no reason
or an unknown record is refused.

books.set_convention takes reason (the text exactly as it stands),
books.record_decision and books.raise_item take revises with reason. Without
reason the old refusal stands and now says how to correct only the source.

8.0.6: the hand-written lines of the three shapes in
books_written_under_older_rules_still_open; fixture old-books/p5_before,
written by b97234ac (a revised convention, two decisions, two open items, a
draft) through old-books-writer/src/bin/p5_before.rs, opens to that
version's figures, so no figure moved; books_p2_served_cut_over_whole
refuses the correction before consent and takes it after; the fuzzer and the
viewer's journal know the types. The live books refuse the new write until
the owner gives the go-ahead; merging does not need it.

Tests: a_source_only_revision_is_journaled,
a_source_revision_must_change_only_the_source,
source_revisions_wait_for_the_upgrade, source_revisions_is_its_own_feature.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 cf73d0bda5 feat(mcp): every result carries the envelope (P5)
Agent reason: an agent should never have to guess what follows a call. One
place (call_tool, after the tool answers) now gives every tool result the
same four fields: next_steps (what to do next, each with why and whether it
is required), guards (the named refusal and the tool that remedies it),
warnings, and, on any tool that is not read-only, head (where the journal
ends after the call's last write). A tool's own field of the same name is
kept, never overwritten; the text block says what the structured content
says.

Next steps come from the tool's own result (a preview gets its confirm step
with the digest; a post is followed by showing the entry and, only if it
was in error, its reversal; a draft by its lines; a statement by marking
its lines and reconciling; a period list by the close) and, after a change
to an entry or an invoice, from the viewer's own Needs-attention items about
that subject, so the agent and the human read one list.

guards.rs is the registry of refusals (stale_preview,
format_upgrade_pending, receipt_required, approval_required, party_shares,
party_required, open_drafts, untagged_control_line, duplicate_vendor_ref,
first_number_locked), each naming a tool that exists. A refusal whose text
carries a guard gets it as structuredContent.guards; its text is unchanged.

Tests: every_mutating_result_has_head_and_next_steps (the whole router),
the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard,
every_guard_names_an_existing_tool, a_refusal_becomes_its_guard,
every_step_a_tool_names_exists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:48:27 -06:00
swaitsandClaude Fable 5.1 169b914e1f fix: what wave 1's gate found: B-43 speaks CHOICE, a Linux-only socket test is Linux-only
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
GitHub's check on f043db35: bookspec's `rules_md_carries_every_id`
refuses "DECISION" in docs/spec/rules.md (bookspec's word is CHOICE,
P13 step 3); P4's B-43 wrote "DECISION D-47" twice. The portable
macOS job: `a_socket_with_no_other_end_names_no_process_and_isnt_us`
uses `SOCK_CLOEXEC` and Linux's answer for a peerless socket; it is
gated to Linux, which is what it tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:25:37 -06:00
swaitsandClaude Fable 5.1 54312b3980 merge: P11a steps 1 (Linux), 2 and 3: the trial's Linux column, update and rollback, the Linux packages (branch P11a, d6c90a11)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:21:25 -06:00
swaitsandClaude Fable 5.1 d6c90a115f docs(plan): P11a as built for steps 1 (Linux), 2 and 3
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:12:18 -06:00
swaitsandClaude Fable 5.1 3058b5c229 feat(packaging): the trial's Linux column and the Linux packages (P11a steps 1 and 3)
The Linux half of the packaging trial, run on this machine and in Debian,
Fedora and Arch containers, and the recipes it produced (docs/packaging.md
holds the table and what it found):

- mise.toml pins cargo-packager 0.11.8 and cargo-generate-rpm 0.21.1, now
  that `just package-linux` calls them. cargo-packager-updater is a
  library, not a tool, so it is not pinned (update.rs does the checking
  with minisign-verify, which is what the updater uses).
- deploy/packaging/: packager.toml (.deb, AppImage, the pacman tarball and
  its PKGBUILD), the rpm table in crates/ledger-cli/Cargo.toml, the AUR's
  own PKGBUILD (repacks the release tarball), build-linux.sh,
  repo-indexes.sh (the apt and dnf indexes, unsigned until the signing
  step), local-release.sh (a throwaway-keyed feed to try `update`),
  test-linux-packages.sh (linux_packages_install_and_upgrade: install
  0.1.0, open books as an ordinary user, upgrade to 0.1.1, check the host
  restarted onto the new file, the books' last journal line unchanged and
  `update` refuses; passes on Debian trixie, Fedora and Arch). A package
  marks itself package-managed with share/wanderbooks/install-method.
- The icon is a placeholder square (the AppImage cannot be built without
  one).
- .github/workflows/release.yml: on a version tag, build on ubuntu-24.04
  (the oldest glibc the packages claim: a program linked on this machine
  needs GLIBC_2.43 and does not start on Debian 13), attach the packages to
  the release, and add them to the apt and dnf repositories on gh-pages.
  No signing and no macOS or Windows job yet.
- The release profile strips symbols.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:12:18 -06:00
swaitsandClaude Fable 5.1 4b5f44689d feat(cli): a launch with nothing to say and no terminal is the viewer (P11a step 3)
An AppImage's launcher runs the desktop file's Exec line and appends the
arguments it was given, so with `Exec=wanderbooks gui` the AppImage's
`update` ran `wanderbooks gui update`: the program's own commands were
unreachable through it. The desktop files now say plain `Exec=wanderbooks`
and a bare launch with stdin not a terminal (a menu entry, a double-click)
is the viewer; at a terminal it is the usage, as before. The AppImage is
thereby also the command line (`Wanderbooks.AppImage update`).

Test: bare_launch_tests::only_nothing_at_all_with_no_terminal_is_the_viewer
(the launch itself cannot run under test: the viewer finds the account's
real data root from the password database, not from the environment).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:12:18 -06:00
swaitsandClaude Fable 5.1 23f9e9395b feat(cli): wanderbooks update and rollback (P11a step 2)
`wanderbooks update` reads a release manifest (default: GitHub Releases'
fixed address, D-26), refuses anything not higher than this version,
downloads the platform's file and accepts it only if its sha256 matches
the manifest AND its minisign signature verifies against the release key
compiled into the program (WANDERBOOKS_RELEASE_PUBKEY at build time;
a build without one updates from nothing but a file:// feed whose key the
owner names with --trusted-key, so a local release can be tried). The
verified file is staged under <root>/updates/<version>/, the running
version is kept under updates/previous/ with the journal format it
understood, and the program file is replaced by a rename of a complete
copy made beside it (a reader sees the old file or the new, never a
part). The new program must answer --help; if not, the old one is put
back. Nothing tells the books to stop: the host already notices a
replaced executable and restarts onto it, finishing calls in progress
(host.rs INVARIANT 7).

`wanderbooks rollback` swaps the kept version back, and refuses while any
book declares a journal format or feature the kept version does not know
(plan 3.9: after the owner consents to a newer format the only way back is
the pre-upgrade restore). A package-managed install (a package ships
share/wanderbooks/install-method beside bin/) refuses both and says to use
the package manager. An AppImage replaces the file named by $APPIMAGE.

Tests (crates/ledger-cli/tests/update.rs): update_is_atomic,
failed_update_rolls_back, unsigned_update_refused (another key, other
bytes, a sha that differs, no signature, not a signature),
bridge_survives_update, update_waits_for_a_running_call,
package_managed_install_refuses_update, the kept-version and format-gate
tests; unit tests in ledger-host/src/update.rs.

Dependencies: minisign-verify 0.3 in ledger-host (what cargo-packager's
updater uses to check release files; no dependencies of its own) and
minisign 0.10 as a ledger-cli dev-dependency (the tests sign with a key
they make). sha2 and the two minisign crates are built optimized in debug
and test builds: the tests hash a debug build's program. The release
profile strips symbols, for the packages (P11a step 3).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 15:12:18 -06:00
swaitsandClaude Fable 5.1 f043db35ca docs(plan): wave 1 complete (P4, U0e landed); wave 2 building
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 14:03:39 -06:00
swaitsandClaude Fable 5.1 b97234ac7e merge: P4, draft states, the evidence gate in one place, the preview digest, the digest tool (branch P4, 6a66a285; schema 19)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 14:01:08 -06:00
swaitsandClaude Fable 5.1 6a66a2855b feat(drafts): the drafts tools, the preview digest on every confirm, the digest tool (P4)
Accounting reason: a confirm must act on the thing that was previewed.
Every tool with confirmed (post_entry, reverse_entry, run_schedules,
dispose_asset, reconcile, close_period, create_invoice, create_bill,
write_off_invoice, purge_tin, consolidate_duplicates, and the S6
settlement tools receive_payment and pay_bill, which gain confirmed and
preview on a clone of the ledger) returns preview (SHA-256 of the
canonical preview) and refuses a confirm whose preview is missing or
stale (stale_preview, before anything is written). A draft's digest is
its own state and the settings seq; an operation's is the tool, its
arguments as sent, the settings seq and the journal head seq, so another
session's write forces a re-preview (D-50).

New tools books.list_drafts, books.set_draft_state, books.discard_draft,
books.preview_entry; reports carry as_of_status (drafts open on or before
their end, unreconciled accounts, settings problems) and take strict; the
CPA package is refused while a draft dated in it is open unless
allow_open_drafts gives a reason, journaled as a decision;
books.digest takes since_seq (what was posted since a journal line,
backdated entries included: defect 6) and lists zero_net_accounts,
drafts_by_state and the reconciliation split. Needs attention gains
'draft awaiting receipt' beside 'draft not posted'; the drafts handbook
is rewritten. Every over-the-wire harness confirms the way an agent does.

Docs: mcp-interface, rules B-43, parity, testing (with the For-P12
paragraph), architecture, and the unit's As-built block.

Tests: drafts.rs (a_draft_waits_for_its_receipt_and_posts,
the_gate_moved_and_the_mcp_error_is_unchanged,
a_reversal_needs_no_receipt_of_its_own, discard_unblocks_the_close_over_the_wire,
report_warns_on_draft_before_as_of, cpa_package_refuses_open_draft,
stale_preview_refused, every_confirm_tool_takes_a_preview,
digest_since_seq_is_posting_time, legacy_reversed_replays_identically,
gui_and_server_gate_agree), attention_parity.rs, format_upgrade.rs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:55:38 -06:00
swaitsandClaude Fable 5.1 7299cf90f2 feat(evidence): one function decides whether an entry needs evidence (P4)
Accounting reason: the receipt rule (Pub. 583, Rev. Proc. 98-25: the
books must be demonstrably supported by the records) was decided in
three places that could disagree: the server's post, the digest, and a
string match on a journal-event origin in the viewer. ledger_core::
evidence::requirement is now the one decision: Required (with why) or
Exempt (with the source). A reversal needs no receipt of its own (its
evidence is its original's, D-47, D-12); neither do a closing entry, a
schedule month's or a depreciation run's posting, a disposal, a
document's entry, a payroll run or its remittance. The ledger learned
the two origins it could not see (a disposal, a remittance) from the
events that already name their entry, live and on replay. A split group
counts for a draft only: a posted entry's decision was made when it
posted.

The policy moved with it (evidence::Policy; ReceiptPolicy in
ledger-config keeps the words and delegates). Store::post_entry_checked
refuses a covered draft with no receipt (StoreError::ReceiptRequired,
which the MCP maps to the unchanged receipt_required error);
Store::post_entry stays the primitive that commands and fixtures post
through, since ~176 call sites post hand entries with no receipt. The
viewer's missing_receipt calls requirement; its string match is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:55:17 -06:00
swaitsandClaude Fable 5.1 28d46332e4 feat(store): draft states and discards are journaled, posted_seq is kept (P4)
Accounting reason: a draft that waits on its receipt, and one thrown
away, are facts about the books' working papers that must survive a
restart and replay identically, or a discarded draft would come back and
hold its period open. entry.draft_state_set and entry.draft_discarded are
drafts_v2's (an older version stops at the declaration instead of
misreading them); no figure moves. Schema 19 (taken at merge as the next
after the current last): entries.posted_seq, and a draft_states table
(drafts are not rows of entries, so the plan's entries.draft_state column
has nowhere to live). posted_seq is the journal line that posted each
entry (entry.posted, entry.reversed, the line that closed a period or
posted a schedule month), identical live and on replay.

8.0.6: a hand-written line of each new shape in
books_written_under_older_rules_still_open; fixture old-books/p4_before
written by 25b52580 (hand entries posted late, a reversal, a document, a
schedule month, a recurring draft, open drafts, a close) opens to the
same figures (serialized entries omit a building state, so no golden
moved); drafts_v2_is_its_own_feature; the fuzzer and GUI journal know the
types.

Tests: draft_states_replay_and_project, drafts_v2_waits_for_the_upgrade,
posted_seq_is_the_posting_lines_seq, an_older_version_stops_at_the_draft_events.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:55:17 -06:00
swaitsandClaude Fable 5.1 be8bcf340e feat(drafts): a draft has a state and can be discarded (P4)
Accounting reason: an entry that is finished but waits on its receipt is
a different thing from one still being built, and a draft that will
never post must have a way out, or the period that holds it open can
never close (the Lean hypothesis hdrafts). JournalEntry gains
DraftState { Building, AwaitingEvidence { note } }; Ledger gains
set_draft_state (a note: no line, balance or report moves),
discard_draft (a draft only; a posted entry is never removed, B-8; ids
are never reused; what was recorded about the draft goes with it) and
posted_seq (the journal line that posted an entry, for the digest's
since_seq). Lean Step.discard with its preservation proof and three
theorems (a discard moves no figure, removes only its draft, and
unblocks a close), listed in Audit.lean.

Tests: discard_unblocks_close (ledger-core invariants).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:55:17 -06:00
swaitsandClaude Fable 5.1 75882c5e07 merge: U0e, tokens in a file under the data root, the OS keyring removed (branch U0e, d8816f9e; D-63)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:32:25 -06:00
swaitsandClaude Fable 5.1 d8816f9ee4 docs: tokens in a file, no keyring (U0e)
Deploy recipe loses its D-Bus dance, install.sh its macOS skip, README and
site their macOS caveat; packaging, architecture and testing records follow;
U0e As built filled in (D-63).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:31:42 -06:00
swaitsandClaude Fable 5.1 d703b68911 refactor(host): tokens live in a file under the data root (U0e)
D-63: the 0700 data root is the trust boundary already (I-1: hidden, not
locked), so the OS keyring added no wall and cost headless servers a D-Bus
session, CI a knob and every OS its own store. The file keeper is the only
store: run/tokens/, 0600, written atomically. The three keyring stores, the
file_keyring knob and their tests are removed; an unreadable token file is an
error, never no token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:31:42 -06:00
swaitsandClaude Fable 5.1 cfaf05b862 docs(plan): wave 1 landings; D-62 (Windows as P11c) and D-63 (tokens in a file, no keyring); units U0e and P11c
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
8.0.1 and the Status lines record U0d, S4.5, P13 steps 4-5 and P11a
step 0 as landed. Two owner decisions from today's questions: D-62,
the first bundled app ships for macOS and Linux and the native Windows
port is its own unit P11c (step 0 found it is a port: the host, the
MCP transport, the store's locking and permissions, the data root);
D-63, agents' tokens live in a 0600 file under the 0700 data root on
every OS and no OS keyring is used (the plan had never recorded the
owner's view, so step 0 built two more stores). Unit U0e removes the
three keyring stores and the knob; 3.3's keyring sentence and step 0's
are struck. 8.0.3 confirms the eight bookspec example ids P13 took.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:18:39 -06:00
swaitsandClaude Fable 5.1 d839e709ce merge: P13 steps 4 and 5, the laws in Lean, the spec text, the derivation, GnuCash as the second engine (branch P13, f8163381)
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:08:01 -06:00
swaitsandClaude Fable 5.1 ac0c949b9a merge: P4b S4.5, the issued PDF, the viewer's invoice details, the handbook (branch S4.5, ab3bd958)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 13:08:01 -06:00
swaitsandClaude Fable 5.1 ab3bd958c4 docs(plan): S4.5 as built (P4b S4.5)
Departures from the Build text, one bullet each: no golden sha to
recompute, invoice_document's dropped `today`, where the ways to pay, the
footer and the website come from, exact unit prices, the handbook steps
the earlier slices had already written, the viewer parity test's shape,
and the bookspec index left to P13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:55:41 -06:00
swaitsandClaude Fable 5.1 caad35d75b docs(handbook): the invoices-and-bills procedure, whole (P4b S4.5)
Step 1 now covers the issue: preview first (nothing written, the DRAFT PDF,
an abandoned preview uses no number), confirm only after the human has
seen it, the filed issued copy that a later look never replaces,
quantities and prices, the PO, and the snapshot of the customer at issue.
Step 6 is the CPA's list: agings tied to 1200 and 2000, sequential numbers
with voids visible, bills on their real accounts with the vendor's number
and no duplicate, card payments identified for 1099-NEC, deposits as
liabilities, light governance by decision.

A {documents_v2} placeholder (handbook::section_with, the server's
documents_v2 feature test) says "your books have not declared documents_v2
yet" before the owner's upgrade and what is on after it; no other section
changes. Parity, testing (with the For-P12 paragraph) and mcp-interface
notes for S4.5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:55:10 -06:00
swaitsandClaude Fable 5.1 df93d8b951 feat(viewer): the invoice page shows its number, terms, bill-to and PO (P4b S4.5)
The viewer reads the same invoice the server prints. The Money in list and
the page title name an invoice by its issued number (a legacy invoice keeps
"Invoice {id}"); a Details card shows the number, terms, the bill-to as it
was at issue (contact, address, phone, email), Ship-to, the customer's PO
and memo, and a bill's vendor number, day received, terms and memo. Save
invoice PDF still re-creates the current copy; when the issued copy is
filed (found through its link to the invoice, never by name) Save issued
copy and Open issued copy hand over those very bytes, checked against
their SHA-256. Nothing is written to the books.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:55:10 -06:00
swaitsandClaude Fable 5.1 400d0cdcab feat(pdf): the issued invoice carries what the customer pays from (P4b S4.5)
The page a customer pays from must show what the books issued, and only
that. InvoiceDoc gains each line's quantity and unit price ("3 x $33.33"
beside the line's own amount, never recomputed), the customer's PO, memo,
footer, the ways to pay (a QR code through labels::qr_svg for a method
with a link that asks for one), the seller's email and website, and a
Ship-to block. InvoiceVersion separates the issued copy (no stamp, no
settled rows, no "re-created" note) from the current copy, whose
"Re-created on" note prints the as_of it was given, never the wall clock,
so the bytes stay deterministic (B-40 item 3). Every amount reaches the
page through report::amount_parts; every string is data.

ledger_package builds all three copies (issued, DRAFT, current) from one
function over the invoice's own snapshots (A-13, D-55: number, terms,
bill-to contact block, shipping address), falling back to the party record
only for a document made before snapshots, so the three cannot disagree.
invoice_document drops its now-unused `today` argument.

rules.md gains B-39 (terms) and B-40 (numbers and the issued copy) from
the 8.3.13 spec blocks, CONVENTION until their sources are vendored.
No golden moved: the render test never held a hash, it compares two
renders for equal bytes, and still does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:55:10 -06:00
swaitsandClaude Fable 5.1 f81633811b docs(bookspec): P13 steps 4 and 5 test record, For-P12, and As built
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:51:30 -06:00
swaitsandClaude Fable 5.1 3acd634dc4 feat(bookspec): GnuCash as the second engine, its differences settled (P13 step 5)
bookspec-gnucash implements the Engine trait over the oracle container
(oracle/gnucash/books.py) and claims the rules whose examples ask only
for balances. It passes BS-entry-3, BS-ledger-3, BS-statement-1 and
BS-statement-2 and differs from three examples, each adjudicated from the
sources and pinned by tests: GnuCash accepts an unbalanced entry and
parks the difference in an Imbalance account (BS-entry-1 refuses it); it
reads a credit of minus 100 as a debit (BS-entry-2 is a convention that
removes the ambiguity); it rounds 10.005 to 10.01 silently (BS-ledger-1
is the owner's choice not to). The example stands in each case: GnuCash
is a cross-check, not the authority.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:51:30 -06:00
swaitsandClaude Fable 5.1 0e6b6bb768 feat(bookspec): the checker derives the examples' figures from the rules (P13 step 5)
An engine-free reference model with exact decimals replays an example's
entries, reversals, close, bills and invoices and computes its
trial_balance, balances, profit_and_loss and entry figures from the
rules' symbolic forms (signed sums, normal sides, A = L + E + (R - X),
net income), and checks that each refusal an example expects is justified
by its rule (debits differ from credits, an amount is not positive, finer
than the minor unit, an entry already reversed, a date inside a closed
period). An example whose figure disagrees fails the specification's
build before any engine is consulted; what the model cannot derive is
reported as skipped, never as passed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:51:30 -06:00
swaitsandClaude Fable 5.1 1fbc1383cb feat(bookspec): the specification text and an example for the ledger's own laws (P13 step 4)
bookspec/spec holds the first edition: the model and the five laws
(00-model.md) and one file per area, each B-rule a section with its
status, a MUST/MAY statement, sources pinned either to a vendored source
with a verbatim quotation or to an exact external paragraph, its symbolic
form, its examples and its applicability. B-39, B-40, B-43 and B-45 are
reserved sections (allocated, not yet stated in rules.md); B-44 is stated
in rules.md and now carries its bookspec id and CONVENTION status.

Eight new examples show the laws that had none, each figure worked by
hand: an unbalanced entry is refused (Pacioli ch. 36, Pub. 583), a
negative or zero line is refused, a fraction of a cent is refused (ISO
4217), a child account takes its parent's type, the trial balance and the
accounting equation on one small book (cash 6550.25 = loan 2000 + capital
5000 + (sales 750.50 - rent 1200.25)), a reversal leaves the original
standing and a second reversal is refused, a close sweeps revenue and
expenses into Retained Earnings (3999) and the closed quarter takes no
late entry, and cents add exactly. The refusal vocabulary gains
unbalanced, negative_amount, zero_amount, too_few_lines,
finer_than_minor_unit, parent_type_mismatch, already_reversed and
period_closed; a refused entry may carry a non-positive amount.

The checker's spec.rs fails the build on a rule without its section, a
statement without MUST/SHOULD/MAY, a pin that is not verbatim in its
vendored source, examples that differ from the catalogue, or more than 60
pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:51:30 -06:00
swaitsandClaude Fable 5.1 055decf7e2 feat(bookspec): the five laws as Lean theorems (P13 step 4)
The first edition's five laws (debits equal credits; the accounting
equation; the trial balance balances; net income articulates to equity;
the cash-flow sections sum to the change in cash) are theorems in
bookspec/lean, ported from verify/lean (Basic, Entry, Statements, Closing,
Ledger under namespace Bookspec) and restated by name in Laws.lean, each a
statement about every reachable state of the books. Audit.lean prints
their axioms; the checker's lean.rs test fails on a sorry, a law not
audited, or any axiom beyond propext, Quot.sound and Classical.choice.
Money is Int, the exact count of the smallest unit: every law is a law of
addition, so the model is faithful to exact decimals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:51:30 -06:00
swaitsandClaude Fable 5.1 ce69d9d7bc build(mise): the packager tools are pinned when the trial recipe exists
check / check (push) Canceled after 0s
check / portable (macos-latest) (push) Canceled after 0s
check / portable (windows-latest) (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
P11a step 0 pinned cargo-packager, cargo-packager-updater and
cargo-generate-rpm for the trial it did not run. mise-action compiles
every pinned tool on every CI run of every job, so three cargo tools
nobody calls yet would cost minutes per run. They return to mise.toml
with the recipe that calls them (AGENTS.md: if a recipe needs a tool,
it goes in mise.toml), when the trial step starts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:47:08 -06:00
swaitsandClaude Fable 5.1 5e220ccada merge: P11a step 0, the macOS and Windows keyring stores and the portable CI job (branch P11a, ee4f8cd5)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:47:08 -06:00
swaitsandClaude Fable 5.1 39c76d3fac merge: U0d, the reader crate (branch U0d, bb1eb842; D-61)
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:45:54 -06:00
swaitsandClaude Fable 5.1 bb1eb84200 refactor(reader): extract.rs moves into ledger-reader (U0d)
D-61 (Q-9): the writer sheds the typesetter and the crate map is layered
again. The PDF/XLSX reader (extract.rs, plan P6) is now the plain library
crate ledger-reader, moved whole with git mv and no behavior change.
ledger-store depends on it instead of ledger-import, so the store no
longer builds Typst or the fonts; ledger-import re-exports it as
ledger_import::extract so gui, mcp, host and the fuzz targets compile
unchanged, and still never depends on the store. New crate carries only
the dependencies extract.rs imports (hayro, hayro-interpret, kurbo, zip,
quick-xml, thiserror); fuzz/Cargo.lock gains the new package.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:45:14 -06:00
swaitsandClaude Fable 5.1 ee4f8cd58a feat(host): Keychain and Credential Manager token stores, a macOS/Windows CI job (P11a step 0)
The agent path was Linux-only because tokens had a keyring store only
there (every other OS hit NOT_YET, so `connect` and the bridge failed).
D-56's destination is one installation for macOS, Windows and Linux.
Both new stores sit behind the same Items trait as the Secret Service,
so INVARIANT 4 (one account, one item, locked is an error) is kept once
and tested once through the file keyring. Windows credentials have a
unique target name, so a probe stands in for a search.

New dependencies (ledger-host only, target-gated): apple-native-keyring-store
(feature keychain) and windows-native-keyring-store, the stores the
keyring-core 1 API we already use is published with. No ledger-core change.

The `portable` job builds and tests the workspace on macos-latest and
windows-latest. Windows is allowed to fail: the rest of the host is
Unix-only (inventory in docs/packaging.md). Not verified locally: no
macOS or Windows target is installed; CI is the evidence.

Also pins cargo-packager, cargo-packager-updater and cargo-generate-rpm
in mise.toml for the trial, and records that the trial is not yet run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:41:01 -06:00
swaitsandClaude Fable 5.1 f98471516e docs(handoff): the worker's checks follow D-60
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The worker instructions still said `just check` before every PR; under
D-60 a worker compiles every target, runs fmt, clippy on the crates
touched and the unit's named tests, and the EM runs the exhaustive
gate once per wave.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 12:29:45 -06:00
swaitsandClaude Fable 5.1 25b5258036 docs(plan): Q-9 answered (D-61): the reader crate, unit U0d after S4.5
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The owner, 2026-10-29, the question tool: the reader (`extract.rs`)
moves into a small library crate of its own, `ledger-reader`, that
`ledger-store` and `ledger-import` both depend on, so the writer sheds
the typesetter and the crate map is layered again; half a day for the
implementing team once S4.5 is in. 8.2 gains the unit U0d in the
template (its Done-when is `cargo tree`), 8.0.1 its row, 8.0.3 the
crate name (D-53's closed `ledger-extract` subprocess stays closed),
10.1 D-61, 10.3 closes Q-9. STATUS.md, HANDOFF.md and architecture.md
say the same; the arrow stands as built until U0d lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 10:54:16 -06:00
swaitsandClaude Fable 5.1 1111157ed1 ci(check): room on the runner for the workspace's test binaries
The first `check` run after P6 landed (`bcd1048b`) died linking the
store's and the package's test binaries: "No space left on device",
ld killed by SIGBUS. The store's test closure now carries the file
reader (hayro, zip, quick-xml, and through ledger-import the Typst
typesetter: plan Q-9, D-61 moves the reader out after S4.5), and the
runner's disk had no room left for it with full debug info and
incremental caches.

The job drops incremental compilation (a fresh runner gains nothing
from it), keeps line tables only (file:line in a panic's backtrace at
a fraction of the size), removes the image's unused toolchains before
the build, and prints the disk before and after so the next squeeze
is visible.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 10:54:16 -06:00
swaitsandClaude Fable 5.1 bcd1048bb8 docs: the paused wave has landed; D-60, how much checking a merge gets; Q-9
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The plan and the status documents say what is true after the merges
of 2026-10-29: served.md section 0, the 8.0.1 rows and unit Status
lines for S4.4, S5, S6, S7 and P6 (landed, with commits), the 8.0.2
resume paragraph turned into the record of what the merges found,
P6's At-merge bullet (the write-off and dedup interaction, the five
tests its interrupted gate never reached, schema 18), P9a's Done-when 1
verified by the green two-user job, D-58 and D-59 in order.

D-60 (the owner): per merge a compile and the unit's own tests; the
exhaustive gates once per batch, at the end. AGENTS.md's two gate
sentences, HANDOFF.md's merging procedure and the plan carry it, with
the reason: five full gates around five merges took twelve hours and
the one gate at the end found the same five tests.

Q-9 (open, for the owner): P6's `ledger-store` -> `ledger-import`
dependency, with the options and the recommendation; architecture.md
marks the arrow as under review. testing.md records the merge's test
changes under P6. STATUS.md gains "Now (2026-10-29)" with the gate
figures (170 suites, 2,944 tests, 77 minutes), the CI state and the
next steps; HANDOFF.md, roadmap.md, gui.md and the parties doc drop
the paused-branch wording; README counts 129 tools and about 2,950
tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 08:23:51 -06:00
swaitsandClaude Fable 5.1 3a1f4fd4eb test(store): a write-off whose notice is another entry's receipt is refused before posting
The defect found merging S7 and P6: under P6's dedup the notice's
bytes resolve to the record already on file, and the one-receipt-one-
entry rule refused it only after the write-off had journaled its
entry. The store now checks the deduplicated record before the journal
line (`6c26160e`'s companion fix in `write_off_invoice`); this test
pins it: the refusal names the record and its entry, the head and the
journal are unchanged, no entry posts, the invoice stays open, and the
books verify. Also formats the B-27 test `ci-fixes` brought in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 08:23:51 -06:00
swaitsandClaude Fable 5.1 95a54d69ab merge: ci-fixes (fuzz_settings_replay's renamed path; a B-27 check for an unknown convention kind)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 08:21:47 -06:00
swaitsandClaude Fable 5.1 bfd453b679 test: the tests P6's gate never reached, under one-record dedup
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
P6's branch paused with its gate interrupted, so five tests that
predate plan P6 step 4 (the same bytes filed twice are one attachment,
linked to each subject) still encoded the invariant it replaced, and
one golden carried counts from before extraction records existed.

- store: `blob_dedup_and_hash_verification` expects one attachment
  row under the first filing's name, not two. The `attach_receipt`
  spec block's INVARIANT 2/3 now say what P6 built; the stale "each
  filing is its own attachment" was the one wording left.
- store: the power-loss replay copies the blob store too. The books
  on disk were always journal, database and blobs; verify now reads
  an extraction's text blob (P6 step 2), so a copy without `blobs/`
  cannot verify.
- store: the refusals property allows `AttachReceipt` and
  `FileDocument` to journal nothing when the bytes are already on
  file: the record they return is the record they were.
- viewer: each statement the reconciliation helper files has its own
  bytes (a wrong statement is voided and a new one comes); identical
  bytes would be one record, and a record is one statement.
- viewer: the tall sidebar's shapes golden is re-blessed. The fixture
  books now carry extraction records, so the Overview attention
  count, the event count and the verified-file count the sidebar
  prints moved; no layout changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 07:04:22 -06:00
swaitsandClaude Fable 5.1 6c26160e26 test(bookspec): each write-off in an example files its own notice
Under P6's dedup (one record per bytes) the adapter's constant letter
made the example's second write-off of the same invoice reuse the first
write-off's record, which a receipt's one-entry rule (B-38) refuses, and
the write-off now refuses that before anything is journaled (the S7/P6
integration fix). The example's two write-offs have two notices, which
is what it describes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 05:25:22 -06:00
swaitsandClaude Fable 5.1 9351f89d38 merge: P6, the evidence pipeline (branch P6, checkpoint ce366408)
The last of the paused branches, from its "not for merge" checkpoint:
extraction in ledger-import (text, pages, page images; capped, caught),
journaled as attachment.text_extracted; dedup by hash with
books.find_attachment and books.consolidate_duplicates; annotations and
books.set_entry_settles; the company record categories; the two fuzz
targets; feature attachment_text. Nineteen files conflicted with the
four P4b merges, resolved as plan 8.0.2 says: P6 retakes schema 18 (it
had taken 16, which S4.3 holds; S5 holds 17); the tool table is 129 and
the destructive count 31; both sides' appends kept with their seams
restored; one B-27 paragraph naming every feature; in the old-books
fixture P6's hand-written reimbursement is entry 7 (its ids collided
with S4.3's invoice); the replay fuzzer's list gains S7's
invoice.written_off; S4.3's issued-PDF test expects the extraction
record P6 journals right after a filing.

One integration defect between S7 and P6, found by the arap property
test and fixed here: under P6's dedup, a write-off whose evidence bytes
were filed already (the same collection letter for a second write-off of
the same invoice) was refused only after its entry had been journaled,
at the final link to the invoice ("already linked"). The write-off now
resolves the dedup before anything is journaled and keeps a link the
record already has to the invoice instead of refusing it; an explicit
existing_attachment_id already on an entry is still refused (S7's
one-receipt-once rule). P6's gate never ran before the pause; it runs on
main after this merge, and its ledger-store -> ledger-import dependency
is the open review point (plan 8.0.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 05:05:34 -06:00
swaitsandClaude Fable 5.1 fb83af5948 verify(lean): one docstring on Books after the S5 and S7 merges
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The S7 merge kept both branches' doc comments for the Books structure,
S5's (which names the sends) stranded above an unrelated theorem and
S7's on the structure; Lean refused the stray one. S5's text stays, on
the structure it describes. Kani's 52 harnesses verified in the same
run; only Lean's build stopped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 05:02:12 -06:00
swaitsandClaude Fable 5.1 0cbb49b6d2 test(fuzz): the replay fuzzer writes invoice.written_off
S7 added the event type (R-B step 6: the fuzzer's list must name every
type the store replays, the_replay_fuzzer_writes_every_event_type) and
the merge lost the line; the S7 gate on main caught it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 03:54:10 -06:00
swaitsandClaude Fable 5.1 7ebeeed2e6 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-29 02:59:55 -06:00
swaitsandClaude Fable 5.1 76285434cb fix(fuzz): fuzz_settings_replay builds again; a B-27 check for an unknown convention kind
The weekly run's fuzz_settings_replay job failed to build since P3 gave
each projection schema its own file: the target still named
ledger_store::DB_FILE, which became db_file(). The fuzz crate is outside
the workspace, so just check never compiled it; every target builds
again (cargo fuzz build, 2026-10-29).

The same run's fuzz_store_replay crash (the writer refusing a journal
with an unknown convention kind as newer while the viewer said something
else) is not reproduced yet: the hand-made case, kept here as
an_unknown_convention_kind_is_newer_for_the_writer_and_the_viewer_alike,
passes, so the divergence needs the fuzzer's exact journal, which the
weekly job now keeps as an artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 01:56:32 -06:00
swaitsandClaude Fable 5.1 fad2f0e9bb ci: keep a fuzz crash's input as an artifact
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The 2026-10-26 weekly run found a fuzz_store_replay crash (the writer
and the viewer disagreeing on a journal with an unknown convention kind)
and uploaded nothing, so the input is being hunted again locally. A
failing fuzz job now uploads fuzz/artifacts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 01:56:31 -06:00
swaitsandClaude Fable 5.1 fc7ba81f12 ci: mutation testing is local only (D-59); the weekly run keeps proofs, oracle and fuzz
The owner, 2026-10-29: mutation testing is for local runs, not CI. The
weekly workflow's mutation shards (core, store, import, config, MCP) are
removed; they failed every week on the survivors and timeouts D-33
deferred to P12, and their red hid the run's real findings (a
fuzz_store_replay crash and a fuzz_settings_replay build break, both
being fixed). just mutants-* stays for the desktop; P12 runs the pass
whole. Recorded as D-59; the docs that said the shards keep running are
corrected.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 01:28:50 -06:00
swaitsandClaude Fable 5.1 79fb7ff2fd ci: the two-user job's copies belong to the server account
Every run since P9a landed failed at the first adoption with
"/tmp/fixture: Permission denied": the copies were owned by the runner
(cp -a keeps the checkout's ownership) and the server account, which
adopts them, could not take the source's lock. Both copies are now owned
by the server account; the agent account keeps read access to the
source's journal, which the test compares the moved books' head with.
P9a's Done-when 1 (the job's first green run) is checked on this push.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 01:25:44 -06:00
swaitsandClaude Fable 5.1 b66fc89392 test: the tool count after the S6 merge is 124
S5 and S6 each raised the journey test's expected count from 122 to 123
on the same line, so the two merges folded into one bump; with
books.mark_sent and books.bills_due both present the table is 124. The
S7 merge raises it to 125 and P6's to 129.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-29 01:22:02 -06:00
swaitsandClaude Fable 5.1 2ad192bbcd merge: P6, the evidence pipeline (branch P6, checkpoint ce366408)
The last of the paused branches, from its "not for merge" checkpoint:
extraction in ledger-import (text, pages, page images; capped, caught),
journaled as attachment.text_extracted; dedup by hash with
books.find_attachment and books.consolidate_duplicates; annotations and
books.set_entry_settles; the company record categories; the two fuzz
targets; feature attachment_text. Nineteen files conflicted with the
four P4b merges, resolved as plan 8.0.2 says: P6 retakes schema 18 (it
had taken 16, which S4.3 holds; S5 holds 17); the tool table is 129 and
the destructive count 31; both sides' appends kept with their seams
restored; one B-27 paragraph naming every feature; in the old-books
fixture P6's hand-written reimbursement is entry 7 (its ids collided
with S4.3's invoice); the replay fuzzer's list gains S7's
invoice.written_off; S4.3's issued-PDF test expects the extraction
record P6 journals right after a filing.

One integration defect between S7 and P6, found by the arap property
test and fixed here: under P6's dedup, a write-off whose evidence bytes
were filed already (the same collection letter for a second write-off of
the same invoice) was refused only after its entry had been journaled,
at the final link to the invoice ("already linked"). The write-off now
resolves the dedup before anything is journaled and keeps a link the
record already has to the invoice instead of refusing it; an explicit
existing_attachment_id already on an entry is still refused (S7's
one-receipt-once rule). P6's gate never ran before the pause; it runs on
main after this merge, and its ledger-store -> ledger-import dependency
is the open review point (plan 8.0.1).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 23:08:59 -06:00
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
swaitsandClaude Fable 5.1 d329bd6f56 merge: P4b S4.4, the create tools preview and confirm, the party contact block (branch S4.4, 002fb9f8)
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The first of the five paused branches (plan 8.0.2, "Resuming the paused
wave"). books.create_invoice and books.create_bill preview without
confirmed and post with it (A-1), the issued PDF filed in the same
store call, the party's full contact block (D-55) on books.update_party
and the invoice's bill-to. The only conflict was docs/parity.md, where
the 2026-10-28 review had written the S1-S4.3 notes on the A/R and A/P
rows and S4.4 its own: both are kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:11:11 -06:00
swaitsandClaude Fable 5.1 d6388d7be7 docs: D-57 and D-58, the owner's answers to Q-5 and Q-8
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
Q-5: one format upgrade of the live books, after S7 and P4 have landed,
declaring documents_v2 and drafts_v2 together (D-52 confirmed; A-14
records it). Q-8: the review's decisions D-47 to D-54 are the owner's;
the "may revisit" clauses are closed. Both asked and answered through
the question tool on 2026-10-28. The owner also chose to have the
planning session merge the paused branches itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:07:19 -06:00
swaitsandClaude Fable 5.1 af553a3706 docs: the 2026-10-28 drift review; the plan, status and docs brought to date
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
The planning session read plan/served.md, every design document and the
five branches the owner paused on 2026-10-27 against main (44ee4083),
with dry-run merges touching no branch. plan/2026-10-28_DRIFT_REPORT.md
lists every divergence and judges every recorded departure (all kept;
three owed back; one needs a decision: P6 made ledger-store depend on
ledger-import).

The plan: section 0 "What changed on 2026-10-28"; 8.0.1 and the unit
Status lines name S4.4, S5, S6, S7 and P6 as built but unmerged; 8.0.2
records the destination (D-56: the installed app, P11a starts now) and
the resume order with the known conflicts (the shared documents.rs
test file, the types.rs appends, P6 retaking schema 18, the law numbers
34 to 37); 8.0.3 lists the names the EM approved during the wave; P11a
gains step 0 (the macOS and Windows keyring stores, the Windows data
root and pipes, a cross-platform CI build: today the agent path runs on
Linux alone); Q-5 is due and Q-8 asks whether D-47 to D-54 stand; the
Landlock sentences in section 2 and server.toml are struck.

STATUS.md and HANDOFF.md say where things stand and how to resume;
roadmap.md and gui.md are rewritten to today. The docs lose their
stale facts: 122 tools, 2,700 tests, the parity floor at 21 with the
P4b notes the slices never wrote, the ten crates and their dependency
graph (jiff added to the core's allowed list), the cutover of
2026-10-20 recorded in capability-parity.md and parallel-run.md, the
P4b S1 law and its two Kani harnesses added to verification.md (law
34), the proof counts (51 and 9), and README, the site and install.sh
honest about macOS (the viewer runs; connecting an agent waits for
P11a's keyring store, and the installer now says so instead of failing).

just check on main at 44ee4083: exit 0 (167 suites, 2,871 passed, 0
failed, 7 ignored, the server-only build clean). No code changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 18:26:23 -06:00
swaits 44ee408312 docs: the paused team is stopped; record the branches and P6 checkpoint 2026-10-27 14:42:19 -06:00
swaits ce36640874 wip: pause checkpoint — P6 feature complete, gate interrupted (not for merge) 2026-10-27 14:41:34 -06:00
swaits 3a1b8d8576 docs: record the owner's pause of the five in-flight units 2026-10-27 14:39:08 -06:00
swaits ad649be3d5 docs: S5 step 4 landed; attention predicates in the test record (P4b S5)
The As-built block records step 4 built after U0b, its two items and
their tests; docs/testing.md names them and what P12 must mutate (the
number exemption, the open/partial check, sends.is_empty(), the two day
comparisons with their checked date arithmetic, the last-send max);
docs/parity.md says sent tracking also feeds Needs attention.
2026-10-27 13:46:43 -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 b0d4ae7f8e docs(plan): P4b S5 as built (P4b S5)
Records the departures: step 4 (the two attention items) deferred until
U0b (R-F forbids them before it); the Lean step that AGENTS.md's bar
requires over the unit's "no Lean change"; the recipients parameter
renamed for the path-name lint; the note checked for a full TIN; schema
16 taken at merge (S4.3 had not landed); and the handbook section
created before S4.5.
2026-10-27 13:24:10 -06:00
swaits 7c5a2fe3ce docs: sent tracking in the rules, parity and test records (P4b S5)
rules.md B-27 lists `documents_v2`, the one feature S4 to S7 share
(D-52), including `invoice.sent`; receipt-store.md lists the event;
parity.md gains the sent-tracking row (done: implemented, tested, and
golden-covered by the trial balance of the fixture book around a send);
testing.md records the tests and what P12 must mutate, and names the
two attention predicates as deferred with step 4 until U0b lands.
2026-10-27 13:24:10 -06:00
swaits 47c0e4396e verify(lean): Step.markSent, a send moves no figure (P4b S5)
AGENTS.md's formal-methods bar makes every new Ledger command a Lean
Step with a preservation proof, so `Ledger::mark_sent` gets one even
though the unit expected no Lean change (the As-built block records
that). `Documents.lean`'s `Books` gains the sends recorded, and
`Step.markSent` adds one on a document the lines carry. A send touches
no line, so every control balance, every document's sum and what is
unattached are unchanged through any day
(`Step.markSent_changes_no_figure`, no axioms), and `Reachable.tagged`
covers the new step.

`Audit.lean` lists the theorem; rules.md B-38's model now covers the
record side of a document's life as well as its figures.
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 d74ee1e962 docs(plan): P4b S7 as built (P4b S7)
The unit's As-built block: where the build departed from its text, and
why (the bad_debts role, the least-owed bound over the date-sum, the
evidence rule, the handbook section it had to create, the bookspec
schema's write_off event, and where the tests live). The Build text is
untouched; the EM moves the status row.
2026-10-27 12:45:01 -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 f31dd2640b docs: the write-off's place among the destructive tools and the tool counts (P4b S7) 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 62f2fb058e feat(verify): prove a write-off never takes a document below zero (P4b S7)
Ledger::write_off is a new command, so it is a new Step: Step.writeOff,
whose two lines are a tagged control line (the receivable falls) and an
untagged expense line (the deduction). Being a document step in shape, it
moves nothing unattached, so control_agrees and
Reachable.control_is_documents still hold with no new proof:
Step.writeOff_unattached states its half of that.

writeOff_within_bounds is the law the command's refusal enforces: a
write-off of at most what the document is owed, read over the date-sum
docSum ... through D (B-38 fixed that sum, not standing), leaves what is
owed through the write-off's day at or above zero. through_append lets
the write-off's own two lines be read separately.

Both are listed in Audit.lean; just prove holds (standard axioms only, no
sorry).
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 002fb9f8fc docs(plan): S4.4 as built (P4b S4.4) 2026-10-27 12:23:35 -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 ed637f0e27 docs: D-55, the owner's full party contact block; fold it into documents_v2 2026-10-27 11:04:50 -06:00
swaits 4cad2cfce2 docs: P4b S4.3 landed; the document journal holds the new fields, with two departures recorded 2026-10-27 10:37:31 -06:00
swaits d19fb464b7 docs(plan): S4.3 as built (P4b S4.3)
The store now journals the documents_v2 fields, previews without
journaling, files the issued copy and locks invoicing.first_number once a
number exists. This records the departures: the store's create commands
keep their signatures and delegate to _with variants, the issued-copy call
takes the create's arguments expanded (the store has no params struct),
schema 16 adds the document columns only (the projection has no parties
table), the over-the-wire document cut-over entry waits for S4.4, and a
refusal leaves the journal unchanged while a storage failure between the
invoice's lines and its attachment's is not rolled back.
2026-10-27 10:37:03 -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 6be0055927 docs: P9a landed; the tunnel door is loopback-only and its live checks remain owner's 2026-10-27 09:37:46 -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 47951e5734 docs: record P9a as built, and what is still the owner's to verify
The unit's Build assumed `--http` existed (3.9 names it) and that a
headless server could mint a token with `token create`; neither was true.
The As-built block records both, plus the client-side bearer that 3.3's
`token print` cannot supply for a remote book, and the loopback-only unit
rule that blocks a remote backup push. Done-when 1 (the CI job) and 2
(clean-VM walkthrough) are explicitly left to the owner and EM; only the
local evidence is claimed.
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 cc4f104f06 docs: U0b landed; books.start and the viewer share one attention engine 2026-10-27 09:09:18 -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 eeee9658da docs: U0a landed; the MCP handler groups are now the rule 2026-10-27 05:51:32 -06:00
swaits 45396bdce8 docs(architecture): name the ledger-mcp tool groups (U0a)
The layout paragraph the unit asks for: server.rs as the server, the
router and the request machinery; one tools/<group>.rs per tool group
with its router; tools/helpers.rs for the helpers several groups share;
server_tests.rs for the tests that span the surface and each group's own
tests in its file. plan/served.md's U0a "As built" block records the
split, the two departures (settings_tools as the module name, the test
bodies beside server.rs) and the stale 118-tool count (the surface is
122, identical to main's).
2026-10-27 04:15:47 -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 d5385a7ee9 docs: P4b S4.2 landed; S4.3 owes the guard removal and the number lock 2026-10-27 03:46:51 -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 6995ebfee6 docs: P4b S4.1 landed; record the interim document-line guard and S4's departures 2026-10-27 01:57:04 -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 58724bb007 docs: P13 steps 1-3 landed; record the CHOICE vocabulary and Engine::change 2026-10-26 23:23:21 -06:00
swaits 579d0eed5e fix(bookspec): the rebased P7 sentence speaks CHOICE too (P13) 2026-10-26 21:30:47 -06:00
swaits f85b03f467 docs: finish the DECISION-to-CHOICE vocabulary outside rules.md (P13) 2026-10-26 21:23:16 -06:00
swaits 1855aa938e docs: drop the rebase marker from the P13 testing section (P13) 2026-10-26 21:23:16 -06:00
swaits f91798cd6e fix(bookspec): the schema refuses a key only its query may name (P13)
`accepted` and `refused` are a command's (and `refused` a period's): the
schema now refuses either on a query that does not take it, as the loader
already did, instead of letting a malformed example reach the runner.
The no-op table walk in the same branch goes with it.
2026-10-26 21:23:16 -06:00
swaits cb4e955942 feat(bookspec): rule identifiers from B-1 to B-45 (P13)
Every rule of `docs/spec/rules.md` now has one bookspec identifier,
`BS-<area>-<n>`: eleven areas (entry, ledger, statement, period, partner,
asset, prepaid, arap, revenue, 1099, format), numbered in B-rule order
within its area. The table is `bookspec/spec/rules-index.md`, and each
stated rule carries its id under its heading in `docs/spec/rules.md`, so
a reader of either can find the other.

rules.md's DECISION status becomes bookspec's CHOICE: the specification
names the options and an engine declares its pick (`Claims::with_choice`),
which is what an example already meant by `status = "CHOICE"`. B-39 to
B-45 are allocated in `plan/served.md` 8.0.3 and not yet stated: they
carry an id and an area and no status, so a later unit fills them in
without moving anyone else's number.

The catalogue names the examples that demonstrate each rule (56 files,
each under at least one rule) and the rule each refusal reason belongs
to. A rule with no example cannot be claimed: a claim needs evidence.
`docs/architecture.md`, `docs/testing.md` (tests and the For-P12
mutation targets) and `docs/parity.md` record the new shape; the
bookspec README points at the checker, the runner and the adapter.
2026-10-26 21:23:16 -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 8c956e8d74 docs: P7 landed; the burst-post test's load sensitivity recorded 2026-10-26 20:29:55 -06:00
swaits 3e65c83f96 docs: B-26's TIN separators and P7's mutation targets (P7)
rules.md B-26 now says what the scanner reads: a comma or a parenthesis
between a TIN's 3-2-4 or 2-7 groups, and any run of spaces counting
once, while an amount's comma-grouped thousands stay an amount. The
filing scan's (a) and (b) renderings gain commas. docs/testing.md gains
the P7 section and the For-P12 mutation targets (D-33), and plan/
served.md's P7 As-built block records the three departures.
2026-10-26 20:29:15 -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
swaits 40101f197e fix(just): sweep only day-old test roots, so parallel worktrees keep theirs 2026-10-26 19:42:14 -06:00
swaits 750759075b docs: U0c landed; record the books.documents party-filter gap (2026-10-26) 2026-10-26 18:52:24 -06:00
swaits 198aa2a115 docs(plan): U0c as built (U0c)
Records the two departures. The timeline's reversed items are labelled,
not struck through, so the S2 paragraph targets the labels the viewer
draws. And the A/R and A/P section's party filter takes a customer's or
vendor's name (the handler also matches a document's own id), not the
customer or vendor id the old text and the schema promised; the schema
is a code finding, because this unit may not change code.
2026-10-26 15:59:06 -06:00
swaits feae51e49f docs(mcp): the A/R and A/P section against the shipped tools (U0c)
Checking the section against the handlers found four things to say
differently. A row's status is the DocStatus key, which is a different
list from the filter words (overdue and all filter; partial and
unattached only report); the timeline is bounded by as_of; the answer
carries pdf? when it is asked for; and the party filter matches a
customer's or vendor's name, not the customer or vendor id the old text
promised. The section head now says S4 changes the two
document-creating tools, so a reader of today's surface is not
surprised when numbers and terms arrive.
2026-10-26 15:58:28 -06:00
swaits e9febc5694 docs(testing): the P4b S1-B, S2 and S3 sections (U0c)
The 8.0.4 bar asks every unit to record what P12 must mutate (D-33), and
P4b S1 part A has its paragraph while part B, S2 and S3 shipped without
one. S1-B's targets are the payment details (a reference is one line of
at most 200 characters, carried in the entry's description; the
instrument is tagged in the same call) and the two handlers that put
B-38 on the wire (void, control check). S2's are the status filters and
the timeline labels that make a reversed payment read as reversed
instead of disappearing. S3's are the stamp arms and the re-created-copy
branch, where an invoice that has no kept copy says so.

The S2 section records what shipped against the plan's As-built words:
the timeline labels reversed items ("Payment reversed", "Payment
(reversed later)"); the viewer draws no strikethrough.
2026-10-26 15:58:27 -06:00
swaits 1bfce9157a docs: land the 2026-10-26 plan review and the team hand-off (plan/HANDOFF.md, served.md section 8) 2026-10-26 15:20:00 -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 0df7f52ac3 plan: P4b, receivables and payables first class (D-45)
check / check (push) Canceled after 0s
The design (three independent drafts, judged, combined, checked against
main): a document's figures come from its tagged ledger lines that
still stand, so reversals, voids and write-offs are right by
construction and 1200/2000 always equal their documents; hand entries
to them refused; numbered, previewed invoices with terms, the issued
PDF filed, any payment methods printed (QR codes too); bills on their
own accounts, paid on terms; the books record sends and payments a
human or a delegate agent makes, with the processor's fee, and never
send, receive or charge themselves. The owner's answers are recorded;
slices S1 (the defect fix) to S7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 11:41:01 -06:00
swaitsandClaude Opus 5.5 4330671774 test: the sweep ends test processes left behind by a killed run
check / check (push) Canceled after 0s
A test run killed mid-test runs no destructor, so a server it had
stopped (SIGSTOP, to show the CLI doesn't hang on one) stays stopped for
good: one sat for a day. sweep-test-tmp, run before and after just
check, now ends any wanderbooks process whose --root is a test (wb-*)
dir that no longer exists, waking it first; the live books' processes
have no test --root and are never matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 10:16:07 -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
swaitsandClaude Opus 5.5 6c587086a0 feat(iif): carry each line's project in CLASS, the classes declared first
The P3 cutover review's open point 1: entries carry projects (classes,
B-37), but the QuickBooks Desktop file (B-34) left the CLASS field empty
on every line, and neither B-34 nor the README said so, though B-34 rule
6 says nothing changes silently.

Intuit's IIF Import Kit (vendored in docs/spec/sources/intuit-iif.md,
the kit's SHA-256 unchanged): the `!CLASS` header help says "Only the
NAME column is required" and a subclass's NAME is its parents' names
first, joined by colons; the kit's "Import First - List Declarations"
example declares `!CLASS` rows before any transaction, and its "2004
Deposit (Income)" example names the class line by line in CLASS. So the
file now declares every class in a `!CLASS` list after the accounts and
before `!TRNS`, and writes each line's class in CLASS (empty where the
line has none). Books with no class write no list, byte for byte as
before. A line whose class wasn't declared is refused
(IifError::UndeclaredClass), never written classless.

A class's NAME follows B-34's rules for an account's own name: plain
ASCII (rule 3), a colon written '-' (it would start a subclass),
trimmed, '?' if nothing is left, cut to 31 characters: Intuit's qbXML
Onscreen Reference gives `ClassAdd`'s Name "maxDT" : 31 (and 31 in the
Canadian, UK and Australian desktop editions; 100 only in Online), the
same as its `AccountAdd` Name (both JSON files vendored by SHA-256);
Conductor's QuickBooks Desktop API reference agrees and says the name is
case-insensitive. Classes whose NAMEs would match ignoring case get
" (id)", round after round, as accounts get " (code)", except that
among names that match, those already written differently from the
books (made safe, or tagged) yield first, a second " (id)" if need be:
a class's own name is kept unless another class's own name is the same
ignoring case (classes 1 "Café", 2 "Cafe", 3 "Cafe (1)" are written
"Cafe (1) (1)", "Cafe", "Cafe (1)"). Each class is tagged twice at most
and ids differ, so none is left alike (a hand-built list is refused). Each class name
written differently is an IifChange (IifField::ClassName), said one
line each in the warnings and in the package README, whose
"written differently" paragraph now names class names' limits; its IIF
line says CLASS is each line's project when there are classes. The MCP
and viewer notes count "account or class name(s)".

API: `iif_classes(ledger)` builds the list; `journal_to_iif` takes it
(`&[]` for none).

Docs: rules.md B-34 rule 8 with its sources; intuit-iif.md (the
`!CLASS` help, the list declarations and deposit examples, Intuit's SDK
department page, the qbXML Onscreen Reference's ClassAdd and AccountAdd
and Conductor's class reference, each with SHA-256);
mcp-interface.md (books.export iif, the package note); the bookspec
example iif-quickbooks-journal-file.toml (two projects: one carried and
renamed, one only declared; every line derived by hand); testing.md;
STATUS (open point 1 done; point 3 waits for the close, the owner said).

Tests: ledger-import
`iif_writes_each_lines_class_and_declares_the_classes_first`,
`iif_class_names_fit_quickbooks_and_every_change_is_listed`,
`iif_class_names_written_as_the_books_have_them_are_kept`,
`iif_refuses_an_undeclared_or_unfit_class`, property
`iif_class_names_never_clash`; ledger-package
`the_package_iif_carries_each_lines_project` and the bookspec example.
Broken to confirm: CLASS written empty fails the two exact-file tests,
the package test and the bookspec example; the " (id)" tag dropped fails
the names test and the property; tagging every name that meets (no
yielding) fails the kept-names test and the property (its shrunk case,
"A-B" beside "A\uFF1AB", kept in the regressions file).

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 528c070605 feat: invoices and bills take a project, line by line (B-37 item 8)
A client project's invoiced revenue and a contractor's billed cost could
never count toward the project: books.create_invoice and books.create_bill
posted at once with untagged lines, and only a draft's line takes a class.
Job costing (B-37 item 3) collects a job's billings and the supplier
invoices for its direct costs on its cost sheet, so each document line
now takes an optional project.

The owner decided it on 2026-10-22 (plan D-43, rules.md B-37 item 8):
each line takes one project or none; a document spanning N projects is N
lines, each with its own amount; no percentage split, so every figure is
exact; a cost truly shared among projects names none on the document
and reaches projects only as entries of their own under the company's
monthly allocation convention, when books.conventions has one (otherwise
it stays untagged and the agent asks the human). The reasoning, stated
in B-37: job costing charges a job the costs traceable to it, whole, and
reaches shared costs only by allocation on a stated basis. ASC
340-40-25-7 counts as directly related both the costs traceable to a
contract (25-7(a), (b), (d), (e)) and allocations of costs that relate
directly to it (25-7(c)); 25-8 expenses general and administrative cost.
The books carry the first whole on the document's line and the second as
the allocation convention's own entries; neither is a percentage on the
document (sources/fasb-asc.md, 25-7 and 25-8 added). The handbook's
projects rule and the line's class parameter say the same, and no
handbook sentence lets a shared cost be split across a document's lines.

The accounting: the document's entry posts one revenue (an invoice's) or
expense (a bill's) line per project its lines name, in the order each is
first named, each the sum of that project's lines, and the untagged
lines' sum as one more line (arap::document_parts, conservation checked
by a proptest). A document naming no project therefore posts exactly the
one line every document posted before. The receivable or payable line is
never tagged: it is a balance-sheet line, which item 3 counts nothing;
the payments (receive_payment, pay_bill) move cash against it and take no
project either. Nothing but the projects moves: the P&L, the 1099
worksheet (payments made, B-19) and party totals (what moved the money,
B-35) read the same with or without the tags (twin-books test); the
projects with the untagged lines still partition the P&L.

- ledger-core: DocumentLine.class_id (serde-omitted when none, so old
  documents serialize as before); create_invoice / create_bill split the
  revenue or expense side by project and tag it; an unknown project is
  refused whole (atomically).
- ledger-store: a document line's project is journaled as class_id on
  invoice.created / bill.created, and the entry's line as
  entry.line_class_set right after its entry.line_added (the entry.posted
  lines carry class_id, as a hand-built entry's do). Behind a NEW journal
  feature, document_line_projects (KNOWN_FEATURES, declared by new books,
  asked for before the first journaled line, not even the control
  accounts written on refusal); not `projects`, which the live books
  already declared: the owner consents again at the next upgrade. A
  malformed class_id is corrupt; null or absent is none. Untagged
  documents journal byte for byte as before; old journals replay
  unchanged.
- ledger-mcp: each line takes class (id or name, resolved like
  add_line's, every one before anything is written); the result gains
  notes for a finished or abandoned project, and on an invoice for an
  on-spec project (is there now an agreement? on spec a hoped-for
  payment is no receivable, B-37 item 4), tagged anyway; books lacking
  the feature refuse with format_upgrade_pending. books.get_entry shows a
  line's class_id and project. The handbook's "an invoice or bill takes no
  project yet" caveat now depends on the books: gone where the feature is
  declared, an "until their format upgrade" note where it isn't.
- ledger-gui: an invoice's or bill's page (its entry's) lists the
  document's lines, each project a link that opens it; the demo's client
  invoices are tagged to "Client work", whose snapshot is now its revenue
  less its contractors. Looking at the page in a narrow window found a
  tagged line's tag pushing the amounts past the pane (any tagged entry):
  the name is now cut to leave the tag room, the tag itself at most half
  the cell.
- bookspec: an `invoice` event, and `lines` with `project` on a bill or
  an invoice; example projects-invoice-and-bill-lines-count-by-project,
  worked by hand, both projects client work (Zeta 3,500.00 - 1,200.00 =
  2,300.00; Harbor 200.00;
  untagged -1,750.00; together the P&L's 750.00; Dana's 1099 payments
  1,250.00).
- fuzz: fuzz_store_replay can declare journal features.
- Tests: core, store, MCP, viewer, the P2-served cutover fixture and a
  viewer/server parity test (its month lists the same length, not
  empty); a tagged document's reversal nets its projects to zero, live
  and replayed; books.get_entry's class_id; the handbook served as a
  resource both ways; each fix broken to confirm its test fails
  (docs/testing.md). Docs: rules.md B-27 and B-37 items 3 and 8,
  sources/fasb-asc.md, plan/served.md D-43,
  mcp-interface.md, testing.md, parity.md, plan/STATUS.md.

No proof changed (no kernel arithmetic: document_parts sums with the
existing document_total).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 19:28:40 -06:00
swaitsandClaude Opus 5.5 e19df28bf2 Test: the closing door is measured from the close, after a books route
check / check (push) Canceled after 0s
while_closing_the_door_stays_open_refusing_as_requests_come raced the
server: its only request before the close was a route for no books,
which LINGER doesn't count, so when the server reached the linger before
the first ping it found none in the last 500 ms and rightly shut at once
(about 3 runs in 8 on a loaded machine). It now sends a books route just
before the close, measures from the close, and holds the 3 s only when
no request waited LINGER or near it. Checked: 20 of 20 under load; with
LINGER_MAX 1 s or LINGER 0 it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 17:51:58 -06:00
swaitsandClaude Opus 5.5 6e7fe50e53 Test: the tall sidebar's pixels are compared only where they were blessed
check / check (push) Canceled after 0s
CI's software renderer antialiases the same shapes differently (4635
pixels at 1440x920), so a_tall_sidebar_is_unchanged failed there. The
shape hash stays the check everywhere; the pixels are a local check with
SIDENAV_PIXELS=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 15:00:13 -06:00
swaitsandClaude Opus 5.5 641184d3a3 viewer: the sidebar's links fit short windows
check / check (push) Canceled after 0s
The owner's viewer at 1479x519 (scale 1) showed Overview through half of
Accounts above the status card; Assets, Parties, Projects, Documents,
Company and Journal sat in a scroll area with no sign that it scrolled,
so the links seemed to go behind the status box.

The sidebar now gives way in steps, each only when the one before is not
enough, so a window where everything fit looks exactly as it did
(pixel-checked against main at 1440x920, tests/goldens):

1. The status card folds to its one summary line, "● Live · event 559 ·
   ✓ verified". Hovering it shows the three checks; right-click keeps the
   card's copy menu. The line is the fullest wording that fits the card
   (summaries), never cut off: a failed check is named ("⊘ Database
   differs", "⊘ 2 files damaged") before the event and the word "Live"
   are kept, and only then shortened to a count. Whatever the line
   leaves out, its tooltip says.
2. The rows tighten: the footer's menus, the gap under the header, the
   spacing between links, then the rows' height, down to 24 points, in
   the same font. The rail tightens the same way, also to 24 points.
3. Only then do the links scroll, with a soft fade and a chevron at each
   clipped edge (the rail's chevron sits centred on its own pill, clear
   of the icons), and the current page's link is scrolled into view
   whenever the page changes.

Links that overrun their room by under half a pixel count as fitting, so
the card folds where main's links began to lose a visible sliver, not a
point sooner at fractional scales.

At 1479x519 and scale 1 all twelve links show unscrolled in the sidebar,
and at 900x519 in the rail, in every footer state (Live, Snapshot, each
database state, damaged files, no Books menu).

Tests (tests/sidenav.rs, fixture books with a footer like the live one):
every height from 300 to 920 px at scales 1, 1.25, 1.5 and 2, sidebar and
rail, for each footer state: no link under the footer, links cut off only
when they scroll, and nothing scrolls from 519 px up at scale 1; the
owner's window in sidebar and rail; the folded line fits its card and
names a failed check, its tooltip the three checks; the scroll cue and
scrolling to the current link; the tall sidebar unchanged. Unit tests for
fit, its order of steps, the footer's memo and the folded line's
wordings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 14:00:25 -06:00
swaitsandClaude Opus 5.5 2d7b12a936 STATUS: P3 and Projects live on the first company's books
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 09:07:22 -06:00
swaitsandClaude Opus 5.5 3eda95eb3c Projects: the snapshot of a project's figures
check / check (push) Canceled after 0s
The project page opens on a snapshot: revenue, direct costs, the
contribution before overhead and its margin of revenue (a segment
margin: revenue less every cost traced to the project, fixed and
variable; B-37 item 7), in the black or red, and for on-spec work the
cost unrecovered if repaid. To date or this fiscal year. By month,
revenue and costs with the net to date in its own strip; costs by
category; the list colors each net by its sign. books.project carries
the same figures, and a parity test runs the viewer and the MCP on the
same books to the cent. Tests honor TMPDIR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 06:54:26 -06:00
swaitsandClaude Opus 5.5 d24653558c Tokens: one keyring item per token, read back; the bridge says a locked keyring; token check
check / check (push) Canceled after 0s
On the live books a reconnected bridge got no token from a keyring that
couldn't be read, was told to "make a token", and after
`token create --replace` kept on with the new token in memory while
`download` read a revoked one from the keyring.

- tokens.rs INVARIANT 4: keeping a token removes every item for its
  account in every collection, makes one and reads it back; reading finds
  every item and, finding several, refuses with how to keep one again; a
  locked or unreachable keyring is KeyringError::Unavailable, never
  "no token". The logic is shared by the OS keyring and the test files
  (now a folder per collection a test can lock, make unreachable, or
  plant a second item in).
- make_token reads the token back before saying anything, or revokes it.
- The bridge tells the agent the keyring is locked or unreachable and the
  token is fine (or that it holds several items), never to make a token;
  it reads the keyring again before every new session, even with a token
  held.
- `wanderbooks token check --book ID [--name N]`: whether the token kept
  here is the books' live one, asked by its SHA-256 (admin op
  token_check), never its text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 04:17:22 -06:00
swaitsandClaude Opus 5.5 321b94562a Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
A project is a class with facts (description, status, kind, party,
dates, links, notes) in one project.set event behind the format
feature projects. Four tools (books.add_project, update_project,
projects, project); documents and decisions link to projects; B-37:
on-spec costs are expensed as incurred, repayment is revenue, and the
unrecovered cost is never below zero (Kani). Viewer: a Projects list
and a project page with its figures, by month and by account. The
sidebar measures its footer instead of reserving a fixed 172 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 03:50:52 -06:00
swaitsandClaude Opus 5.5 0495726001 P3 review: the surface, docs and proofs
check / check (push) Canceled after 0s
Split kernel::entry_flows into entry_movements and flows_of so the
reversal-mirror proof fits CI; prove runs the fast harnesses, prove-slow
the 8 slow ones (own job in scheduled CI), prove-all both. Laws in
verification.md renumbered 1-31 with a table of all 55 harnesses.

MCP docs checked against the 115 tools (about 60 fixes) with a test that
every tool is documented; rules.md cross-references fixed; bookspec
statuses and loader tightened; viewer notes wrap and accounts sort by
code; STATUS.md says P3 is built, cutover pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 02:13:08 -06:00
swaitsandClaude Opus 5.5 2c5b5a7171 P3 review: P2 can read the books until the owner consents
check / check (push) Canceled after 0s
A schedule-convention setting, the two new tax categories, a disposal
before the in-service date and a depreciation catch-up each need their
feature now (tax_categories_gain_exempt, disposal_before_service,
depreciation_catch_up), so no write before consent leaves books P2 refuses
or misreads; replayed names are read with P2's TIN detector (B-28). The
cutover runs end to end through the real P2 and P3 binaries in a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 17:43:45 -06:00
swaitsandClaude Opus 5.5 6288ead747 P3 review: asset bases and the tax-basis K-1
Schema 13 is as shipped and the asset columns are schema 14 (shipped
migrations pinned by hash); the tax-basis K-1 leaves net income open (only
the return has it) and takes a contributed prepaid plan at its book amount
as its section 723 basis; a disposal of contributed property names its
704(c) gain or loss; aliases compare by meaning; fixture amounts made up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 17:43:44 -06:00
swaitsandClaude Opus 5.5 2ae93fb2d6 P3 review: the same figures agree across the pieces
A distribution declared to a payable and then paid is financing when paid,
the declaration disclosed (ASC 230-10-45-15(a)); a disposal's depreciation
lines count as depreciation in the cash flow and the tax-basis K-1; an
as-of asset list says the status of that day. Seam tests tie the members'
statement, K-1, party totals, cash flow, 1099s, depreciation and the CPA
package together on three dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 16:37:47 -06:00
swaitsandClaude Opus 5.5 2f999c6050 P3: assets' posted and scheduled depreciation, their bases, and the K-1 on the tax basis (defect 13, D-41)
check / check (push) Canceled after 0s
Book value is cost less what the books posted; the schedule's figure is
apart. An asset's acquired date comes from its entry. Contributed
property keeps its section 723 carryover basis apart from its depreciable
basis (the lower of value and adjusted basis), with its 704(c) built-in
gain and method. The K-1 worksheet reports tax-basis capital (Form 1065
Item L) beside the book figures, leaving open what only the CPA knows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:12 -06:00
swaitsandClaude Opus 5.5 3a9aa92315 P3: the books' format moves only with the owner's go-ahead (D-39)
Books an update could write a newer format for open on their old format
and are asked at every open; anything needing a new feature is refused
until the owner agrees with the passphrase, after a verified backup that
includes the keys. New books start on the current format. Each projection
schema has its own database file, so an older version still opens the
books until the owner agrees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00
swaitsandClaude Opus 5.5 b0a9fd8ad4 P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days;
a disposed asset stops on its disposal day. A skipped month is caught up
in the month the run is made; a reversed run un-posts its month. New
schedules spread by day; equal-months schedules must start on the 1st.
Amounts round to the currency's own minor unit. Kani and Lean proofs; an
exhaustive search over runs, reversals and disposals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 14:04:11 -06:00
swaitsandClaude Opus 5.5 1304b9c0ad P3: dated 1099 decisions, payment methods and the card exception (defects 23, 12, 18, D-40)
check / check (push) Canceled after 0s
A vendor's 1099 decision is its own dated fact (files NEC or MISC, not
reportable with its reason, or undetermined), applied by each payment's
date. Payment methods are recorded; card and network payments are left
out and listed; a member's check through financing counts; a bank payment
with no method counts (D-40); $2,000 for payments after 2025. One
worksheet for the tool, the viewer and the CPA package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 12:12:26 -06:00
swaitsandClaude Opus 5.5 c2ceabbae9 P3: party totals by what moved the money, once (defects 14, 11, 24)
Each party's part of an entry is signed as its own account would show it,
with a direction and its cash and in-kind amounts; a reversal is its
original negated. Totals on a cash or all basis: for a member, all equals
the members' equity statement and cash the K-1's cash. Money is cash,
cards and the financing accounts, and a liability credit never pays its
own creditor (creditor_of), so accruals and declared distributions count
once. tin_last4 from the TIN or W-9 facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 12:12:26 -06:00
swaitsandClaude Opus 5.5 9ab003d0d1 P3: rename accounts, one set of period defaults, tax categories warn or refuse (defects 15, 16, 8)
check / check (push) Canceled after 0s
books.rename_account; account names trimmed, bounded, refused when a
sibling's or TIN-shaped; books.show refuses what its target ignores. A
report asked for without dates covers this fiscal year so far, everywhere
(one rule for tools, descriptions and the viewer; the IIF and CPA package
keep their own dates). policy.require_tax_category warns or refuses; a
Form 4797 gain is shown apart on every return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 10:41:24 -06:00
swaitsandClaude Opus 5.5 8255ac2103 Tests and docs: no figures from the first company's books
check / check (push) Canceled after 0s
The odd-cent regression's net loss and its splits, the website's example
Apple charge and the GUI plan's example receipt now use made-up amounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 09:38:17 -06:00
swaitsandClaude Opus 5.5 eb4329bb2a P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
check / check (push) Canceled after 0s
Direct method with the operating reconciliation and noncash schedules; it
foots by construction (Lean and Kani). Every non-cash account belongs to
one section; reversals net; a member or lender paying for the company is
cash borrowed and spent (D-42, constructive receipt and disbursement);
property a member contributes stays noncash. The bookspec seed: one loader
checks every example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 09:35:32 -06:00
swaitsandClaude Opus 5.5 2c29d48ab9 P3: the IIF in plain ASCII with every change listed, and the CPA package as of one day (defects 7, 21)
The CPA package's general-journal.iif now holds every posted entry through
as_of (closing entries left out, D-36), written as plain ASCII with CR LF,
DOCNUM the entry id, QuickBooks Desktop's field limits and subaccount types
honoured, and every character written differently listed in the export's
warnings and the README (grouped by substitution, TINs masked). The 1099
worksheet stops at as_of too. PDFs print CJK, Arabic, Hebrew, Thai and
Devanagari (Noto fallbacks, OFL), and so does the viewer. has_full_tin reads
Unicode separators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 09:35:32 -06:00
swaitsandClaude Opus 5.5 1e8f9275b9 Plan and a doc comment: no figures from the first company's books
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 07:55:26 -06:00
swaitsandClaude Opus 5.5 a7f3cc09a9 P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
Members' shares of earnings are split cumulatively by largest remainder in
the closes, the members' equity statement, the K-1 worksheet and the
viewer, so a member's capital on a given day never moves by a cent when its
month is later closed. Old closes replay as they were; the first P3 close
carries any true-up. Feature members_cumulative. Kani and Lean proofs of
the bounded drift; bookspec examples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 21:35:00 -06:00
swaitsandClaude Opus 5.5 2390dc6553 mise: pin sccache, the compile cache the workspaces share
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 19:13:37 -06:00
swaitsandClaude Opus 5.5 9c99a0cfe8 Format after the history scrub renamed the test fixtures
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 18:04:21 -06:00
swaitsandClaude Opus 5.5 f790a27cc1 plan: D-42 a member paying for the company is cash borrowed and spent (revises D-35)
check / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 16:08:41 -06:00
swaitsandClaude Opus 5.5 6e1a941200 plan: D-40 dated 1099 statuses, D-41 contributed property basis, P3 settled-in-design defaults
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 14:28:31 -06:00
swaitsandClaude Opus 5.5 ac5d9c1d20 plan: D-37 schedules, D-38 odd cents everywhere alike, D-39 pushing the format upgrade
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 13:18:23 -06:00
swaitsandClaude Opus 5.5 754abacc2f plan: P3's first owner decisions (D-35 cash flow, D-36 the IIF file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 13:14:46 -06:00
swaitsandClaude Opus 5.5 49b70b88cf STATUS: P2 live on the first company's books
The cutover of 2026-10-20: adopted at event 466, every number unchanged,
backups to two places, the old copies put aside. P3 next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 13:00:57 -06:00
swaitsandClaude Opus 5.5 cbb7d54463 P2 slice 9: put the old copies aside after an adoption
`wanderbooks books put-aside ID DATA-DIR [--vault DIR --vault-key FILE]
[PATH...]` moves the old copies of adopted books (their data folder, their
TIN vault and its key, their settings file, the git history around them,
old exports) whole into numbered folders of their own in the root's
legacy/, a list of where each came from written and synced first. Nothing
is deleted. Refused unless the books hold everything the old copy does,
byte for byte (journal, stored files, vault records, key; what adoption
left behind and records the books sealed anew are listed and go aside
too); while a wanderbooks process holds it open for writing; when its
head moved since the preview; for links, paths named twice, inside the
root or missing, a vault or key given as a plain path, and paths on
another disk. Renames never replace anything, and a move cut short puts
back what it moved. Three review rounds of data safety.

The plan records the cutover's answers (Q-3: move the old copies aside;
the backup repository and folder).

Also: `connect`'s "the books fernhill' agent token" reads properly, and
the restart tests wait for a copied program to be runnable (a child forked
by another test while the copy was written made it "text file busy").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 12:50:43 -06:00
swaitsandClaude Opus 5.5 c53a01e163 site: deploy without the Netlify CLI's native build scripts
sharp and unix-dgram fail to build here (Node 26), which made every
`just deploy` exit 1 silently; deploying uses neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:22:40 -06:00
swaitsandClaude Opus 5.5 d5c9522ad1 P2: tests mutation found, three bugs fixed; plan P12 and P13
Mutation testing pauses until P12 (D-33, owner 2026-10-20), which reruns
the whole max-testing pass over everything since P1. Before the pause,
pass 1 on slices 3-6 and its triage gave tests in ledger-store,
ledger-package and ledger-mcp, and three real bugs, fixed:
- the command line took an admin answer longer than four MiB, or cut off
  before its newline, whenever the bytes that came still parsed;
- The CPA package put a twin's number on the last dot of the whole path,
  so in a folder with a dot the second file landed in another folder;
- a document or category named of dots alone ("..") could climb out of
  its folder when the CPA package is unpacked.
The plan gains P13, bookspec (D-34): a formal, sourced accounting
specification first (rules with symbolic forms and worked examples), then
its own formal validation and a conformance harness any engine can be
tested against, seeded by P3's regression tests. docs/testing.md records
slices 3-8 and what P12 must cover; plan/STATUS.md says where P2 stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:22 -06:00
swaitsandClaude Opus 5.5 3cf359a0cd P2 slice 8: the viewer's menu, setup, no confinement (D-32)
No Landlock, no seccomp, no helper: isolation is "same_user
(advisory)". The lease socket keeps two intents (close, format-upgrade
consent). The viewer holds the books open while it runs and gains a
Books menu: New books, Back up now, Restore (preview, then confirm),
Close, Connect Claude Code / Codex. New commands `wanderbooks books
back-up` and `wanderbooks connect claude|codex`. The scaffold
(`new`/`init` and its templates) is gone; install.sh needs no sudo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:22 -06:00
swaitsandClaude Opus 5.5 019d8d76a3 P2 slice 7: backups and restore (D-30, D-31)
The server backs each book up itself: a per-book git archive, pushed
fast-forward only to a git remote and to a folder, when the books open,
after a quiet spell, before they close and on request; every commit,
push and drill is a record in ops/backup.jsonl. Drills clone each
target and verify the chain, the blobs, the vault and the head, weekly
and after a failure. `wanderbooks books restore` previews, then
restores beside or (with --replace) over, never moving books something
holds. The archive refuses anything but a plain bare repository and
pins git against the owner's configuration. books.backup and
books.back_up_now for the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:21 -06:00
swaitsandClaude Opus 5.5 5bc0633cdd P2 slice 6: the viewer
`wanderbooks gui [BOOK]` finds the data root, reads settings from the
journal, the vault of the book, presence through ops/, Save a copy /
Open on every document, Save the W-9, the Export menu, and colour
schemes. One builder for the CPA package and the 1099 worksheet
(ledger-package), shared by the server and the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:21 -06:00
swaitsandClaude Opus 5.5 b26c099a68 P2 slice 5: files in and out
Files reach the books through the server: uploads by hash and size,
then filed; exports (the CPA package, the 1099 worksheet, W-9s) handed
over as downloads from the server, never read off disk by the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:21 -06:00
swaitsandClaude Opus 5.5 d1f3ad891a P2 slice 4: access
Agent tokens (wb_ and 64 hex digits), one book and one name each, kept
only as SHA-256 in a chain log and in the OS keyring; made, listed and
revoked only through the admin socket (`wanderbooks token ...`). The
door checks them, scopes cover every tool, every call is in the access
log (fail closed), and the owner passphrase is an Argon2id hash.
Setup's terminal test waits for its prompts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:20 -06:00
swaitsandClaude Opus 5.5 a298d154b6 P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)
One wanderbooks executable. The books are open while the viewer, an
agent session or a terminal holds them: a host process takes leases on
a socket, starts the server (the hidden mode `wanderbooks server`) with
a pipe on its input, and closes it 10 s after the last lease ends. No
background service. One host even in a race (run/host.lock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:19 -06:00
swaitsandClaude Opus 5.5 168e5c9846 ci: a newer push cancels the check still running for an older one
Two pushes a few minutes apart ran two checks; the older, already
superseded, still emailed its result. Now a newer push to the same branch
cancels the check still running for the older one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-19 13:26:24 -06:00
swaitsandClaude Opus 5.5 cd17eca44c viewer: the all-schemes test doesn't count a thumbnail still loading
CI (GitHub's slower runners) failed every_scheme_paints_the_same_words_and_figures_readably
on the Documents page: thumbnails still rendering show "…" until their
picture comes, and a few were still loading when one scheme's frame was
read and done when the next one's was. The placeholder isn't a word of
the page: it is left out of the comparison, and its contrast is still
checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-19 13:16:21 -06:00
swaitsandClaude Opus 5.5 3843f1638b viewer: a scheme hovered in the menu is shown live
Owner, 2026-10-19: hovering a scheme for a moment switches to it live;
moving off goes to the next one hovered or back to the one in force, to
sample schemes without re-opening the menu.

A variant's row rested on for 50 ms is worn; off every row for 50 ms,
the scheme in force again; the menu closed without a click, the one in
force at once. Only a click chooses, and a preview is never kept.
Tested with frames 10 ms apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-19 12:51:59 -06:00
swaitsandClaude Opus 5.5 af684c3612 viewer: color schemes, twenty families and every variant, with a light/dark filter
Owner, 2026-10-19: full color-scheme support for the viewer, the top
Neovim-style schemes, about twenty families each with a flyout of all its
variants, a light/dark/all filter, Ayu included, alphabetical, a whole row
to click.

- The sidebar's corner button (the light/dark toggle before) opens the
  menu: the filter, then, alphabetically and all on screen at once (in
  columns in a short window), Ayu, Catppuccin, Cyberdream, Dracula,
  Everforest, GitHub, Gruvbox, Kanagawa, Material, Melange, Monokai Pro,
  Night Owl, Nightfox, Nord, One Dark, Oxocarbon, Rosé Pine, Solarized,
  Sonokai, Tokyo Night and Wanderbooks (the viewer's own Light and Dark):
  80 schemes, each variant a row with a strip of its colors.
- Colors from each scheme's official source, checked value by value
  (docs/color-schemes.md; the table names the sources).
- Every palette, the viewer's own included, keeps every text color
  (figures, gains and losses, warnings, links, secondary and faint text)
  at WCAG AA (4.5:1) on every ground it sits on, soft fills included; a
  color that falls short keeps its hue and only changes lightness. Text,
  secondary and faint text stay apart (each 1.2 times the next), gains,
  losses and text and the charts' series shown together stay plainly
  different (CIE76 ΔE 20).
- The scheme sets both egui styles and pins its mode, so the system
  turning light or dark changes nothing (it made figures unreadable).
- Preferences now live in the platform's place for app settings
  ($XDG_CONFIG_HOME/wanderbooks-viewer on Linux), not eframe's default,
  which was $XDG_DATA_HOME/wanderbooks: the data root's own place. The
  old file is moved once.
- Tests: every page of thirteen painted in all 80 schemes, the same words
  in the same places, each text readable on what was painted under it;
  the menu's order, reach and whole-row clicks in a tall and a short
  window; the system flip; preferences restored. Two review rounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-19 12:51:58 -06:00
swaitsandClaude Opus 5.5 13b09aa766 ci: shard the weekly mutation runs; no viewer job; six import mutants caught
The first scheduled run (2026-10-19) failed four ways: the viewer's
mutation job called the recipe retired in P2 slice 1 (owner, 2026-10-18:
no mutation runs on the GUI); the core and MCP runs were stopped at
GitHub's six hours; the import run missed six mutants.

- Every mutation recipe takes a shard (k/n, default all of it); the
  workflow runs the core in 8 shards, the store in 4, the importers in 2,
  the MCP server in 16, each its own job with a 350-minute limit.
- Test timeouts are three times each run's own baseline, never under the
  recipe's old fixed value, so slower runners don't report false
  timeouts.
- The mutants-gui job is gone.
- ledger-import: rule_marks_match_their_notes,
  asset_register_xlsx_headings_and_totals_rule and
  xlsx_missing_amounts_are_cells_only_on_ruled_rows catch the six
  (the tax-basis rule marks, a heading's alignment, the totals row's
  blank cells, and a blank amount cell only on ruled rows), each checked
  against its mutant by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-19 10:00:38 -06:00
swaitsandClaude Opus 5.5 b47331e9d5 P2 slice 2: the data root (plan 3.4, 5.5)
ledger_store::root: one private folder for every book, found without the
environment (--root, else the root setup recorded in a file only this
account can write, else the platform default under the home from the
password database). Every folder made 0700, files 0600; a root that is a
link, another user's, open to others, unmarked, or under a folder another
user could swap is refused; no error names it. Setup keeps a recorded
root, never makes a second root or moves books while one holds any, and
leaves a folder holding other things untouched. Books are created and
adopted one at a time; adoption reads the source under its writer lock
(changing nothing in it), copies into staging, re-hashes every stored
file against its name, checks every named file is there, opens and
verifies the copy with the source's head, imports settings for books
that keep none, syncs, and only then renames it into place.
wanderbooks setup makes the root and never prints where it is. Three
review rounds; mutation on the diff (docs/testing.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 14:42:32 -06:00
swaitsandClaude Opus 5.5 e863bfee74 plan: defect 23, a vendor marked no-1099 reads as undecided
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 12:40:27 -06:00
swaitsandClaude Opus 5.5 cab6e8a334 P2 slice 1: one executable (D-22)
The server and the viewer are libraries with run(prog, args), returning
Exit(code) instead of ending the process; ledger-mcp and ledger-gui are
one-line shims (the live books keep using them until the cutover).
wanderbooks dispatches dev-stdio and gui to them in-process; a mode must
come first and owns its flags. The gui feature (default) leaves the
viewer out of the server-only build, which just server-only builds,
clippies, tests, and checks for window libraries. The viewer's mutation
recipe is retired (owner, 2026-10-18); its two musts are tested
directly. Two review rounds; mutation on the diff (docs/testing.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 12:27:48 -06:00
swaitsandClaude Opus 5.5 18120727eb plan: cargo-packager for installers and updates (D-25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 10:56:41 -06:00
swaitsandClaude Opus 5.5 05855919c3 plan: one app, open while held, installers and updaters (D-22, D-23, D-24)
The owner, 2026-10-18: one app to simplify installing and updating.
One wanderbooks executable whose modes run as separate processes; the
books open while the app, an agent session (its stdio bridge), a serve
terminal or a CLI command holds them, and close after the last (still
no background service); a signed, notarized dmg and a per-user Windows
installer that update themselves, AUR, apt, dnf and a self-updating
AppImage on Linux. New section 3.9 (leases, the helper's clean host
start, local sockets instead of TCP, restarts, one version at a time,
the server's format-upgrade gate); P2, P11 and the decisions follow.
Five review rounds, the last clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 10:45:44 -06:00
swaitsandClaude Opus 5.5 f9c8350889 plan: the local server runs in the foreground, started by hand (D-21)
The owner, 2026-10-18: no background service for now. wanderbooks serve
runs in a terminal (server plus archive process) until stopped; nothing
is installed to run in the background. P2's scope, cutover, tests,
limitations, user flows and P11 follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-18 00:06:56 -06:00
swaitsandClaude Opus 5.5 53242ae1ac plan: P1 live on the first company's books; Q-2 answered (D-20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 23:06:38 -06:00
swaitsandClaude Opus 5.5 8c123a13b1 K-1 shares print by value; filed documents may print the company's own EIN
The first company's P1 cutover found the K-1 worksheet printing a
member's share as typed: "50%" read from a settings file printed
0.50, the same share kept by the books as 0.5 printed 0.5. Shares now
print at least two places whatever their spelling.

Every company document now goes into the books (D-20, owner,
2026-10-17), and formation papers and plans print the company's EIN,
which the filing scan refused as a printed TIN. It now passes the
company's own EIN, as its settings hold it, printed 2-7, and nothing
else; a vault TIN is still refused (rules.md B-26). Its refusal no
longer suggests converting a file to get past it.

plan/served.md: D-20; correcting a record's source without changing
its words (P5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 21:16:34 -06:00
swaitsandClaude Opus 5.5 6c04342427 tests: every test dir is removed when its test ends, pass or panic
Many test helpers removed their temp dir only before the test, or only
on pass, so runs left them behind: 117,000 filled /tmp (RAM here) and
stopped a mutation run. Each now holds a guard that removes the dir on
drop, unwind included; servers started on a test dir hold its guard
until they stop. A full cargo test --workspace leaves 0 dirs (859
before). just sweep-test-tmp stays for runs killed mid-test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 20:47:44 -06:00
swaitsandClaude Opus 5.5 c587593d78 P1: the books keep how they are kept
Settings, conventions (posting templates, approval rules, policies,
close steps, calendar, mappings, notes), decisions, open items and
entry approvals are journal events (journal format 2). Books that keep
their settings never read wanderbooks.toml or config.json; the server
and the viewer take the vault key from --vault-key-file instead.
books.import_settings moves a settings file in once, exactly as the
server reads it. Approval rules, when enforced, gate every posting
path. Replay refuses what it can't read as newer, never as corrupt.

Lean: a record changes no figure. Mutation on the whole diff: every
survivor killed by a test but two equivalent (docs/testing.md). The
Company page says a late open item is overdue in words, not colour
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 19:27:48 -06:00
swaitsandClaude Opus 5.5 e60ecc5317 local_files: a file is the checked one by size and times too, not inode alone
CI failed on a_file_swapped_after_the_check_is_never_read: its filesystem
handed the deleted checked file's inode number to the file made in its
place, so device and inode matched for another file. The opened file
must now also match the checked file's size, modification time and change
time. The test now models a real swap (the replacement exists before the
original goes), the reused-inode case, an in-place rewrite, and that the
unchanged file is read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 10:58:44 -06:00
swaitsandClaude Opus 5.5 6aac8c539d plan: one app for Windows, Mac and Linux (P11); backups restore (P2)
Owner, 2026-10-17. D-19 settled: one installer and updater per
system; server, viewer and CLI ship together but stay separate
programs; no launcher; the viewer gains back up, export, restore and
new books (the last two through the server). P2 gains verified,
non-destructive restore onto new or existing installs, and restore
drills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 10:41:46 -06:00
swaitsandClaude Opus 5.5 35f9074a3c plan: P0 landed and live; P1 next
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 10:31:15 -06:00
swaitsandClaude Opus 5.5 543c85e3b7 plan: P0 notes
plan/served.md: defect 21 (found in P0: the CPA package's
general-journal.iif ignores as_of, so an as-of package includes entries
dated after it; its xlsx and csv files respect it) joins the defect
count, P3 beside defect 7, and the P10 re-run; 5.6 records that the
first company's goldens are kept privately in its own books dir
(export_cpa_package zips plus a canonical JSON form made by its
goldens/canon.py, which normalizes per-export text, with the comparison
protocol in its README), not in this public repo, by decision.
plan/STATUS.md: P0 is in final review.

Review round 3: STATUS says what is true (P0 in final review, the
mutation runs done).

Adds defect 22 (depreciation through an asset's final month plugs to
the full amount mid-month) to P3 and to the P10 re-audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 09:25:00 -06:00
swaitsandClaude Opus 5.5 68046665d0 docs: P0 review: server instructions, handbook, local files, fail closed
docs/mcp-interface.md: the connect text and its budgets, the handbook
(tool and resources), annotations, failing closed, import_local_file's
roots and refusals, the set_w9 path rule and where TIN-bearing files may
go. docs/testing.md: the P0 laws and the tests that check them.
docs/architecture.md: the journal format gate and the server's
fail-closed store lock.

Round-2 review (2026-10-16): mcp-interface.md lists all 26 destructive
tools (unlink_receipt, unlink_attachment and correct_asset were missing)
and openWorldHint false on every tool; the connect text states the
parties rule and the exact receipt rule, and how it fits its budget; the
lint covers every refusal; the import roots refuse /, the home folder and
folders holding the books or a key, the open is a component walk, every
missing or outside path gets one refusal, and the TIN-file registry and
text scan (and what they can't catch) are described. architecture.md:
the frozen envelope, the lenient next-line prev, no repair of newer
books. testing.md: the new laws and tests. verification.md and
parity.md: P0 notes (no new law to prove, no status moved).
The TIN text check, narrowed (2026-10-16; rules.md B-26,
mcp-interface.md): text is refused for a TIN the vault holds, in its
common renderings, or a stand-alone printed SSN or EIN shape, so bank
exports go in; the oracle a vault comparison gives is recorded as a
known limit for P7. testing.md: the P0 mutation run of the final diff,
per crate, and how each survivor was resolved (killed by a named test,
removed by simpler code, or argued equivalent).
The probe bound (rules.md B-26, mcp-interface.md): rule (a) answers
for at most two candidates a filing, so finding a TIN from its last four
takes on the order of 50,000 refused filings; the open limit now names
that path, closed by P2's access log and P7's human-only TIN channel.
testing.md: the law and its tests; tin_files.rs mutated again, 74 of
74 caught.

Review round 3: rules.md B-28 (four-digit years, amounts a line can
carry, checked products, bounded recurring runs) and B-26/B-27 updates
(BOM-less UTF-16, when set_w9 records); mcp-interface.md matches the
code (25 destructive tools, the five idempotent ones, no reverse_entry
receipt checklist, strictly above the threshold, no tool text names
wanderbooks.toml, dot names and roots inside the books refused, the new
settings lists); testing.md lists the new laws and this round's
mutation run.

Review round 4: rules.md B-28 says the bounds are for new commands
only, replay accepts every line an earlier version accepted, and derived
figures refuse rather than panic; mcp-interface.md documents plan_error
and the nullable totals; testing.md lists the old-books contracts and this round's
mutation run (core, store, MCP, config, GUI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 09:25:00 -06:00
swaitsandClaude Opus 5.5 8693d5a3f5 MCP: short connect text, a handbook, tool annotations, safe local imports
- The 13K-character instructions (clients cut them short) become a
  connect text of at most 1,400 characters whose first 512 stand alone,
  plus books.handbook and books://handbook/{section}: the old guidance by
  section, with no helper commands, home paths, pull requests or
  file-reading suggestions (tool_text_lint).
- Every tool has a title and read-only/destructive hints; no
  requiresUserInteraction anywhere (owner decision 2026-10-16).
- books.import_local_file reads only regular files inside the import
  roots (the downloads folder by default, or --import-root), never the
  data dir, books dir, vault or the key's folder, no symlink escapes or
  hard links, and the file opened must be the file checked.
- The store lock is a tokio mutex taken off the runtime threads.
- get_info refreshes settings first; a bare books://digest reads.

Review fixes (P0 review, 2026-10-16):
- Fail closed: a tokio mutex never poisons and spawn_blocking caught the
  panic, so after a panic mid-mutation the server kept serving a Store
  behind its journal. A panic (the task, or while holding the store)
  now stops the shared store; every later request on every session is
  refused with a plain message; a restart re-verifies the journal. The
  join error goes through redact_refusal and never carries the panic's
  text.
- books.set_w9 w9_file_path read any path (a link named w9.pdf to the
  vault key was stored and get_w9 wrote it out): it now goes through the
  import roots' lockdown, its type judged on the canonical path.
- Sealed even inside a root: the key file in force, where a linked one
  leads, both folders, and the default key folder whatever the settings
  say. Refusals check the roots first, so outside them every path gets
  one message (no probing what exists); inside, other books-dir files say
  to copy them out first.
- The open is O_NOFOLLOW | O_NONBLOCK (a pipe swapped in after the check
  can't hang a thread) and must match the checked file's device and
  inode. New dependency: libc (unix only), for those two open flags,
  which std doesn't name.
- Default import roots are the downloads folder only (the plan); files
  with full TINs (get_w9, vendor_1099 include_tins, the CPA package with
  TINs) are never written into an import root, and the server refuses to
  import any file it wrote with TINs (by SHA-256).
- set_tin and set_w9 overwrite vault records: destructive_hint = true;
  annotations_complete pins the key tools' hints. The read-only hint is
  checked in behaviour on every spec_mcp call (journal and data-dir files
  unchanged).
- Text: the connect text names the settings file as the one local file
  to edit, says every entry balances on real accounts and names its
  party, says invoice/bill/payment/payroll tools post unchecked, rewords
  the exemptions, and shows the company name as one plain line; the
  handbook takes section "all", lists its ids in the schema and says to
  read contract first; books.settings drops the config.json path and no
  error names the data dir or the key file. tool_text_lint covers the
  whole tool JSON and resources and bans git/commit/FL-0001; tool
  descriptions over 700 characters are pinned to today's eight (P5).
- digest: an empty since= is bare; the default date is pinned on every
  day of a month.

Round-2 review fixes (2026-10-16):
- TIN files (B-26): the in-memory, per-session set of TIN-file hashes
  became one registry beside the vault (vault/tin-files.sha256, hashes
  only), shared by every session and every later server. It holds the
  W-9 set_w9 reads (recorded as read), get_w9 copies, 1099 worksheets
  with TINs, CPA packages with TINs and each file in one that holds a
  TIN; a file is recorded before it is written, and a failure refuses.
  import_local_file, attach_receipt and file_document (base64 was never
  checked) refuse a recorded file, and a text file (by type, name, or
  UTF-8/UTF-16 decoding) holding a full TIN. Not caught: an altered
  binary copy; the docs say so (P2/P7 remove the path tools).
- The text check is narrow (owner/engineer decision, 2026-10-16: bank
  exports are filed for reconciliation, and has_full_tin's "nine digits
  however separated" refused their routing numbers, long references and
  dates run into amounts). Text is refused when it holds (a) a TIN the
  vault holds for a party, anywhere, as nine digits, 3-2-4 joined by
  hyphens, spaces or dots, or 2-7 joined by a hyphen or space, compared
  on the server against the decrypted vault and never returned or
  logged (a vault that can't be opened leaves (b)); or (b) a stand-alone
  printed SSN (123-45-6789) or EIN (12-3456789) shape: no digit, and no
  hyphen or dot followed by a digit, beside it. Full-width digits and
  Unicode hyphens and spaces read as ASCII. has_full_tin still guards
  refusals and results (redact_refusal). Known limit, for P7: a refusal
  under (a) tells the agent a text it chose holds a vault TIN.
  New dependency: zeroize in ledger-mcp (already in the workspace
  through ledger-vault, same major version), so the vault's TINs decoded
  for the scan are wiped from memory on drop as the vault's own are.
- Mutation testing of the final P0 diff (docs/testing.md): tests for
  each law a survivor broke (handbook titles, a dropped default root, a
  .jpg W-9, the waker, the walk's folder flags against a link and a
  pipe, the 40-link limit, save paths refused as such, the package
  marking only its TIN files, a registry that can't be read, text types,
  the short receipt rule's counts and a 12-character code). Simpler
  code where only a race could tell: inspect places an existing path by
  canonicalize itself (no second comparison), read_checked's read limit
  is its one size check, openat fails on -1 as specified. The 25 MiB
  upload test removes its 50 MB of books however it ends.
- The GUI's stale-rule prompt no longer says to open a pull request: it
  asks the agent to find the figure from the rule's official authority,
  confirm it with the human, pass it explicitly, and tell the human so it
  can be reported upstream (as the handbook says); a lib test pins it and
  bans files, git, GitHub and pull requests from every attention prompt.
- Import roots (local_files.rs INVARIANT 0): XDG_DOWNLOAD_DIR="$HOME/"
  made $HOME (or /) the root. No root may be /, the home folder or hold
  it, or hold the books, data dir, vault or a key: a named one stops the
  server at startup, a default one is dropped; the default falls back to
  ~/Downloads only if that is a real folder other than $HOME; with no
  root, an import says so.
- The read is a component walk from / with openat(O_NOFOLLOW |
  O_DIRECTORY) per folder and O_NOFOLLOW | O_NONBLOCK for the file, so a
  folder swapped for a link after the check fails; no step may land on a
  protected inode (one renamed into a root); dev/ino and nlink stay. No
  /proc. Portable to macOS. (libc's openat, two small unsafe blocks.)
- One refusal for missing, dangling and outside paths: resolved()
  follows every link, dangling ones too, so a link's target existing or
  not no longer changes the answer.
- Sealing: the key files (in force, default) and their targets always;
  their folders only when not $HOME, not a root, not holding a root; and
  ~/.config/wanderbooks whatever XDG_CONFIG_HOME says.
- Agent text (I-12): RuleNotKnown names the authority (IRS, with its
  address) and says to tell the human, never a repo path, PR or GitHub;
  the digest's stale_rules drop the repo catalog and path for it; vault,
  outside-books, STOPPED and local-file refusals name no git, path or
  flag. tool_text_lint bans docs/, .md, /home, data dir, --data-dir,
  --import-root, vault.key, .jsonl, sqlite, .gitignore, github, pull
  request, and also lints every error template (scanned from source),
  built error texts, and the server's refusal constants.
- The receipt rule told (ReceiptPolicy::rule) is the one enforced: the
  exact threshold in the base currency (no "$", no rounding), required
  and exempt accounts, reversals exempt; the connect text fits 1,400
  characters by dropping reasons, then codes, never the rule.
- The connect text follows the parties setting; "offer books.digest"
  everywhere. shown_name drops Unicode format characters (bidi, zero
  width).
- read_only_tools_change_nothing compares file contents, the database
  by every row, the vault and the settings file (raw database bytes did
  not change either; rows are the stable comparison).
- import_local_file takes the name and type from the canonical path;
  the digest's journal repair says when the tail was set aside, not the
  journal's file name; "version-history messages" back in the TIN rule;
  openWorldHint false on every tool, pinned in annotations_complete.
- Outside the MCP crate, for the same texts: ledger-core's DatedRule
  gains `authority` (the statute or publication with its address) and
  RuleNotKnown carries it; ledger-store's "sqlite error" reads "database
  error"; ledger-config's ReceiptPolicy::rule takes the base currency.
  New dev-dependency: rusqlite in ledger-mcp (already in the workspace,
  same version and features as ledger-store), to read the database's
  rows in read_only_tools_change_nothing.
- The filing scan is no TIN oracle (tin_files.rs INVARIANT 4): text
  holding more than two TIN-shaped numbers (any rendering rule (a)
  reads) that end in a known TIN's last four (the books' TIN and W-9
  facts, via known_tin_last4, before the vault is opened; then the
  vault's own) is refused with TIN_PROBE, the same words whether or not
  one is the vault's TIN; only one or two candidates are compared
  against the vault. Tests: unit
  text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it,
  one_or_two_candidates_are_compared_against_the_vault; over HTTP
  text_probing_for_a_tin_is_refused_alike_by_every_filing_tool.

Plan: plan/served.md P0, section 6 "Immediate hole (P0)", 4.1, 4.3, 4.9.

Review round 3. S1: a panic stops the server for every session, so no
input may reach one. Dates are four-digit years (new ledger_core::
calendar, 0001-01-01..9999-12-31): the MCP's parse_date is strict
YYYY-MM-DD, periods are bounded, last_day_of_month returns a refusal
(server and store); schedules, assets, recurring entries, periods,
documents, disposals and entries refuse dates outside the calendar, and
replay refuses such journal dates. Every amount a command takes is at
most a line's (draft lines by size, document lines, payouts, payroll,
statements, shares, tax basis, proceeds, recurring templates), on
replay too, so sums are exact; the 1099 vendor part and the K-1
property share use checked products (TooLargeToCompute); a recurring
run makes at most 1,000 drafts. tests/hostile_args.rs drives every tool
with schema-derived hostile arguments and every resource with generated
URIs, asserting the server never stops; the audit's panics are pinned.
S2: a `.` or `..` path name is refused as NO_SUCH_FILE up front. S3:
NUL-interleaved UTF-16 without a byte-order mark is scanned both ways.
S4: resource-read refusals are TIN-redacted. S5: set_w9 records its W-9
only once the call passes every check, just before the vault write.
S8: idempotentHint only on verify, run_depreciation, run_schedules,
run_recurring, set_party_roles (a repeat leaves journal and vault
alone; spec_vault checks every candidate twice); every tool's hints
pinned in one table. C1: books.settings lists receipt_required_accounts
and receipt_exempt_accounts uncut. C2: the default company name reads
"a new company". C3: the parties rule names no-party reasons. C4/C5:
the handbook names the settings file only as books.settings' books_toml,
and restores "check books.digest and offer it". C6: an import root
inside the books, data dir or vault is refused at startup. The lint
catches `just` however it ends and where the books are;
digest_resource_bare_reads reads the server's month.

Review round 4 (2026-10-17): round 3's bounds ran in replay too, so
journals main wrote and reopened (an entry dated 0000-06-01 or
+10000-01-01, a draft line of 2e15, an asset ending past 9999, a basis
or share past the line limit, sub-cent document and payroll lines) no
longer opened. Posted is forever: the bounds are the commands' only.
- Replay applies main's rule exactly: parse_date takes any date chrono
  reads; replay_add_line(_fx) has no size bound (add_line/add_line_fx
  do); replay_register_asset/correct_asset (chrono overflow only),
  replay_set_asset_tax_basis (check_journaled), replay_set_entry_parties
  (check_shares_journaled), replay_register_recurring
  (validate_recurring_journaled), replay_register_schedule (months 1 to
  600), replay_create_period (order only), replay_dispose_asset, and no
  line, date, payment or payroll range checks in replay_invoice/bill,
  their payments and replay_payroll_run.
- Derived figures over such books refuse (TooLargeToCompute,
  DateOutOfRange), never panic: amortization plans, depreciation into
  chrono's last month (and the true-up for lives past 9999), intervals,
  document totals and aging, net pay and payroll plans, money flows and
  share resolution, the asset register, receipt-policy debits; the MCP
  tools and the viewer show what can't be computed.
- tests/fixtures/old-books: the review's 13 journals, its control and 15
  more edges, written by main's Store API with main's figures
  (expected.json); every one opens in the writer and the viewer to those
  figures, every derived figure computes or refuses, every MCP tool and
  GUI page answers; hostile_args restarts the server after each
  property; each bound has a command/replay test pair.

Final review: a schedule's plan refuses a total of 10^26 or more
(TooLargeToCompute) instead of letting Decimal round its cents silently,
and a null `remaining` in the schedule output is always explained by
plan_error. Replay is unaffected: no journaled schedule month can belong
to a total that large (the line limit bounds every posted month).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 09:25:00 -06:00
swaitsandClaude Opus 5.5 9f9f00ed9a Store: say "newer wanderbooks", not "corrupt", for books from a newer version
An unknown event type on a whole, chained line, or a journal.format_set
declaring a newer journal format, now raises StoreError::NewerJournal
(update wanderbooks) instead of JournalCorrupt. Corruption (bad hash,
seq, or JSON) is still corruption. The gate runs in the store, the
Snapshot and the GUI, after each line's parse, seq and hash checks, and
never skips a line it can't read. Adds the journal.format_set event
(format 1 today; nothing writes it yet) and a registry of every event
type this version reads, tested against the replay and projection arms.

Review fixes (P0 review, 2026-10-16):
- A line's own hash is checked only by the next line's prev, and the
  gate used to refuse a line before reading the next: a flipped bit in a
  middle line's type (or a format_set edited from 1 to 2) was reported
  as "newer, the books are not damaged". Now the reader settles every
  refusal against the next line (format::settle): a next line that
  doesn't chain makes it corrupt; one that chains makes it newer and
  verified; with no next line the message says "newer or damaged; update,
  then run verify", never "not damaged" (NewerJournal.verified).
- A newer format number is judged newer before its features are parsed:
  a newer format may shape them differently.
- Snapshot treats a blank complete line as corrupt, as the writer does.
- docs/spec/rules.md B-27, which the code cites, now exists.

Round-2 review fixes (2026-10-16):
- The envelope {seq, ts, type, payload, prev} and its hash (SHA-256 of
  the canonical JSON) are frozen across formats (rules.md B-27); new
  fields go in payload. settle reads only the next line's prev, leniently,
  so a newer format's next line (a float, a string seq, keys of its own)
  is never called corrupt: no readable prev means unverified newer.
- A key beside the envelope's was silently dropped (and the chain then
  broke as "corrupt"): it is now NewerJournal, hashed over the raw
  canonical JSON so the next line still checks it; JournalLine::parse
  refuses such a line.
- "the books are not damaged" overclaimed: a verified refusal says line N
  is intact and this version can't check the lines after it.
- The writer read the journal with BufRead::lines (invalid UTF-8 was an
  I/O error); it now splits bytes on newlines as the Snapshot does, so a
  non-UTF-8 line is JournalCorrupt in both.
- Store::open truncated a torn tail before the gate ran, altering newer
  books (perhaps mid-append) before refusing them. The gate now runs over
  the complete lines first; the tail is set aside (and journaled) only
  when they pass.
- Mutation testing (docs/testing.md, P0): the viewer and the writer say
  the same words about the same damaged line (the newline is no part of
  it); read_journal_and_format returns every line and the format in
  force; the journal page tells a format declaration.
Plan: plan/served.md P0 and 5.1.

Review round 3 (S6, S7). Store::open replays every complete line in
memory (the first line's currency, then the ledger) before a cut-off
last line is set aside, so a complete line the books can't apply
refuses the open with the journal byte for byte as it was and nothing
set aside (B-27 SCENARIO 3; test
a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was);
catch_up now only projects. The viewer
applies the writer's first-line rule (created_currency, shared) instead
of guessing USD: any other first line, or a store.created without a
sound base_currency, is corrupt to both (test
the_first_line_is_store_created_for_the_viewer_and_the_writer). fuzz_store_replay varies the
first line (14,696 runs clean).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 00:32:42 -06:00
swaitsandClaude Opus 5.5 4f8047689f P0: pin dev tools, honest transports doc, tools-only template
mise.toml: pin cargo-mutants 27.1.0 and cargo-fuzz 0.13.2 (the latest
published, and what is installed) instead of "latest", so mutation and
fuzz runs repeat (plan/served.md I-13, P0).

docs/mcp-interface.md: the spec had two Transports sections, and the
first claimed bearer-token auth on Streamable HTTP. There is none: the
server uses rmcp's default config, whose only guard is a Host check for
localhost, 127.0.0.1 and ::1, with no token and no Origin check. One
section now says so plainly and points to plan/served.md P2 for auth.

ledger-cli AGENTS.md template: "the journal is the evidence - read it,
don't guess" led an agent to cat the journal file instead of asking the
books. The template now says to trace a figure through books.get_entry,
books.search_entries, or books.export (journal or general_ledger), and a
new bullet says the books are reached only through the wanderbooks MCP
tools: never open, list or parse the books' files, not even to look; if
a tool can't answer, say so. The other lines that sent the agent to the
data dir go too: onboarding is triggered by an empty books.chart instead
of a missing journal.jsonl, the CPA paragraph drops the ledger-gui
--data-dir books-data command, and the backup note no longer names the
journal's path. Test agents_md_reaches_the_books_only_through_tools
pins this; agents_md_carries_the_contract no longer expects journal.jsonl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-16 18:18:05 -06:00
swaitsandClaude Opus 5.5 a870e11f50 STATUS: Served planned; P0 paused mid-build, with resume steps
Records where the Served work stopped on 2026-10-16 (owner's usage
credits): which P0 pieces are done, which are work in progress in which
jj workspace, how to resume, and where the before-snapshots of the first
company's reports live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-16 15:49:32 -06:00
swaitsandClaude Opus 5.5 8071b2b964 plan: Served — books behind the MCP, local and self-hosted
The 2026-10-16 audit of the first company's books found an agent reading
the journal file directly, the instructions truncated by clients, and 20
engine defects. plan/served.md is the plan to fix all of it: the books
become a server that owns its data, every agent reaches them only
through the MCP from any directory or client, settings and conventions
move into the journal, and the scaffold goes away. Phases P0-P10, ending
with a full re-audit through the MCP alone.

Chosen from three independent designs by a three-judge panel, then
revised for the owner's decisions: books kept as the user in a private
data root (advisory isolation, labelled so; enforcement is the self-host
deployment), the viewer reads the data directly, no added permission
prompts, self-hosting now and multi-tenant hosting later.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-16 15:27:19 -06:00
swaitsandClaude Opus 5.5 149d108126 Viewer: clickable breadcrumbs
Every crumb above the page you're on links to where it names: the
section goes to its home (Parties › Tobin Harlow → Parties), and a
reconciliation's trail runs Accounts › its account › it. Same look; a
pointer and "Go to …" on hover. A crumb naming the current page isn't a
link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-16 08:41:39 -06:00
swaitsandClaude Opus 5.5 babb662eba Viewer: click a vault TIN to copy the full number
The party page's "TIN on file" and the W-9 card's TIN copy the full,
formatted number on click (right-click: digits only). The viewer reads
it from the vault only at that moment, with the server's key file in
force, never creates a key, refuses a key inside the books dir, and
never draws more than the last four. A locked vault copies nothing and
says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-16 08:27:13 -06:00
swaitsandClaude Opus 5.5 e9e8d9fb11 Add the government_agency party role
Regulators and registries that don't tax the company (a division of
corporations, a licensing board), distinct from tax_agency. Core Role,
MCP descriptions, viewer label/noun/icon, docs, and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 20:21:11 -06:00
swaitsandClaude Opus 5.5 8498e6cfab Audit fixes: party money, reconciliations, require policy, K-1 property
An independent recompute of the real books found no numeric
disagreements; review found 15 defects, all fixed with spec-first
regression tests:

- k1: a prepaid credited straight to capital is property; a member
  paying a vendor through 2100 is cash. Docs and B-13 match.
- parties: a reversal of a reversal counts on its root's side;
  liability-to-liability payments (member pays the card) count as paid.
- viewer: party money uses the server's cash accounts and full calendar
  year; per-party "What for" scales multi-party entries.
- reconciliations: refuse a withdrawn statement before journaling; new
  books.void_statement; reconciled lines can't be un-cleared; one shared
  reconcilable-accounts rule for digest and viewer.
- CPA package includes receipts linked to entries; a file can't be both
  filed on and linked to one entry.
- parties = "require" covers payroll, tax remittance (agency) and
  disposal (buyer); no-party marks follow reversal chains and clear when
  a vendor is tagged.
- derived member shares are frozen to exact amounts when posted, so a
  settings change never re-splits history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 20:16:03 -06:00
swaitsandClaude Opus 5.5 e1b2509c22 Shared parties with exact shares, member capital statements, TINs for any party, K-1 worksheet
Entries may name several parties, each with an exact amount, a percent,
or a share derived from the members' capital lines or [[company.members]]
shares (entry.parties_set); shares always sum exactly. The 1099
worksheet counts a vendor's share. A member's party page is their
partner capital statement, from the same ledger_core::k1 source as the
CPA package.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 18:20:18 -06:00
swaitsandClaude Opus 5.5 ef00ccc242 Party money counts cash paying down a liability (reimbursements, card payments)
ledger_core::parties::entry_flow: cash lines count; liability lines count
except the part paired against cash in the same entry, which counts once
as paid to the liability's creditor (or received from a lender). A
reimbursement to the members no longer nets to zero. What-for breakdown
comes from the same function and sums to the net exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 16:07:23 -06:00
swaitsandClaude Opus 5.5 08ce558ed3 Attachments, statements, reconciliations, TIN vault, counterparty policy
MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 15:42:37 -06:00
swaitsandClaude Opus 5.5 a299077991 Parties, attachment links, statements, reconciliations (store), TIN vault crate
Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 14:43:40 -06:00
swaitsandClaude Opus 5.5 2a188bd039 new: ignore the database copies kept when the projection is rebuilt (books-data/wanderbooks.mismatch.*)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:53:44 -06:00
swaitsandClaude Opus 5.5 a4a3165b2c fix: books with an asset linked to a since-reversed entry failed to open (the projection read-back re-checked the link against today's books); links restore as recorded, Ledger::acquisition_reversed reports ones to relink; regression test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:51:17 -06:00
swaitsandClaude Opus 5.5 1500d18cea Assets can be corrected before depreciation: books.correct_asset (cost, salvage, life, in-service date, with a reason), journaled as asset.corrected with the old figures; refused once depreciation is posted
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:41:15 -06:00
swaitsandClaude Opus 5.5 ba7373bdb8 Fixed assets carry federal tax basis beside book value; tests sweep stale temp dirs
- core: TaxBasisRule (cost IRC 1012, carryover IRC 723, personal-use conversion Treas. Reg. 1.168(i)-4(b)), TaxBasis with the contributor's adjusted basis, Asset::tax_basis and ::built_in_gain (IRC 704(c)); the register gains tax rule, contributor basis, tax basis, built-in gain, total tax basis, missing count (rules.md B-25; sources vendored in docs/spec/sources/irs-partnership-basis.md)
- store: asset.tax_basis_set journaled, replayed, projected (schema v10)
- mcp: books.set_asset_tax_basis; register_asset takes tax_basis_rule and contributor_basis, checked before anything registers (found by the spec-blind suite); list_assets and the register export (json, csv) carry tax basis; instructions say to record it for every asset
- import: the register's XLSX and PDF gain the federal tax basis table with citations
- viewer: tax basis on the Assets page, the asset page (a Tax basis card), the report, the journal, and Needs attention for assets without one
- just: check and the mutation recipes sweep this user's test book dirs older than an hour (killed runs had left 23 GB in RAM-backed /tmp)
- docs: the parties, attachments, TIN vault, completeness engine, and updates designs (docs/parties-and-attachments.md)
- tests: spec-blind tax basis suites in core, store, MCP; register PDF/XLSX and viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 13:30:09 -06:00
swaitsandClaude Opus 5.5 6adc7b34c7 Asset descriptions can change: books.label_asset description, journaled as asset.described (with the old one), replayed, projected, shown in the journal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:43:28 -06:00
swaitsandClaude Opus 5.5 7f199737ef viewer: journal text for asset.acquisition_linked; a test that every event type the store writes has plain-language journal text
The acquisition link was added after the viewer's asset work and showed its raw event name in recent activity. The new test reads the event types from the store's replay arms, so the next new event without text fails it (checked: it fails without this fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:35:39 -06:00
swaitsandClaude Opus 5.5 cd085108c1 Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 12:18:00 -06:00
swaitsandClaude Opus 5.5 4e96b5f54d testing: mutation scores for control roles and settings reload
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 10:43:00 -06:00
swaitsandClaude Opus 5.5 762d47268a Control roles in the journal (books.assign_role, books.roles); the MCP reloads wanderbooks.toml on change and reports settings problems (books.settings, digest); new writes a full commented wanderbooks.toml
- core: ControlAccount keys, Ledger::assign_role/check_assign_role/role_account (rules.md B-23): one account per role, of the role's type, balance sheet roles move only when empty; invoices, bills, payroll, remittance, and closes post to the assigned account; an assigned role frees its default code for renumbering
- store: role.assigned journaled and replayed (strict account_id)
- mcp: settings.rs reloads by content at the start of each request, keeps the last good settings on a broken edit; books.settings with chart problems; digest settings_problems; instructions say when to edit the toml
- viewer: journal text for role.assigned
- cli: full example wanderbooks.toml, tested to load with every example uncommented
- tests: spec_roles.rs (25, spec-blind), store roles.rs (6), MCP reload/problems/roles, settings unit tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 10:30:16 -06:00
swaitsandClaude Opus 5.5 fdc5eee811 mcp: tests for the settings beside the books, year earnings (open periods, last year's partial close), year 1, a failed flush; drop by-class P&L's dead placeholder guard; record config, MCP, GUI mutation scores
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 20:45:49 -06:00
swaitsandClaude Opus 5.5 394c99056b viewer: drop parties' bill arms (the ledger tags bill entries with their vendor, which wins); burn tests for one covered month and breaking even
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 19:00:47 -06:00
swaitsandClaude Opus 5.5 a122f8d3a5 gitignore every mutants output folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 18:16:01 -06:00
swaitsandClaude Opus 5.5 fab60774b1 viewer: fmt::plain refuses underscore and exponent forms (test)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 18:15:50 -06:00
swaitsandClaude Opus 5.5 3b1317cad2 store journals payroll.taxes_remitted; viewer tests for the numbers mutation found unchecked
A payroll tax remittance was journaled as a plain entry, so the viewer
labeled it "Journal entry" and, at a 0.00 threshold, flagged it as
missing a receipt (the server posts it without one, like a payroll
run). The store now names it with payroll.taxes_remitted, as it names
runs with payroll.run; replay and the projection pass over it, and the
viewer's journal page describes it.

tests/view_model.rs: the cash history is never empty when cash moved
and ends on the last day it did; the prior month runs to the same day;
burn averages over only the months the books cover, half away from
zero; cash accounts found by name when the settings name none; card
accounts; each entry's accounts debits first, once each; only what
arrived since the last refresh is fresh; integrity counts files again
without rereading them; the invoice's customer, the bill payment's
vendor, payroll and remittance origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 18:03:10 -06:00
swaitsandClaude Opus 5.5 638b1b8316 mcp: P&L by class no longer counts closing entries; oracle tests for every tool's output
profit_and_loss_by_class re-added every posted entry in the range,
closing entries included: after closing January, its by-class P&L for
January showed an unclassified row of the whole period's net income,
negated. Closing entries are left out, as profit_and_loss_for_period
leaves them out.

tests/oracle_mcp.rs (24 tests): every export (CSV, JSON, XLSX, PDF,
IIF) against ledger-core's reports on the same books; the CPA package's
contents; P&L by class; the 1099 worksheet; disposals; depreciation;
payroll remittance; the digest against the journal; books.show's viewer
commands; resources; receipts and documents round-tripping; the 25 MiB
upload limit; bank import sides; reference resolution; partial FX lines;
preview balances. Written against the mutation run's 126 survivors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 17:14:27 -06:00
swaitsandClaude Opus 5.5 5bbe3871bb just mutants-gui: also the view-model oracle and copy tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 16:43:15 -06:00
swaitsandClaude Opus 5.5 51e74248bd mcp: balance sheet's year earnings no longer charge this year with closing last year's; tests for what mutation found untested
year_earnings counted every closing entry dated this year against this
year's income, but a close takes all unclosed income through its end,
earlier years included. A partnership closing January after an unclosed
December printed "Net income (loss)" -969 for a year earning +730, and
the closed December as "Undistributed earnings, prior years". The
unclosed part is now the income after the last close, from no earlier
than the fiscal year's start (closes are cumulative).

Unit tests: the 25 MiB upload limit, YYYY-MM periods, config_mentions
for every setting naming a code, report members, year earnings before
and after a close, and which entries may lose their last receipt.
tests/startup.rs: --help, the default data dir under HOME, settings
read from beside the books, and a held lock refused after the wait
instead of waited on forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:58:48 -06:00
swaitsandClaude Opus 5.5 21292a70b8 viewer: the overview's numbers checked against ledger-core
Kpis gets a spec (cash, liabilities, receivables, month-to-date against
the same days of last month, fiscal year to date, burn). tests/view_model.rs
computes each from ledger-core on random books (month ends, leap days,
any fiscal start) along with the period ranges, cash history, account
labels, journal count, receipt rule, origins and parties, the blob
root, and integrity counts. Burn rounds half away from zero, like the
rest of wanderbooks (it was banker's rounding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:53:57 -06:00
swaitsandClaude Opus 5.5 afd427a023 viewer: click to copy values, amounts, and identifiers
Company details (EIN, state number, address, every profile value), an
entry's number and amount, an account's code and balance, a document's
file name and SHA-256 copy on click: a pointer and copy icon on hover,
a check for a moment after. Amounts copy as their exact plain decimal
(fmt::plain: no "$", commas, or parentheses; "-" for negatives); where a
click already drills down (report cells, register and transaction rows,
general ledger lines), a right-click menu copies the amounts, the entry
number, or the description. The sidebar's chain check copies the
journal head and data folder.

tests/copy.rs: every display style copies back to the exact value
(property), and clicking the EIN, an entry's amount, and its number puts
exactly that on the clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:38:05 -06:00
swaitsandClaude Opus 5.5 c126843ce1 mutants-gui timeout 300 s (tests compete with other mutants' builds); read_only test restores permissions on failure and sweeps leftovers from killed runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:46:01 -06:00
swaitsandClaude Opus 5.5 30032e1f01 cargo: tests keep their books in /tmp when TMPDIR points elsewhere
just mutants* points TMPDIR at ~/.cache for cargo-mutants' tree copies;
the tests inherited it and synced every journal append to disk, several
times slower per mutant. Cargo's [env] reaches the tests it runs, not
cargo-mutants, so the copies stay in the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:19:27 -06:00
swaitsandClaude Opus 5.5 931b95fc09 just mutants-gui: run the accuracy tests per mutant (unit + spec_gui), 60 s timeout; the read-only test renders every page and timed out under parallel load
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:12:40 -06:00
swaitsandClaude Opus 5.5 5a912f372a spec: ASC paragraphs read directly (360, 340, 210, 230, 606); all confirm the rules
Read in the ASC basic view and vendored as short quotes with paragraph
numbers (docs/spec/sources/fasb-asc.md). Depreciation: any systematic and
rational allocation of cost less salvage (360-10-35-4); prepaids are
current assets with no prescribed expensing method (340-10-05-4,
210-10-45-1/2); cash flows gross and classified three ways, owners'
advances financing (230-10-45-7/10/14(b)/15(b)); a principal's revenue
is gross (606-10-55-37B). Citation fixed: 230-10-45-14(b), not (2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 14:11:59 -06:00
swaitsandClaude Opus 5.5 1547885031 viewer: proven read-only; its database check no longer creates SQLite files
tests/read_only.rs: with every file and folder of the books read-only on
disk, the viewer loads, follows live, and renders every page (receipts
included), and every file keeps its bytes, size, and mtime; on writable
books nothing appears beside the database either.

Found: with no writer running, the snapshot's read-only open of the WAL
database had to create -wal/-shm (a write into the books) and failed on
read-only books, showing a false "database unreadable". With no -wal
file no writer has it open, so check_disk now reads it immutable.

just mutants-gui sweeps only what the viewer must get right: the view
model's numbers (books.rs) and their formatting (fmt.rs); 179 mutants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 13:54:40 -06:00
swaitsandClaude Opus 5.5 84febee304 just mutants*: no proptest shrinking (a mutant only needs a failing test); GUI timeout 60 s (its suite takes ~10 s)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 13:47:48 -06:00
swaitsandClaude Opus 5.5 53d041c338 just mutants*: scratch copies go to the user's cache, never /tmp
cargo-mutants copies and builds the tree per job in TMPDIR; on Arch /tmp
is tmpfs (RAM, up to half of it), and a GUI run put 20 GB there. Every
recipe now uses $XDG_CACHE_HOME/wanderbooks/mutants (~/.cache/...).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 13:19:20 -06:00
swaitsandClaude Opus 5.5 5cf42e2487 mcp: HTTP concurrency test; state-machine suite tolerates a contended disk
tests/http_concurrency.rs: six HTTP clients at once against one store
(main.rs serve_http shares it): every bare entry refused at threshold
0.00, every receipted one posted, each contested draft posted exactly
once, books verify and balance, a restart replays the same head.

spec_mcp.rs: the response timeout is 180 s (a mutation run's jobs
contend for a synced disk; the baseline timed out at 30 s), and
MCP_CASES sets the state machine's cases (just mutants-mcp uses 16).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 12:06:24 -06:00
swaitsandClaude Opus 5.5 7ce4defb2b just mutants-config/-mcp/-gui, weekly in CI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 11:41:20 -06:00
swaitsandClaude Opus 5.5 2c4af07611 gui: spec-blind suite; search finds the best match, axis ticks never overflow
tests/spec_gui.rs (31 spec-blind tests): exact money text round-trips,
file names never escape the folder or hit a Windows device name, history
against a browser model, fuzzy search against a subsequence model, axis
ticks, the view model against ledger-core, and the attention list.

Found and fixed:
- fuzzy matched greedily, so "ac c" scored 1 for "c" (the "c" inside
  "ac") instead of 5 for the word "c"; it now scores the best alignment.
- ticks accumulated float error (a last tick at -5.55e-17 for a top of
  0), gave more than n + 2 ticks at large magnitudes with tiny spans,
  and overflowed for spans near f64's limit. Ticks are now whole
  multiples of the step, checked, with [lo, hi] when f64 has no nice step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 11:39:41 -06:00
swaitsandClaude Opus 5.5 714caf9be9 lean: apportion's parts sum to the whole for any number of parts
The ranks of n parts are a permutation of 0..n-1 (being ahead is a strict
total order; pigeonhole by hand, core Lean has no subperm), so exactly
u parts get a unit and one gets the fraction. apportion_sum and
apportion_sum_of_shortfall; the audit shows only the standard axioms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 10:17:37 -06:00
swaitsandClaude Opus 5.5 57c4cc0ab6 config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found
ledger-config: parse gets a sourced spec. A fiscal year starts on the
first of a month (IRS Pub. 538); tax_classification is read by whole
words (Treas. Reg. 301.7701-3, Schedule C instructions; "business corp"
no longer reads as an S corp, "corporation or S corporation" is refused);
duplicate or blank members and shared capital accounts are refused; a
negative or non-grammatical receipt threshold is refused; codes trimmed.
tests/spec_config.rs (65 spec-blind tests, 14 properties), fuzz target
fuzz_config_parse (weekly). Mutation: 55 of 55 viable caught (from 25/38).

The receipt policy moves to ledger-config, with one loader
(effective_receipt_policy) and one decimal grammar, so the server and
the viewer enforce and show the same rule (the viewer had its own: only
expense-like debits, a silent $25 default, and no config.json).

ledger-mcp: a module spec (MCP 2025-06-18 tools, JSON-RPC 2.0, the
operating contract). Amounts in are exactly -?digits[.digits]; a number
that is one account's code and another's id (vendors, classes alike) is
refused, not guessed; the threshold is validated at startup. Fixed:
add_line with a bad class journaled the line before refusing;
profit_and_loss_by_class ignored `class`; aging buckets said "0". A wire
in front of rmcp answers malformed JSON with -32700 and invalid requests
with -32600 and their id (rmcp dropped the first, omitted the id).
tests/spec_mcp.rs (26 spec-blind tests: every tool's result against its
own outputSchema, a random-call state machine checking the journal is
untouched by every refusal, amount grammar, previews, the receipt policy).

ledger-gui: money, accounting, and dollars never round (they rounded to
cents, banker's style); export file names avoid Windows device names,
trailing dots and spaces, empty names, and 255-byte overflow; specs on
attention, history, fuzzy search, and axis ticks.

ledger-core: the lifetime profit_and_loss is renamed unclosed_earnings
(docs/testing.md #3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 10:13:05 -06:00
swaitsandClaude Opus 5.5 09efa17670 books.renumber_account: give an account a new code, history kept
Ledger::renumber_account changes only the code (entries name accounts by
id): refuses an unknown account, a blank or used code, and a control code
for an account of the wrong type or a placeholder. Journaled as
account.renumbered (with the previous code), replayed, projected, shown
in the viewer's journal. The MCP tool lists wanderbooks.toml settings
that still name the old code. The server instructions now warn that the
engine can't tell a same-type account with another purpose at a control
code (a hosting expense at payroll's 6100).

Tests: core property (only the code changes; trial balance equal), core
refusals, the store's command property, MCP end to end with restart.
Mutation on the new code: 8 of 8 viable caught.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 08:00:58 -06:00
swaitsandClaude Opus 5.5 0ed664080c import: XLSX layout spec and tests; mutation 53% -> one equivalent left
report_to_xlsx gains INVARIANTS 4-6 (header block, right-aligned amount
headings, spacer and account spacing, footer one empty row down, rules
exactly where rule_above says, exact text grouped in threes), tested by
reading the workbook's styles, merges and column widths. The general
ledger's footer now sits one empty row down like the other sheets.

Also killed: IIF transliteration arms, tax summary's optional sections,
and day-equals-month bank dates. `just mutants-import`, weekly in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 23:01:07 -06:00
swaitsandClaude Opus 5.5 13239d4d8c import: tests for the XLSX ledger and journal sheets, header and footer, tax summary differences, cash flow adjustments, owner limit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:36:02 -06:00
swaitsandClaude Opus 5.5 6fdb8fccde import: spec-blind suite (40 tests); POSIX two-digit years; exact sub-cent text
tests/spec_import.rs, written from the import crate's specs and sources
alone: every statement foots as printed on random books (P&L, balance
sheet with and without members, cash flow, trial balance, members'
equity), format_amount round-trips, bank CSV round-trips in both layouts
with date-order scenarios, IIF transactions sum to zero with every
account declared and ASCII fields, XLSX cells read back through a small
zip/DEFLATE reader, PDF labels with Typst markup render as text and the
bytes are deterministic, chart CSV and templates.

Its two findings, both fixed:
- "1/15/69" parsed as 2069: chrono's %y cuts over at 70, POSIX strptime
  at 69 (69-99 are 1900s). Two-digit years are computed by POSIX now.
- An amount finer than a cent went into an XLSX as rounded text ("1.01"
  for 1.005); it is written as its exact text now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:17:02 -06:00
swaitsandClaude Opus 5.5 9cd9e968a9 ci: weekly fuzz_import_csv; testing.md: import in progress
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:03:26 -06:00
swaitsandClaude Opus 5.5 26fd0af1cf import: specs for the PDF, XLSX, and chart parsers; chart header on the first non-blank row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:03:03 -06:00
swaitsandClaude Opus 5.5 48ccc20e03 import: templates match the engine's control accounts; report specs
- Starter charts put "Platform and Payment Processing Fees" at 6100, the
  code payroll posts wages to, so a template company that ran payroll
  would book its wages as processing fees; their own payroll accounts
  (7000, 7050, 2300) were never used. Now 6100 Salary & Wage Expense and
  6110 Payroll Tax Expense (the engine's control accounts, with tax
  categories), fees at 6060, and the unused accounts gone (the
  withholding payables 2110-2150 are created when payroll first runs).
  New tests: every template imports with categories valid for its
  return; no template puts another account at a control code; a
  partnership names every partner and its shares sum to 100%.
- format_amount printed a negative zero ("(0.00)" for -0.004); it rounds
  half away from zero first now, like amount_parts.
- Specs (GOAL/SOURCE/INVARIANTS) for statement finishing, P&L, balance
  sheet, cash flow, trial balance, members' equity (each must foot as
  printed), format_amount, and the templates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 21:01:51 -06:00
swaitsandClaude Opus 5.5 96c70f727a tax summary: Schedule K items stated separately; XLSX never shows a wrong amount
From the IRS form instructions (now quoted in docs/spec/sources/
irs-tax-lines.md):
- On Forms 1065 and 1120-S, Schedule K takes "only taxable portfolio
  interest", and charitable contributions are among the items the entity
  "must state separately". The tax summary counted both in ordinary
  income and deductions; they now go in TaxSummary::separately_stated,
  outside book_net and tax_net, and the report, MCP export, and viewer
  list them under "Separately stated to owners (Schedule K)".
- Schedule C: "Do not include charitable contributions". The category no
  longer applies to Schedule C (a sole proprietor's gift is personal, an
  owner draw).
- interest_income's note: interest earned in the business itself is
  ordinary income (other_income).

XLSX: a cell number is a binary double and Excel honors 15 significant
digits (Microsoft, "Excel specifications and limits"), so an amount over
$9,999,999,999,999.99 or finer than a cent is written as its exact text,
right-aligned, instead of a number Excel would show wrong. The old
conversion also fell back to 0.0 silently; gone. Property test: every
whole-cent amount up to 15 digits round-trips exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 20:57:00 -06:00
swaitsandClaude Opus 5.5 d99589e6c1 import: grounded in Intuit's specs; atomic chart and bank imports
New vendored sources (docs/spec/sources/): Intuit's bank CSV upload and
IIF formats, ECMA-376 / Excel number storage, and the IRS form lines the
tax categories map to.

IIF export (rewritten to Intuit's IIF Import Kit):
- declares every account first (!ACCNT NAME, ACCNTTYPE, ACCNUM): an
  undeclared account is created in QuickBooks "as a new bank account";
  the MCP export types them (cash accounts BANK, the control accounts AR
  and AP, registered fixed assets FIXASSET, the rest by type);
- subaccounts by colon path, parents first; colons in a name replaced;
- ASCII only (IIF is ASCII text), dashes and quotes transliterated;
- the kit's 2007+ journal layout and M/D/YYYY dates; an entry using an
  undeclared account is refused.

Bank CSV:
- the 4-column layout is money out then money in by position, which
  matches Intuit's "Credit, Debit" and banks' "Debit, Credit" both (the
  doc's "Expense, Income" labels were wrong, the parsing was not);
- a slash date's order comes from the file (a part over 12 settles it)
  or the caller's new date_order, never a guess: Intuit recommends
  dd/mm/yyyy, US banks write mm/dd/yyyy;
- fractions of a cent and double negatives ("(-5)") are refused; the
  header is recognized on the first non-blank row.

Chart CSV: a blank code or name is refused with its row (found by the
new fuzz_import_csv target), and the core now refuses a blank account
code or name too (replay keeps older books).

MCP:
- books.import_chart_csv and books.import_bank_csv rehearse the whole
  file on a copy of the ledger first: a bad row anywhere refuses the
  file (before, a duplicate within the file, a parent after its child,
  or a date in a closed period left earlier rows behind);
- bank rules: the longest matching pattern wins (they arrive in a map,
  so "first match" changed from run to run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 20:47:41 -06:00
swaitsandClaude Opus 5.5 18daa16ac1 store: last mutation survivors killed; 98% with four equivalent left
Final store mutation run: 357 caught + 1 timeout of 365 viable (from 82%
this morning). Three of its seven survivors now have tests:
- replay refuses a re-chained journal whose schedule posting differs
  from what it re-derives (check_replayed_entry guards that event);
- a disposal refused for an account's type says the type it needed;
- a database whose last pages are damaged while its settings still read
  fails the integrity check and is rebuilt (verified to fail with the
  check disabled: "database disk image is malformed").
The four left are equivalent: sort_value's two arms, OpenFlags | vs ^,
and writing the long form of a sweepless retained-earnings close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:59:37 -06:00
swaitsandClaude Opus 5.5 c3ba9e0884 store: second mutation pass (345/373); trim what rehearsal covers; tests
Store mutation rerun: 345 caught of 373 viable (92.5%, from 82%). The
survivors, triaged:

- Removed code the survivors showed could never matter: set_line_class's
  hand-written checks (its rehearsal refuses first), ensure_on (the
  engine creates missing control accounts itself), and verify's
  live-vs-replay halves of the asset, class, and payroll comparisons
  (the whole-ledger comparison above them already checks that; the
  SQLite read-back halves stay).
- New tests: disposal account types; depreciation periods must be real
  months; search_entries (filters, newest first, paging, no drafts);
  the database's invoice amount paid and status; replay refusing a
  re-chained journal whose closing entry differs from what it
  re-derives; a projection missing a table.
- Equivalent: sort_value's arms (serde_json's maps are already sorted
  without preserve_order, which nothing enables; the function stays as
  the guard if anything ever does), OpenFlags | vs ^ (disjoint bits), and
  writing the long form of a sweepless retained-earnings close (replay
  reads both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:46:00 -06:00
swaitsandClaude Opus 5.5 22b5a699b5 store: a database with the wrong currency is rebuilt, not blamed on the journal
Store::open refused books whose database recorded a base currency other
than the journal's, with JournalCorrupt, though the journal was fine and
the database is derived (a mutation-testing survivor: nothing tested
the case). Now the currency comes from the journal, and a database that
fails its integrity check or disagrees on currency goes through the same
path: kept aside for forensics and rebuilt (open SCENARIO 6).

Also tests: the viewer tells a database Behind the journal from one
Ahead of what it has read; the table check counts missing and
unexpected rows; a new journal creates its directory; a stored blob
exists and an unknown one doesn't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:37:20 -06:00
swaitsandClaude Opus 5.5 309572fc65 store: tests for mutation survivors; drop pre-checks the rehearsal covers
From the first store mutation run (324 caught of 396 viable):
- receive_payment and pay_bill repeated the engine's refusals by hand
  after rehearsing the engine itself, so those checks could never fire
  (their mutants survived). Removed: the rehearsal is the check.
- New hand-computed tests: a disposal's final catch-up after some
  depreciation was posted (200 of 300, gain 100); a disposal on the
  purchase date (allowed) or the day before (refused); a retained
  earnings close with a sweep replays it; a float inside an array is
  refused; the viewer's journal and head hash.
- The concurrency test's reader stops when the writer thread ends, not
  only when it says it's done, so a mutant that kills the writer fails
  the test at once instead of spinning to the five-minute timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:32:32 -06:00
swaitsandClaude Opus 5.5 64c272c290 ci: weekly store mutation run and replay fuzzing
`just mutants-store` (64 random cases per mutant, temp books on a RAM
disk in CI) runs beside the core's weekly; fuzz_store_replay joins the
weekly fuzz matrix. docs/testing.md lists the spec-blind store suite,
the refusal property's converse law, and the replay fuzz oracles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:26:27 -06:00
swaitsandClaude Opus 5.5 111a5af470 store: refusals leave the live books untouched; per-filing names; spec suite
The refusal property gained the converse law (an accepted command
journals something; only batch runs may find nothing to do) and checks
the store's class and payroll listings. It found a schedule registered
with an out-of-range total whose first month was refused after its entry
was begun: the live ledger kept a draft the journal never had.

- Core: compound commands (post_schedule_month, reverse_entry,
  close_period_to, create_invoice, create_bill, receive_payment,
  pay_bill, run_payroll, remit_payroll_taxes) run inside
  Ledger::atomically, so a refusal partway leaves the ledger exactly as
  it was.
- Core: a schedule total no line could carry, or finer than a cent, and
  an asset cost beyond the line range, are refused at registration.
  Replay keeps older schedules (replay_register_schedule).
- Store: add_line, add_line_fx, mark_cleared, set_fx_rate,
  post_schedule_month and close_period_to changed the live ledger before
  journaling; they now run on a copy, journal what it produced, then
  swap it in, so a failed write leaves the live books as journaled.

New spec-blind suite tests/spec_store.rs (57 tests written from the
store's specs alone). Its two findings, adjudicated:
- A file filed twice under different names listed the first name and
  type for both (the projection kept them per blob). Schema v8 gives
  each attachment its own; older databases backfill and heal by verify.
- Withdrawn attachments stay in the store's listing, marked (the MCP
  listing hides them): the spec and doc said "omit" and are corrected.

Also removes journal::truncate_to_last_good_seq: unused, and it dropped
everything after the first bad complete line, which the books now treat
as corruption for a person, never repaired automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:24:34 -06:00
swaitsandClaude Opus 5.5 27da4b63bf store: specs for the write path, open, verify, heal, receipts, viewer
GOAL/SOURCE/INVARIANTS/SCENARIOS blocks, citing R-11, R-12, R-13a,
docs/receipt-store.md, IRS Pub. 583, and Rev. Proc. 98-25: the module
spec every command method follows (refuse whole, journal first, gapless
chained lines, replay equals live, multi-step commands rehearsed whole,
nothing deleted), Store::create, open, verify, verify_and_heal,
attach_receipt, link_receipt, unlink_receipt, withdraw_attachment, and
Snapshot::refresh. Also moves ensure_control_account's doc comment back
onto it (it had been stacked on rehearse).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:08:33 -06:00
swaitsandClaude Opus 5.5 6855126b42 store: benchmarks at 10k entries on disk
benches/store.rs (criterion, in `just bench`): open 249 ms, viewer load
616 ms, full verify 809 ms, post one entry (four synced appends) 62 ms.
The books are built on a temp dir and copied under target/ so every
sync hits a real disk. Baselines in docs/testing.md; no budget, by
decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 19:06:11 -06:00
swaitsandClaude Opus 5.5 6fa1b382ae closes: a run of members closes splits cumulatively
Each close into members' capital rounded on its own, ties to the
earlier member, so twelve monthly 50/50 closes of odd-cent income could
leave one member several cents ahead of half the year (docs/testing.md
#12, found with the members' equity fix).

- CloseTo::Members now splits cumulatively over a run (closes back to
  back with the same members and shares): each close gives a member
  allocate(run total so far) - allocate(run total before). Periods
  record their close order and a cumulative close's members and net
  (Period::close_seq, member_close) so the next close continues the run
  and replay recomputes it exactly. A close to anything else, or other
  shares, ends the run.
- Journaled "rounding": "cumulative". The per-close rule is kept as
  CloseTo::MembersPerClose for closes journaled "largest_remainder";
  older closes still replay with the last member absorbing it.
- Lean: runPart_telescopes (a member's total from a run is their
  allocation of the run's total) and closePart_total (each close hands
  out exactly its net), audited to the standard axioms.
- Tests: hand-computed three one-cent months (per close A 0.03 / B 0;
  cumulative A 0.02 / B 0.01), a run ending, a property that each member
  stays within a cent of their share (fails on the per-close rule), and
  a store contract that each rule journals and replays as itself.

The first company's books have no closes yet, so all of theirs use the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:58:21 -06:00
swaitsandClaude Opus 5.5 ad80171ba2 viewer: no false "database differs" while the books are being written
New concurrency test: one thread writes entries, drafts and receipts as
fast as it can while a Snapshot (the viewer) refreshes in a loop. At
every refresh the snapshot's books must balance, never go backward, and
never see the writer's database as differing from the journal; at the
end it must equal the writer in full. It failed 30 runs in 50:
check_disk read the applied position and the tables in separate
statements, so a write landing between them made a consistent database
look like it "differs", which the viewer shows as a problem in Needs
attention.

- check_disk reads in one read transaction (one consistent view).
- A database that has applied lines the viewer hasn't read yet is now
  DiskCheck::Ahead ("viewer catching up"), not Behind.
- Without the transaction the test fails 14 runs in 20; with it, 50/50.

Also: members' equity spec 3b states the exact bound (a member's period
income is within two cents of their share, one cent of rounding at each
end; the running balance chains exactly), and the spec-blind property
checks that bound. docs/testing.md gains the concurrency layer and item
12: each close into members' capital still rounds on its own (pending).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:51:15 -06:00
swaitsandClaude Opus 5.5 45982f702d members' equity: split undistributed earnings cumulatively
A capital account is one running balance (Form 1065 Schedule K-1
instructions, Item L: beginning and ending capital "based on the
partnership's books and records"), but the statement split prior
earnings and the period's income separately, so a member's ending on one
statement could differ by a cent from their beginning on the next.

Now each member's share of undistributed earnings is allocated as of a
date (largest remainder, as before): beginning as of the day before the
period, ending as of its last day. A member's period income is the
roll-forward's remainder (their share within a rounding cent); incomes
still sum to net income. New property
each_members_capital_chains_across_statements (random books, odd-cent
shares, closes to members or not, any split date) fails on the old code
and passes now. The hand-computed test is recomputed: the old split gave
two cents off an even 50/50 to date.

Also: rules.md B-13 quotes Item L; docs/testing.md #7 and #8 marked
changed; the CPA-question list is replaced by the rule that wanderbooks
settles questions from the sources and makes real choices per-company
settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:41:37 -06:00
swaitsandClaude Opus 5.5 fbe496566b cash flow: financing and investing gross; account roles are a setting
From ASC 230 (via Deloitte DART and PwC Viewpoint), replacing two
formerly assumed behaviors:

- Financing and investing are gross (230-10-45-7): per account, cash in
  and cash out are separate lines (contributions apart from
  distributions, borrowing apart from repayment), netted only within
  one entry. Lines come in code order, in before out.
- Investing includes making and collecting loans and buying investments
  (230-10-20, 45-13), not only registered fixed assets:
  CashFlowParams gains investing_accounts.
- The roles are per-company settings: [books] financing_accounts and
  investing_accounts in wanderbooks.toml (beside cash_accounts). The MCP
  tools (cash_flow, export, CPA package) default every list to them and
  share one path; the viewer reads them too, falling back to its name
  guess for debt. A due-to-owners liability listed as financing gives
  PwC 6.9.3.1's constructive treatment of expenses an owner paid.

rules.md B-14 quotes the sources; new tests cite them; docs/testing.md
#8 is resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:36:21 -06:00
swaitsandClaude Opus 5.5 cced14fa52 docs: the assumed list, checked against the standards; mutation score
Each formerly ASSUMED behavior is now CONFIRMED, CONVENTION, DESIGN, or
CHANGE (pending), with its source (Reg. S-X, ASC 230 via Deloitte DART,
IRS K-1 instructions, OpenStax). Two need engine work: members' equity
should allocate cumulatively so each member's beginning equals the prior
ending (#7), and the cash flow should show financing gross and classify
investing by account role (#8). Two questions for the CPA are listed.
The test doc comments that relied on them now cite the verdicts.

Core mutation run: 1,011 caught + 13 timeouts of 1,024 viable, 0 missed.
No benchmark budget, by decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:18:30 -06:00
swaitsandClaude Opus 5.5 e2c421be35 core: no entry posts a fraction of a cent; verify compares whole ledgers
CI's run of the store refusal property found a payroll run that posted
$65.27794: the employer tax was passed in unrounded and nothing refused
it, though rules.md B-3 already says a ledger in one money holds no
fractions of its smallest unit. Sums of it then differed in scale
between journal replay and the SQLite read-back.

- Ledger::post_entry refuses any line finer than its currency's ISO 4217
  minor unit (FinerThanMinorUnit), whatever made the line: payroll,
  closes, disposals. add_line refuses it up front. Older journals replay
  as booked through replay_post_entry. Spec tests for the kernel's
  ten-place limit now run on the replay path.
- The property's body is now run_ops, so failing sequences become named
  regression tests (regression_payroll_with_a_fraction_of_a_cent). The
  generator rounds payroll tax to the cent, as callers must.
- Ledger derives PartialEq and gains differs_from; Store::verify compares
  the live ledger with the journal replay in full (drafts, documents,
  schedules, periods, settings), not just balances, and names the parts
  that differ. The property checks the ledger its commands built against
  that replay and against the reopened books.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 18:17:13 -06:00
swaitsandClaude Opus 5.5 54f632dfea store: set aside a torn last journal line on open; server waits for lock
A crash or power loss mid-append can leave a partial last line. The
writer acknowledges an append only after its whole line is synced, so
that line was never acknowledged: opening now saves those bytes to
journal.jsonl.torn.<time>, truncates to the last newline, and journals
journal.tail_repaired (replay and projection ignore it; the GUI shows
it; books.digest reports it in journal_repairs). Damage before the last
newline is still corruption and still refuses to open. Tested at every
byte of the last append.

The MCP server now waits up to ten seconds for the writer lock: a
restarted server (a client reconnecting) raced the exiting one and died
with Locked, which also made mcp_schedules_and_recurring_over_stdio
flaky. The test harness now waits for each server to exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:58:29 -06:00
swaitsandClaude Opus 5.5 55f9e8f6e0 store: refusal property reaches every success path; two engine fixes
The refusal property now starts from real books and adds compound
commands with valid arguments (book an entry, register an asset or
schedule, raise an invoice or bill, close a month to members), so every
command's success path is exercised. Compound commands checkpoint after
each step, so the law is checked against the one command refused.
STORE_CASES sets the case count (256 by default).

It found two bugs:

- run_recurring created drafts dated inside a closed period (B-12).
  generate_recurring now refuses them, atomically; the store leaves
  them ungenerated and books.run_recurring reports them in
  skipped_closed_period for a human to decide. Replay uses
  replay_generate_recurring so older journals still open. The store
  also journals before mutating the ledger here, as everywhere else.
- Disposing an asset for no proceeds (scrapping it) journaled a
  catch-up depreciation entry and a half-built draft with a 0.00 line,
  then refused. Zero lines are now left out (Store::disposal_lines,
  shared with the MCP preview), and the whole disposal is rehearsed on
  a clone before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:48:20 -06:00
swaitsandClaude Opus 5.5 02d1f20f90 fix(store): a full disk mid-command leaves the books whole
Max-testing pass on ledger-store, fault injection (rank 1). A full disk
is simulated with no code seam: the test re-runs its own binary as a
child under bash with the file size capped (ulimit -f, SIGXFSZ
ignored), so writes past the cap fail as on a full disk. Two bugs, both
of which left the books unopenable:

1. SQLite side: applying one journal line runs several statements (rows,
   events mirror, last_applied_seq) in autocommit. When the disk filled
   partway, the rows committed but last_applied_seq didn't, and the next
   open's catch-up re-applied the line into "UNIQUE constraint failed:
   entries.id". Each line now applies inside a savepoint: all or
   nothing. (docs/receipt-store.md promised exactly this heal; it
   wasn't atomic.)
2. Journal side: an append that failed partway left a partial line, and
   any later line would land after it, mid-journal. append now cuts the
   file back to its length before the write on any error.

Tests (tests/faults.rs, its own binary because the child re-runs it):
a_full_disk_mid_command_leaves_the_books_whole (the database fills
first) and a_full_disk_mid_append_leaves_only_whole_journal_lines (the
journal writer alone). Both shown to fail without their fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:16:43 -06:00
swaitsandClaude Opus 5.5 6625c8cea0 fix(store): a blob id never reaches outside the blob store
Max-testing pass on ledger-store (security property, rank 2). Blob ids
come from the journal, which is untrusted input on disk. BlobStore built
paths from any string: an id under four bytes (or with multi-byte
characters) panicked on slicing, and exists() would report on any path
an id like "../../x" reached. get() never served such bytes (it serves
only content whose SHA-256 equals the id), but it read them.

Now only a 64-hex-digit id names a blob; anything else is an error from
get() and false from exists(), touching nothing. Test
a_blob_id_never_reaches_outside_the_blob_store (empty, short, unicode,
traversal, absolute, non-hex, 63/65 digits; uppercase hex still works).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:10:01 -06:00
swaitsandClaude Opus 5.5 bf3f5b4cdb test(core): every surviving mutant killed or argued equivalent
Max-testing pass, step 8. The final mutation run left 112 survivors
(89% caught). Three agents triaged them by file, by the playbook's
table: spec the boundary then test it, a law rather than one more
pinned value, assert the real value, never weaken a test. Every expected
value is hand-computed.

- reports.rs (35): 28 killed, chiefly cash flow (inclusive start date,
  each line's sign and label, a loss on disposal moving to investing,
  financing designation), general ledger and members' equity date
  boundaries, and the balanced() checks (a real report nudged by a cent
  must stop balancing). 7 equivalent: four >/>= after an early return on
  zero; two in dead code (removed in this commit); one sign of a zero
  cleaned on output.
- ledger.rs, kernel.rs, journal.rs, money.rs (52, plus one new): 49
  killed, including every replay_* mutant at the core level (ids,
  accumulation, status, vendor tags, out-of-order refusal), FX rates,
  0%/100% deductible boundaries, a dividend sweep with no income, the
  legacy member rounding at close, a bill paid on its own date, a
  liability-account reconciliation, mixed-currency entries (via serde),
  ISO 4217 minor units, Display/Debug. 3 equivalent: zero refused before
  a </<= check; a period whose end is in a closed period refused earlier;
  columns(0).
- schedules, tax, analysis, payouts, assets, form1099 (25): 23 killed
  (schedule length 1-600, recurring end on its start, frequency parsing
  and stepping, cost of goods in tax net, a zero payout, disposal on the
  purchase date, quarter starts, labels); 2 equivalent (zero adds zero;
  days_held is provably >= 1).

Two small production cleanups the triage surfaced: kernel::columns(0)
returned a negative-zero credit that would print "-0" (regression
a_zero_movement_has_no_negative_zero_column, shown to fail before); and
cash_flow computed a book value it never used.

Spec lines added where a boundary wasn't specified (cash flow's
inclusive start and line signs, members' equity start date, payout with
no deposit, disposal on the purchase date, bill paid on its own date).
`just mutants` now runs core and store tests (the store's replay tests),
not the whole workspace, which rebuilt the viewer for every mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 17:00:45 -06:00
swaitsandClaude Opus 5.5 b2b49267db fix(store): one writer at a time; a second is refused, not allowed to corrupt the books
Max-testing pass on ledger-store, found during the survey: nothing
stopped two processes from opening the same books for writing. Two
agent sessions (two MCP servers) on one books dir would each extend the
hash chain from the same last line; one write from each broke the
journal ("seq gap: expected 3, found 2") and the books would not open.

The store now holds an exclusive lock on books-data/wanderbooks.lock
(std's File::try_lock, no new dependency) from before it reads the
journal until it is dropped; the OS releases it if the process dies. A
second writer gets StoreError::Locked saying another process has the
books open. The viewer reads through Snapshot and never takes the lock.
The lock file is git-ignored in the books scaffold.

Regression: a_second_writer_is_refused_while_the_first_has_the_books
(refused while open, allowed after close, books whole).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 16:05:59 -06:00
swaitsandClaude Opus 5.5 c4c4ce05c3 test: fuzzing, Miri, benchmarks, CI, and a strategy note; the journal refuses floats
Max-testing pass, ranks 4-6 and step 9 for ledger-core.

Fuzzing (rank 4): fuzz_op_sequence now drives balanced pairs, foreign
lines, periods, closes, and amounts from raw decimal bits, and asserts
the books stay valid (TB balances, posted entries validate, no draft in
a closed period); new fuzz_core_serde (every persisted core type
round-trips) and fuzz_store_open (a corrupt journal is an error, never a
panic); hangs count (-timeout=10). 54M executions; one crash:
- fuzz_journal_line: a number too big for an integer parsed as a float,
  and its canonical form (which the hash chain covers) re-canonicalized
  differently. The journal never holds floats (R-2), so the reader now
  refuses them (JournalLine::parse, used by every journal reader);
  regression a_journal_line_with_a_float_is_refused. Checked first: the
  real books' journal has none. Rerun: 13.2M executions, clean.
Seeds in fuzz/seeds/ (synthetic, minimal); the grown corpora stay out of
git. `just fuzz <target> <seconds>`.

Miri (rank 4): the core's unit, bridge, and invariant tests under Miri
(our code has no unsafe; rust_decimal and chrono do): no undefined
behavior. `just miri`. A pinned nightly (nightly-2026-10-01, with miri)
is now in mise.toml.

Benchmarks (rank 6): criterion (new dev-dependency: the playbook's Rust
benchmark tool; none existed) in benches/books.rs; baselines in
docs/testing.md. The store's per-command rehearsal clone costs ~1 ms at
10k entries. No budget yet, so nothing gates on them. `just bench`.

CI: .github/workflows/check.yml runs `just check` on every push;
scheduled.yml runs proofs, the GnuCash oracle, fuzzing, and mutation
weekly. Tools come from mise.toml.

docs/testing.md: every layer, its oracle rank, command, runtime, and
gate; benchmark baselines; the ASSUMED invariants for the owner to reject;
the gap list with triggers. docs/oracle.md's fuzzing paragraph had
claimed persistent corpora that didn't exist; corrected.

Also: ledger-import now inherits the workspace lints (nightly cargo
warned).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 15:09:22 -06:00
swaitsandClaude Opus 5.5 b35fad7e05 test(core): model-based stateful test against the Lean state machine
Max-testing pass, rank 3. tests/model.rs transcribes the Lean ledger
state machine (verify/lean/Wanderbooks/Ledger.lean) into the plainest
Rust with the same observable behavior: lists of accounts, drafts,
posted entries, and periods. Random command sequences (add account,
draft, add line, a balanced pair of lines, post, reverse, create period,
close), weighted toward commands that interact, run against both; after
every command the engine and the model must make the same accept/refuse
decision and agree on the posted log line for line, the chart, drafts,
periods, ids, and every account's balance. 2,048 sequences of up to 60
commands; about 3s.

Honest about its first version: it agreed vacuously. Counting accepted
commands (MODEL_STATS=1) showed zero posts and zero reversals: random
lines never balance. A balanced-pair command fixed it (now ~150 posts,
~100 reversals, ~200 closes per 512 sequences). Shown to have teeth:
letting the model reverse an entry twice fails in 0.03s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:49:46 -06:00
swaitsandClaude Opus 5.5 f9bd507765 fix(core): payments never predate their documents; aging as of a date; refusals leave no draft
Three spec decisions from the owner (2026-10-13), raised by the spec-blind
test writers, each test-first:

- A payment dated before its invoice or bill is refused
  (PaymentBeforeDocument); a real prepayment is a deposit. Test:
  a_payment_never_predates_its_document. Replay is unaffected (it goes
  through replay_invoice_payment / replay_bill_payment).
- Aging as of a date shows only documents issued by then, less only the
  payments made by then. The ledger now records each document's payment
  entries (live and on replay), and aging counts those dated by as_of.
  Test: aging_as_of_a_date_sees_only_what_existed_then; the A/R and A/P
  conservation properties' aging oracles updated to the confirmed rule
  (they had assumed aging ignored dates).
- A schedule through a mid-month date prorates that month by days; the
  code was right and its doc wrong. The spec-blind test that disagreed is
  rewritten to the confirmed spec and no longer ignored.

Also found while wiring payments: a payment to an unknown cash account
created the payment's draft and then failed, leaving it behind; the cash
account is now checked before anything is created.

The unreviewed golden van_schedule.json is gone: every row was first
checked against an independent hand computation (all 61 matched), then
replaced by van_schedule_is_the_hand_computed_one, which asserts that
computation directly (548.39, 59 x 1,000.00, 451.61; total 60,000.00).

Tests: 452. No spec-disagreements left ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:47:10 -06:00
swaitsandClaude Opus 5.5 ff7a9172b0 test: restore properties.proptest-regressions (deleted by mistake)
proptest replays the failing cases saved there on every run; it is
checked in on purpose (the max-testing playbook: keep the framework's
failure database).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:18 -06:00
swaitsandClaude Opus 5.5 bac81593e9 test(core): spec-blind suites and a live GnuCash oracle; four fixes they found
Max-testing pass, steps 2-4.

Spec-blind tests (rank 1-2): four agents, each given only the specs and
the public interface (doc comments and signatures, every body removed,
never src/ or the existing tests), wrote tests/spec_reports.rs (62),
spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50):
hand-computed examples per scenario and a proptest law per invariant
(conservation, partition, invariance, scaling, reversal cancels,
"a refusal leaves the books unchanged" over random invalid commands).
They found:

1. A refused payroll run changed the books: an all-zero run passed
   validation, then failed at post after creating seven payroll
   accounts and using up an entry id. validate_payroll_lines now
   refuses a run with no gross pay.
2. A refused add_account used up an account id (empty code, duplicate,
   unknown parent). The id is now taken only when the account is added.
3. Stale prose: close_period_to and members_equity still described the
   old last-member rounding.
4. Open (left #[ignore]d pending the owner): depreciation_schedule's doc
   says "through the month containing" the date; the code prorates that
   month by days.

GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds
GnuCash 5.10 in a container pinned by base digest and package versions
(oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random
books built through wanderbooks (entries, foreign lines, reversals,
monthly closes), every account's balance at month ends and random dates
and each month's P&L, compared exactly: 15,800 comparisons. First run:
4,724 differences, all from foreign lines; without them, zero.
Adjudicated from principles (rules.md B-3): wanderbooks booked
foreign * rate unrounded ($108.34567); a ledger in one money holds no
fractions of its smallest unit, and its cash must match a bank. The owner
decided: book in the base currency's minor unit, half away from zero.
add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17,
SIX), generated into Currency::minor_units and recorded in
docs/spec/sources/iso-4217.md. Replay now takes the journaled base
amount (replay_add_line_fx), so lines booked before replay exactly as
booked (contract: books_written_under_older_rules_still_open). Now:
zero differences, foreign lines included.

Tests: 449, from 223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:06 -06:00
swaitsandClaude Opus 5.5 f0fe80a8db docs(core): written specs, with sources, for the commands and reports
Max-testing pass, step 2: every domain decision in ledger.rs and
reports.rs gets a GOAL / SOURCE / INVARIANTS / SCENARIOS block in its
doc comment: post_entry, try_create_entry, close_period_to,
mark_cleared, reconciliation_report, account_balance, create_invoice,
create_bill, receive_payment, pay_bill, register_asset, dispose_asset,
run_payroll, remit_payroll_taxes, set_line_class, set_entry_vendor;
trial_balance(_as_of), profit_and_loss_for_period, balance_sheet_as_of,
general_ledger, members_equity, cash_flow, profit_and_loss_by_class,
snapshot. Sources trace to docs/spec/rules.md (and through it to
Pacioli, the IRS, FASB); what no document settles is marked CONVENTION,
DECISION, or ASSUMED for the owner to confirm. The specs are the input to
spec-blind test writing (the next step): tests derived from the spec
alone, never from the code.

Also: rustdoc now runs in `just check` with -D warnings; fixed the seven
existing broken intra-doc links and one bare URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:21:44 -06:00
swaitsandClaude Opus 5.5 4647611f96 fix(core,store): child accounts share their parent's type; periods nest; lines checked when added
Three spec decisions from the owner (2026-10-13), each test-first:

- A child account has its parent's type (ParentTypeMismatch): a child
  subdivides its parent. Test: a_child_account_has_its_parents_type.
- Periods nest (a month in a quarter in a year) but never partially
  overlap (PeriodsOverlap; a refusal uses no id). Test:
  periods_nest_but_never_partially_overlap.
- A line's account must exist and take postings when the line is added,
  not only at post (UnknownAccount / PlaceholderAccount from add_line
  and add_line_fx); posting still re-checks. Two older tests that pinned
  the post-time refusal now check both (the post-time check is reached
  through the unchecked replay path, as old journals can).

Compatibility: replay reads history through new Ledger::replay_*
commands (add_account, add_line, add_line_fx, create_period) that skip
checks added later, so books written before these rules still open.
The contract test books_written_under_older_rules_still_open writes such
a journal directly and found, on its first run, that the SQLite
cross-check rebuild used the checked commands and would have failed
verification ("sqlite projection can't be read back"); it now replays
too. Documented in docs/spec/README.md; decisions recorded in rules.md
(B-1, B-4, B-12).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:16:51 -06:00
swaitsandClaude Opus 5.5 4eaac20311 feat(core,mcp,gui): never apply a legal rule past what wanderbooks knows
The owner (2026-10-13): when what wanderbooks knows is stale, check the
date, research it or ask the owner or CPA, and update the rule for
everyone with a pull request (best) or an issue (worst).

- ledger_core::rules: a registry of rules set by law that change over
  time. Each says the years it's known for, its rules.md entry, its
  vendored official source, and a review date. stale_rules(today) is
  pure; the engine has no clock.
- The 1099 threshold is the first: $600 through 2025, $2,000 in 2026,
  then indexed for inflation (26 U.S.C. §6041(h)). default_threshold
  and vendor_1099_report now return Result; for 2027+ they refuse with
  LedgerError::RuleNotKnown, whose message says what to do: get the
  figure from the official source or the owner/CPA, pass it explicitly,
  then open a PR (or issue) at github.com/swaits/wanderbooks. Before,
  2027+ silently used $2,000.
- MCP: books.vendor_1099 relays that refusal; books.digest gains
  stale_rules; the server instructions tell the agent the procedure (and
  how to handle control-account conflicts: explain, never renumber).
  The CPA package writes a NOT-INCLUDED note instead of a guessed 1099
  worksheet.
- Viewer: "Needs attention" shows each stale rule with a ready prompt
  for the agent; the 1099 page explains instead of guessing.

Review date for the 1099 rule: 2026-11-01, when the IRS usually
publishes next year's inflation adjustments (CONVENTION, marked as such
in the registry).

Tests: an_unknown_year_threshold_is_never_guessed,
dated_rules_go_stale_on_their_review_date (dates passed in, so no clock
in the test), MCP stdio: 2027 refused with guidance, explicit threshold
accepted, digest carries stale_rules (asserted date-independently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:07:22 -06:00
swaitsandClaude Opus 5.5 8559bba6bc fix(core,store): refuse control accounts of the wrong type, with a reason
Invoices, bills, payments, payroll, and closes post to accounts the
engine finds by code (1200, 2000, 4000, 6999, 3999, 6100-2150). If an
account with that code existed it was used whatever its type: a 1200
"Customer Deposits" liability would have taken receivables. Now
(the owner, 2026-10-13: never force an imported chart into our numbering)
the engine refuses with ControlAccountMismatch, naming the code, the
account, its type, the role, and the type needed, so the agent can work
it out with the human. A placeholder is refused too.

Every command checks all its control accounts before changing anything
(Ledger::check_controls, spec + SOURCE in its doc comment), so a refusal
never leaves a half-made account behind. One table of control accounts
(ledger_core::RECEIVABLES ... PAYROLL) replaces the codes duplicated
across the engine and the store.

Tests (spec-derived, all assert the books are untouched after the
refusal): an_invoice_refuses_a_1200_that_is_not_receivables,
an_invoice_refuses_a_placeholder_1200,
an_invoice_creates_nothing_when_its_second_account_is_wrong,
a_bill_refuses_a_2000_that_is_not_payables,
a_close_refuses_a_3999_that_is_not_equity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 12:29:24 -06:00
swaitsandClaude Opus 5.5 855d51e297 docs(spec): every rule traced to an authority; public-domain sources vendored
The owner asked that specs come from recognized documents, not from
anyone's memory. There is no single US bookkeeping bible; authority is
layered, and this records it:

- docs/spec/sources/: verbatim excerpts (whitespace normalized), each
  with URL, retrieval date, and the SHA-256 of the fetched file, of
  public-domain texts: Pacioli's 1494 treatise in Geijsbeek's 1914
  translation (double entry, the trial balance, one money, write only
  forward, correct by opposite entry, close into capital); IRS Pub. 583,
  538, 946, 15; the 1099-MISC/NEC instructions (2025 and 2026); 26 U.S.C.
  §6041, §6041A, §704; Treas. Reg. §1.263(a)-1(f) and -4(f); Notice
  2015-82; Rev. Proc. 98-25; Form 1065 instructions; 17 U.S.C. §105. The
  excerpts were extracted by matching each passage against the fetched
  file, so none can contain text the source doesn't.
- docs/spec/rules.md: the rule catalog, B-1 to B-22. Each rule says
  AUTHORITY, CONVENTION, or DECISION; cites its sources (FASB's
  Codification and Concepts Statement 8 and OpenStax quoted briefly,
  never copied: copyright and CC BY-NC-SA); and names the code that
  enforces it and the tests and proofs that check it.
- Spec blocks in the code now carry SOURCE: lines into the catalog;
  AGENTS.md makes sourcing rule 0 of the formal-methods bar.

Corrections the sources turned up: the de minimis limit is $500 in the
regulation and $2,500 by Notice 2015-82 (both now cited); 1099
thresholds after 2026 are inflation-indexed, so the engine's flat
$2,000 for 2027+ is an open question (B-19). Unverified ASC paragraphs
(360-10-35-4, 340-10, 230-10-45-10, 606-10-55-37B, 210) are listed in
docs/spec/README.md until someone with a FASB basic-view login reads
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 12:18:57 -06:00
swaitsandClaude Opus 5.5 e0b6d6988f fix(core): due-today is Current; odd cents split by largest remainder
Max-testing pass, step 2: two spec decisions the owner confirmed
(2026-10-13), each test-first.

Aging (arap::aging_bucket): a document due today was "1-30 days past
due"; it is Current, since past due starts the day after (QuickBooks'
convention). Spec in the doc comment. Boundary examples at every edge
(due today, 1, 30, 31, 60, 61, 90, 91 days) fail on the old code. A
property: the balance lands in exactly one bucket, and time only ages
it.

Member splits (periods::allocate): the last member absorbed every odd
cent, so the same partner always got or lost it. Now largest remainder:
each share rounded toward zero to the cent, leftover cents to the
largest remainders, ties to the earlier member, a loss the negation of
the gain. The core is a new kernel function, kernel::apportion,
allocation-free so Kani proves it (n <= 3): apportion_is_exact (it
succeeds exactly when the shortfall is under a unit per part; then the
parts sum to the whole, each gains 0 or 1 unit, one may take the
sub-unit fraction) and apportion_is_fair (never a unit to a part while
a larger remainder goes without). Kani found an off-by-one in the first
version: a shortfall of n units plus a fraction was accepted. Native
regression apportion_refuses_a_shortfall_of_a_unit_per_part_or_more.
Properties at Decimal (2,000 cases, up to 5 members): conservation,
within a cent of each exact share, fairness, sign symmetry; they fail on
the old rule. Bridge: apportion agrees on i64 and Decimal.

Contract: closes journaled before this change replay exactly. New
member closes journal "rounding": "largest_remainder"; journal lines
without it replay as CloseTo::MembersLastAbsorbs, the old rule
(periods::allocate_last_absorbs, pinned by
closes_journaled_under_the_old_rule_replay_with_it). The members'
equity statement isn't journaled and uses the new rule.

Open gap: Lean proof of apportion's sum law for any n (needs a
permutation lemma on the ranking); Kani covers n <= 3, properties n <= 5.
The Lean closing theorems already hold for any split summing to the
net.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 12:10:55 -06:00
swaitsandClaude Opus 5.5 847dd4388d fix(store,core): a refused command never reaches the journal; reversal rules
Max-testing pass, step 2 (rank 1 regressions + a rank 2 law). Two ways
to make the books unopenable, both found by test, both fixed:

1. Reversing into a closed period: the store journaled `entry.reversed`,
   then the engine refused it at post. Replay could never apply that
   line, so Store::open failed ("period 1 is closed"). Regression:
   reversal_into_a_closed_period_is_refused_before_journaling.
2. An oversized invoice (or any command whose hand-written store
   pre-checks lagged the engine, here the new line range) journaled,
   then failed in the engine. Found by the new property
   refused_commands_never_reach_the_journal within 4s.

The fix is structural, not another hand-written check: every
journal-first store command now rehearses on a scratch clone of the
ledger (`Store::rehearse`), so whatever the engine refuses is refused
before the journal, now and after any future engine change. The
create_entry rehearsal uses the checked constructor: no draft is ever
dated in a closed period (the Lean invariant Inv.draftsOpen, which the
store had bypassed).

The property (1,000 random command sequences, heavy on refusals)
asserts, after every command: a refusal leaves the journal
byte-identical; every posted entry validates; the trial balance
balances; no draft sits in a closed period; and the books reopen and
verify at the end. Shown to have teeth: removing the create_entry
rehearsal makes it fail and shrink to open-period, close, draft.

Engine (spec confirmed by the owner, 2026-10-13), Ledger::check_reversal,
a pure pre-check that reverse_entry and the store share:
- an entry is reversed at most once (EntryAlreadyReversed); reversing
  the reversal re-instates it;
- a closing entry is never reversed (ClosingEntryIrreversible);
- a refused reversal leaves no trace (it used to leave a draft and
  burn an id).

Specs go in doc comments as a ```text GOAL/INVARIANTS/SCENARIOS block.
New dev dependency: proptest for ledger-store (already the workspace's
property library).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 11:40:50 -06:00
swaitsandClaude Opus 5.5 2d36fb05a4 feat(core): prove the accounting laws (Kani + Lean); fix three engine gaps
The money arithmetic every invariant rests on now lives in one kernel,
ledger-core/src/kernel.rs: signing a line, totals, net, per-account
movements, reversal, normal balances, trial-balance columns, closing
lines, and the rounding plug for member splits. It is generic over a
checked `Amount` trait. The engine runs it on Decimal (validate,
movements, reverse_entry, account_balance, close_period_to, allocate,
trial_balance): there is one copy of the arithmetic, and it is the one
under proof.

Three layers, docs/verification.md maps every law to its proofs:

- Kani (src/proofs.rs): 17 harnesses prove the kernel at i64 for every
  input up to 4 lines over 3 accounts. An entry is accepted exactly
  when it is two or more positive, in-range lines whose debits equal
  credits; accepted entries net to zero; grouping by account loses
  nothing; a reversal mirrors and cancels its original; trial-balance
  columns balance iff the books net to zero; closing lines zero their
  accounts and a close balances however the net is split; the plug
  makes parts sum to the whole. No panics, no overflow. kani::cover!
  shows the proofs are not vacuous.
- Lean 4 (verify/lean): 24 headline theorems for books of any size. The
  ledger is a state machine (add account, draft, post, reverse, open,
  close), and in every reachable state the books net to zero, the trial
  balance balances (as of any date), and the accounting equation holds.
  Posting only appends ("posted is forever"), closed periods are
  frozen, a close zeroes revenue and expenses and credits exactly the net
  income, and period statements add up. Audit.lean shows standard axioms
  only, no sorry.
- Bridge (tests/kernel_bridge.rs): the Decimal and i64 kernels agree.

What the proofs found (each one missed by every test, fuzzer, and
mutant):

- The engine accepted negative line amounts; only MCP refused them. Now
  NegativeAmountLine: direction is the side's job.
- Summing lines near Decimal::MAX panicked. Lines are now capped at one
  quadrillion and ten decimal places (AmountOutOfRange), which keeps
  every realistic sum exact, and the kernel's sums are checked.
- FX conversion (foreign_amount * fx_rate) could panic the same way; it
  is now a checked multiply.
- Kani itself: 0.68 mis-models symbolic-length copy_from_slice
  (model-checking/kani#4904) and reported a reversal that doesn't net to
  zero. The counterexample, replayed natively, passes, and a 15-line
  standalone crate reproduces the bug. Harnesses avoid bulk copies, and
  a should_panic canary will flag the upstream fix.

`just prove` runs it all (Kani memory- and time-capped, fails on an
unsatisfiable cover or a non-standard Lean axiom). AGENTS.md now makes
formal coverage part of every core change.

Also, docs: GnuCash is *an* oracle, never *the* oracle (the owner). The
spec is accounting principles, proved. Golden files are recomputed from
principles. External implementations (GnuCash, the old Sheet, QBO/Xero)
are cross-checks whose disagreements get adjudicated, never obeyed.
docs/oracle.md gains "Who is right"; AGENTS, README, architecture,
roadmap, parity, and STATUS are reworded to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 11:12:13 -06:00
swaitsandClaude Opus 5.5 dd0b0fa69a build: pin every dev tool in mise.toml
Rust (with clippy and rustfmt), just, Node (for deploys), Kani, Lean,
cargo-mutants, and cargo-fuzz, so `mise install` is the whole bootstrap
and nothing gets installed by hand. Lean is 4.34.1, the newest release
past mise's minimum release age; verify/lean/lean-toolchain matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 11:11:56 -06:00
swaitsandClaude Opus 5.5 587b445c78 gui: Save PDF on the general ledger too
The general ledger draws its own per-account tables, so it had no PDF
button. It now has one: each account a section, a line per posting
(date, entry, description, the other side) with debit, credit, and
running balance, its beginning balance, and totals with the ending
balance, typeset through the same statement template.

The PDF plumbing (button, status, background job) is shared by both
paths now. The ledger's rows also measure and clip their text and
scroll sideways in narrow windows, like every other table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:21:20 -06:00
swaitsandClaude Opus 5.5 0f74bb3956 Cargo.lock: ledger-gui depends on ledger-import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:14:45 -06:00
swaitsandClaude Opus 5.5 b582d3d8a0 gui: Save PDF on every report; short and narrow windows hold up
Reports get a Save PDF button beside Copy for a spreadsheet. It typesets
the report as it's set up on screen (range, months/quarters/years,
comparison) through ledger-import's Typst statement template, the same
one the CPA package uses, in the background, writes it to the Downloads
folder (never the books: the viewer stays read-only), and opens it.

- ledger-import: Statement.percent_from marks percentage columns
  (comparison %, % of income), typeset as 12.5% with no dollar sign;
  wide statements turn the page, and past seven columns set smaller so
  a year of months fits; statement() is public for other callers.
- Compared columns that read the same (Jan 1 - Oct 12 vs a year
  earlier) now say which year, on screen and on paper.
- Short windows: the sidebar's and rail's links scroll above the status
  box instead of under it. The search palette fits the window.
- A test typesets every table-backed report from the demo books,
  plain, by month, and compared (PDF_DIR keeps the files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:14:35 -06:00
swaitsandClaude Opus 5.5 6fb594ed28 gui: every screen lays itself out to fit, down to a 360-point tiling slot
Narrow windows overlapped and clipped: the top bar's crumb, date, and
search ran into each other; big numbers wrapped mid-figure; tables
painted columns over each other; the entry and document panes squeezed
their lists to slivers; anything too wide widened the whole page.

- Below 1000 points the sidebar becomes an icon rail (names, attention,
  and integrity checks in tooltips).
- The top bar lays out right to left: search shrinks to its icon, the
  date drops, the crumb truncates.
- Pages are pinned to the window width with narrower margins when tight,
  so one wide thing can't push everything off screen.
- Tiles (KPIs, cash accounts, documents) fit as many columns as the
  width allows, balanced across rows; KPI figures shrink rather than wrap.
- Painted tables shrink their amount columns, clip labels with an
  ellipsis, then scroll sideways rather than overlap.
- List/detail splits show only the detail when both won't fit.
- Reports: the side list becomes a picker; controls wrap by known width.
- Smaller fixes: chart labels thin out, company facts stack, journal
  timestamps yield, transaction filters become a dropdown.
- Min window size drops to 360x400; the screenshot test covers every
  report, a register, a document, and a journal event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:02:48 -06:00
swaitsandClaude Opus 5.5 3b274ed853 cli: the viewer check is just session-start, hooked for Claude Code and Codex
New books dirs get .codex/hooks.json beside .claude/settings.json; both
SessionStart hooks run one justfile recipe, which reports the viewer's
state per [viewer] open_at_start (and opens it itself for "always").
Codex runs project hooks only once the dir is trusted and the hook
approved, so new --codex says so; AGENTS.md stays the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tpv1gBaepKjQiAVJqejGbA
2026-10-12 19:44:04 -06:00
swaitsandClaude Opus 5.5 db61a60e6a cli: new books dirs get a SessionStart hook that checks the viewer
The viewer offer (open_at_start) lived only in AGENTS.md, so it depended
on the agent remembering it on its first turn. The scaffold's
.claude/settings.json now runs just gui-status at session start and hands
the agent the result plus open_at_start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tpv1gBaepKjQiAVJqejGbA
2026-10-12 19:41:48 -06:00
swaitsandClaude Opus 5.5 72b66b5800 cli: just gui runs the viewer in the background; gui-fg, gui-status, [viewer] open_at_start
The scaffolded justfile now detaches the viewer (log in books-data/gui.log)
and won't open a second one for the same books. AGENTS.md tells the agent
to check gui-status at session start and follow open_at_start
(ask/always/never) in wanderbooks.toml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeBdcyb3YyRkcpicBZH5wh
2026-10-12 19:38:33 -06:00
swaitsandClaude Opus 5.5 f15b4d43ee fix(store): verify no longer chokes on a draft before a posted entry
The SQLite cross-check rebuilt entries by id and assumed no gaps, but
drafts aren't projected until posted: a draft #8 before a posted #9 made
the server refuse to start. Gaps are now held with empty drafts (which no
report counts), and a projection that can't be read back counts as drift
to heal, not a reason to refuse to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 19:09:08 -06:00
swaitsandClaude Opus 5.5 c16899f5d6 feat: books.show — the agent puts things on the viewer's screen
books.show asks the viewer to open an entry, an account register, a
report (dates, month/quarter/year columns, prior-period or prior-year
comparison), a document, a search, or a page. The request lives outside
the books (runtime dir, keyed by data dir, written atomically); the
viewer watches it beside the journal and applies books before
navigating. 66 tools.

Site: the dashboard is the hero again; 'Here's what using it looks like'
shows the session beside the viewer, ending in books.show; the cash page
replaces the repeated overview lower down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 19:03:19 -06:00
swaitsandClaude Opus 5.5 679051ac8f site: dark screenshots; the hero shows a Claude Code session beside the viewer, same entry
The session (an HTML terminal pane, demo company) records the Adobe
receipt; the viewer shows entry #245 with its lines and rendered receipt.
Screenshot harness: SHOT_LIVE labels a fixed-books app as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:56:09 -06:00
swaitsandClaude Opus 5.5 65b97cef52 site, README: the GUI is the viewer; you work by talking to Claude or Codex
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:48:36 -06:00
swaitsandClaude Opus 5.5 96f08ae87f feat: Codex support in wanderbooks new; the site leads with 'free, turn Claude or Codex into a full bookkeeping system'
wanderbooks new --codex registers the books' MCP server with Codex (as
wanderbooks-<dir>) and starts Codex; the scaffolded justfile gains codex
and codex-setup. The site drops 'hire', says free up front, shows
Claude Code or Codex + wanderbooks = a complete bookkeeping system, and
explains MCP and cost in the FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:47:51 -06:00
swaitsandClaude Opus 5.5 3f6b045fc3 fix(gui): needs-attention grows to fit with 'show N more' instead of a hidden inner scroll; receipt wording follows the policy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:39:48 -06:00
swaitsandClaude Opus 5.5 d7f80d29ea fix(gui): recent activity rows clip before the timestamp column; SHOT_HEIGHT for tall screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 18:37:18 -06:00
swaitsandClaude Opus 5.5 1597ab6c76 fix(gui): clip long row text instead of drawing over amounts; drafts aren't payments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:32:50 -07:00
swaitsandClaude Opus 5.5 7e5a617d39 fix: tell the agent the receipt policy the books actually use
The server's instructions hardcoded '$25.00'; they now state the
configured threshold, and a zero threshold reads 'every entry needs at
least one receipt'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:30:35 -07:00
swaitsandClaude Opus 5.5 e9af0046da site: wanderbooks.ai — one-liner, canonical and social tags, Netlify config and deploy recipes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:24:10 -07:00
swaitsandClaude Opus 5.5 061d433f88 feat: the CPA package carries the evidence; the site answers 'why not QuickBooks?'
CPA package: every receipt behind the period's entries with an index,
company documents, the journal as IIF, the 1099 worksheet, and a README;
include_receipts=false for a light package. The scaffolded AGENTS.md
covers receipts from email, switching from QuickBooks/Xero/spreadsheets,
and working with the CPA. GUI: a books copy without a server database
reads 'Read-only copy'. Site: example prompts, QuickBooks comparison,
what it does and doesn't do yet, the CPA section, FAQ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:14:42 -07:00
swaitsandClaude Opus 5.5 2a976e68a2 feat: end-to-end integrity checks with self-healing; website; installer and Windows fixes
Store::verify compares the whole SQLite projection, every table row for
row, against a fresh projection of the journal; verify_and_heal rebuilds a
drifted database (keeping the old file) at every server start and via the
new books.verify tool, which also re-hashes every blob. The GUI checks the
server's database read-only and every stored file, and shows the result.
rebuild() also drops stale WAL/SHM files.

site/: the wanderbooks website (just site, just publish). install.sh:
OS detection (Windows points to WSL2), C compiler and git checks,
WANDERBOOKS_REPO. Windows: no directory fsync off Unix; .exe-aware lookup.
Repo justfile: check, build, install, demo, site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 17:05:05 -07:00
swaitsandClaude Opus 5.5 07f5b8e154 feat: one-command onboarding — wanderbooks new, install.sh, justfile
wanderbooks new DIR installs the server and GUI if missing, writes the
books dir (AGENTS.md + CLAUDE.md, wanderbooks.toml, justfile, .mcp.json,
.claude/settings.json approving the server), commits, and starts claude
onboarding. install.sh: Rust + cargo install + wanderbooks new in one
line. The tax summary labels its net when accounts are uncategorized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:29:29 -07:00
swaitsandClaude Opus 5.5 c09325ef8c feat: ledger-gui — a read-only, live desktop view of the books
egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-12 16:20:14 -07:00
swaitsandClaude Opus 5.5 bc3ab779e7 feat: 1099 tracking — vendor forms, payment tags, year-end worksheet
Vendors carry form_1099 (NEC/MISC) and tin_on_file (never the TIN).
Entries carry the vendor they paid; bills, bill payments, and reversals
are tagged automatically. books.vendor_1099 counts cash paid per vendor
per calendar year (card payments excluded) against $600 through 2025 and
$2,000 from 2026, or an override, and flags missing W-9s. Four new tools
(64), two journal events, schema v7. Docs: finished R-FUTURE work is no
longer listed as future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:18:32 -07:00
swaitsandClaude Opus 5.5 d519f8a0d8 feat: payout gross-up (books.record_payout)
A platform deposit becomes a draft entry with the deposit, fees,
adjustments, and gross revenue (by class), refused unless gross - fees
+/- adjustments equals the deposit to the cent. The payout statement is
attached as its receipt before posting. 60 tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:12:38 -07:00
swaitsandClaude Opus 5.5 75623afd46 feat: prepaid/deferred amortization schedules and recurring entries
Amortization schedules spread a prepaid expense or deferred revenue over
the months it covers, to the cent, posting on month ends; runs preview
first, are idempotent, honor closed periods, and are exempt from the
receipt policy like depreciation. Recurring entries generate drafts only,
never posts. Six new tools (59 total); four new journal events, replayed
and checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:09:43 -07:00
swaitsandClaude Opus 5.5 4144d889eb feat: entity-aware owner equity — chart templates, draws, distributions, dividends
Every business has owners, and how owners put money in and take it out
depends on what the business is. wanderbooks now knows the four common
US shapes:

- books.chart_template {entity_type, owners}: a starter chart for a sole
  proprietorship (owner's capital and draws), partnership or
  multi-member LLC (capital and distributions per partner, guaranteed
  payments), S corporation (stock, APIC, shareholder distributions,
  officer compensation, retained earnings) or C corporation (dividends,
  income tax expense), plus common income and expense accounts with
  tax categories, and the wanderbooks.toml settings that go with it
  (tax form, members with equal-share placeholders that sum to exactly
  100, close target). A proposal for the agent to review with the human.
- Closing: CloseTo is now RetainedEarnings { sweep }, Account { into,
  sweep } (a sole proprietor's capital) or Members([MemberClose { capital,
  share, distributions }]). Sweeps zero owner-pay equity accounts into
  their target at close: draws into owner's capital, dividends or
  distributions into retained earnings, each partner's distributions
  into their own capital. Validation (equity, distinct, shares sum to 1)
  runs in the pre-close preview, before anything is journaled. The
  journal records the target; journals with no target or the old
  "retained_earnings" string still replay.
- Members' equity: a partner's distributions account is part of their
  capital; its activity reports as distributions.
- Config: members' distribution_account; [books] close_into and
  close_sweep for single-owner businesses (rejected alongside members).
  The cash flow names members on their distribution accounts too.

Tests: a sole proprietor (net 2,000 and 500 of draws into capital: 6,500,
the cash); a corporation (dividends 300 into retained earnings: 600); a
partnership with distribution accounts (Bob 100 + 25 - 20 = 105, swept at
close, February begins there); bad sweep accounts; templates round-trip
through the chart parser, unique codes, equity per entity, every tax
category valid for the entity's return; stdio: template -> settings ->
import -> postings -> close with the exact closing lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:01:31 -07:00
swaitsandClaude Opus 5.5 e6c49d6650 feat: tax categories and the tax summary report
wanderbooks is a general MCP-first accounting application: every
business needs its accounts mapped to where they land on its income tax
return, and its CPA needs book-to-tax differences surfaced, not hidden.

- ledger_core::tax: a catalog of stable categories (gross_receipts,
  commissions_fees, meals, guaranteed_payments, officer_compensation,
  nondeductible, ...) with kind (income, cost of goods, deduction), the
  returns each appears on (Schedule C, 1065, 1120-S, 1120), a default
  deductible percent (meals 50, nondeductible 0), and guidance. Keys,
  not line numbers: form lines move from year to year.
- Accounts carry tax_category and an optional deductible_percent
  override, validated against the account type (and the return, at the
  MCP layer), journaled as account.tax_set, replayed, projected (schema
  v6).
- tax_summary(start, end, form): the P&L grouped by category, book and
  deductible amounts (half away from zero to the cent), contra income
  (refunds) reducing receipts, and every account still uncategorized.
- MCP: books.tax_categories (filtered to the configured return),
  books.set_tax_category, a tax_summary report in every format and in
  the CPA package, chart output with categories, and a fifth
  tax_category column in chart CSV imports, validated before anything is
  created. [company] tax_form, or derived from tax_classification.

Scope stays book basis: no tax depreciation, basis, or filing.

Tests: a hand-computed quarter (50% meals 333.33 -> 166.67, penalties at
0%, refunds as negative receipts, guaranteed payments, one uncategorized
account; book net 5,276.67 vs tax 5,483.33); overrides; category/account
/return validation; form parsing and derivation; CSV parsing; stdio:
filtered catalog, mapping, refusal, summary, all-or-nothing import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:53:56 -07:00
swaitsandClaude Opus 5.5 754060ccf0 fix: period close sweeps only through the period end; partnerships close into members' capital
The close zeroed every revenue and expense account's all-time movement,
so closing a period after later entries were posted swept those too:
close January in March and February's and March's income landed in
January's closing entry. The preview had the same bug and never showed
where the net went. Now:

- close_period_to(id, to) zeroes balances as of the period end; entries
  dated later stay open for their own period. Periods must close in
  order (LedgerError::CloseOutOfOrder): a later close has already swept
  the earlier period.
- CloseTo::Members: for a partnership (multi-member LLC) the net goes to
  each member's capital account by share, to the cent, the last member
  absorbing rounding, which is what K-1 capital accounts track.
  CloseTo::RetainedEarnings (3999) stays the default; close_period is
  shorthand for it. books.close_period picks Members when
  wanderbooks.toml lists members.
- The journal records the close target; replay re-derives the closing
  entry and now compares every line with the journal, not just the id,
  so a change to closing math can never silently rewrite history.
- The tool's preview is the real close run on a clone of the ledger
  (Ledger now derives Clone), so preview and post can't disagree.
- Members' equity: closing entries into a capital account are income,
  not contributions. Balance sheet for a partnership: capital accounts
  (with allocated earnings), then only unallocated earnings —
  "Undistributed earnings, prior years" if any, and this year's unclosed
  net income (loss). periods::allocate is shared by the close and the
  statement.

Tests: January closed after February is posted sweeps exactly Dr 4000
2,500 / Cr 6100 800 / Cr 3999 1,700 (mutation-checked: the old code gives
3,500/120/800/2,580); out-of-order close refused; a partnership close
splits 666.67 into 333.34/333.33 with no 3999, and the members' equity
statement reads correctly before and after; store replay of a members
close (reopen, rebuild, verify); partnership and corporate balance sheet
layouts; stdio close preview equals the posted lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:39:17 -07:00
swaitsandClaude Opus 5.5 f8de584254 docs: more ledger-gui design references
Records three more dashboard references the owner shared: the QuickBooks
Online dashboard cards, KPI tiles with period-over-period change, and a
cash-flow dashboard (runway, forecast, A/P and A/R aging, burn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:31:08 -07:00
swaitsandClaude Opus 5.5 cfabd7cad6 feat: canonical report set in QuickBooks-style presentation
Every report a CPA expects, laid out the way US small-business books
read (QuickBooks as the reference):

- Exports: books.export gains general_ledger (by account, running
  balances, splits), general_journal (the entry-ordered listing, which
  the CPA package used to mislabel "general ledger"; alias journal),
  members_equity, and trial_balance as of a date, in xlsx/csv/json, and
  PDF for all five statements (P&L, balance sheet, cash flow, trial
  balance, members' equity). books.trial_balance takes as_of.
- CPA package: every statement as PDF and XLSX, plus the general ledger
  and general journal, all for the fiscal year to date.
- Layout (ledger-import::report): multi-column statements (Total;
  Debit/Credit; one column per member plus Total), "$" on subtotals and
  totals, a rule above every sum (none over an empty section), a double
  rule under statement totals, zero as 0.00, negatives in parentheses
  with the closing paren hanging so decimal points align.
- Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity",
  member contributions and distributions named in the cash flow, and the
  balance sheet splitting retained earnings (the Retained Earnings
  account plus earnings not yet closed from earlier years) from the
  fiscal year's net income (loss); the cash flow's "Adjustments to
  reconcile net income (loss) to net cash from operating activities" and
  "Net increase (decrease) in cash".
- PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1,
  license in fonts/; from the official v4.1 release), centered title
  block, column-header row, full-width rules, footer "Accrual basis ·
  Prepared <date> · Journal seq N" with page numbers. Amounts on total
  rows stay regular weight so bold figures can't leave the decimal
  column. typst-assets dependency removed.
- XLSX: centered title block, header row, Excel accounting formats with
  "$" attached on sums, general ledger and general journal workbooks.
- Config: wanderbooks.toml [[company.members]] (name, share "50%" or
  "0.5", capital_account; shares must total 100%) and [books]
  accounting_basis (accrual | cash).
- Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses.
- Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow,
  so reports defaulting to "through today" included the next day and
  footers carried the wrong date.

Tests: layout (currency and rule placement, retained earnings vs. this
year's net income for a member LLC, footer text); exact template data;
deterministic PDFs; config parsing (members, shares, basis); stdio:
trial balance as of, February general ledger (beginning 1,004.00, splits,
ending 1,003.50), members' equity (contributions 1,000.00, net income
3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:29:12 -07:00
swaitsandClaude Opus 5.5 353bef1d5d feat: general ledger by account, trial balance as of, members' equity
Three reports a CPA expects that the engine could not produce:

- general_ledger(start, end): the canonical general ledger, organized by
  account in code order. Each account has its beginning balance, every
  posting in the period (date, entry, description, debit, credit, running
  balance on the normal side) and its ending balance, which ties to the
  trial balance. Each posting names its split: the one account on the
  opposite side of its entry, or "-Split-" when that side has several,
  so a compound entry (one debit, several credits) reads correctly from
  every account it touches. The entry-ordered listing the exports called a
  "general ledger" is really the general journal.
- trial_balance_as_of(as_of): a trial balance is always as of a date.
- members_equity(start, end, members): the statement of changes in
  members' equity for a multi-member LLC taxed as a partnership. Each
  member's capital roll-forward: beginning (capital plus their share of
  undistributed earnings), contributions, allocated net income (loss),
  distributions, ending. Allocation is by ownership share to the cent,
  the last member absorbing rounding, so totals are exact and the ending
  total equals balance-sheet equity; any equity change the roll-forward
  can't explain shows as "other" instead of disappearing. Shares must be
  positive and sum to exactly 1; capital accounts must be distinct
  equity accounts (LedgerError::InvalidMembers).
- CashFlowLine.account: the account a line is about, so renderers can
  name members instead of account codes.

Tests (every expectation computed by hand): trial balance as of Jan 31;
February general ledger with beginning balances, running balances, a
card that nets to zero, an inactive account still listed, and every
ending balance tied to the trial balance; a compound entry's splits from
all three sides; a two-member partnership where 666.67 and 100.01 split
unevenly (333.34/333.33, 50.01/50.00), with and without closing January,
tying to the balance sheet each month; bad member configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 20:16:25 -07:00
swaitsandClaude Opus 5.5 bf970fc1d1 fix: money strings always carry cents
money() printed whatever scale a Decimal happened to have, so an empty
sum came back as "0" while every other amount read "207.68". Amounts now
have at least two places ("0.00", "1000.00"), padded and never rounded:
an exact FX-converted amount with more places keeps them. Negative zero
normalizes to "0.00".

Test: money_always_has_cents_and_never_rounds; three stdio assertions
that pinned the old "0" now expect "0.00".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:38:01 -07:00
swaitsandClaude Opus 5.5 45e167c233 chore: clippy clean across the workspace, all targets
Zero warnings from `cargo clippy --workspace --all-targets`, which is now
a hard rule rather than "clean except pre-existing":

- ChartOfAccounts::add takes the Account it registers instead of eight
  loose parameters (too_many_arguments); Ledger::add_account builds it.
  Same validation, same errors.
- close_period tool: collapse the nested if-let into a let chain.
- iif.rs docs: the IIF layout example shows tabs as `\t` instead of
  literal tab characters (tabs_in_doc_comments).
- Tests: drop an unused binding; assert! instead of assert_eq! against a
  literal bool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:24:10 -07:00
swaitsandClaude Opus 5.5 dc3dc8bd24 feat: Typst-typeset statements in US accounting format
The printpdf statements padded columns with spaces in a proportional font
(amounts didn't line up), had no company name, printed negatives as
"-207.68", and put everything on one page (a long statement was silently
cut off past ~70 lines). Now:

- PDFs are typeset by Typst from one template,
  ledger-import/templates/statement.typ. The laid-out statement goes in as
  JSON data (sys.inputs), so account names are never parsed as markup.
  Fonts are bundled (typst-assets: Libertinus Serif), so output doesn't
  depend on the machine and is byte-for-byte deterministic. Statements
  paginate, with the company/title repeated on later pages and "Page n of
  m" plus "Prepared from journal through seq N" in the footer.
- US accounting presentation, decided once in ledger-import::report and
  applied by both PDF and XLSX: tabular figures; "$" on each section's
  first amount and on every subtotal and total, pinned to the column's
  left edge; negatives in parentheses with the closing paren hanging (its
  width reserved on every row, so decimal points align); zero as a dash;
  a single rule above sums (none over an empty section) and a double rule
  under statement totals; amounts never bold, because bold figures are
  wider and would leave the decimal column. XLSX uses Excel's own
  Accounting formats for the same result.
- Cash flow: "Net change in cash" is a subtotal; only "Cash at end of
  period" takes the double rule.
- Core fix: cash_flow walked the chart in HashMap order, so the order of
  working-capital and financing lines could change between runs. Lines
  now follow account code. Test: five owners, twenty runs, always
  3000/3001/3003/3005/3009; fails without the sort.

New dependencies (ledger-import): typst, typst-layout, typst-pdf,
typst-assets (fonts), typst-as-lib (World/font plumbing), serde_json (the
template's data). Removed: printpdf. Release ledger-mcp grows by the
bundled fonts.

Tests: exact template data for a hand-built P&L (like a golden); same
statement renders to identical bytes; 200 rows of Typst markup
characters render literally and span several pages; currency-sign and
rule placement; amount splitting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:23:01 -07:00
swaitsandClaude Opus 5.5 a0e40690d2 feat: real dates and per-account detail in every statement export
Every statement export rendered life-of-ledger totals under whatever date
range or as-of label was asked for, and the P&L and balance sheet XLSX
were totals-only. Now:

- books.profit_and_loss / books.balance_sheet and the csv, json, xlsx,
  pdf exports and CPA package all use core's profit_and_loss_for_period
  and balance_sheet_as_of, with a line per account. The tools keep their
  totals and add revenue/expense/asset/liability/equity account lines.
  An omitted start is the first posted entry, never "0001-01-01".
- ledger-import::report lays a statement out once (section, line,
  subtotal, total) for every renderer, with one exact amount formatter:
  thousands separators, negatives in parentheses, no floats. XLSX keeps
  numeric cells with an accounting number format, named sheets, company
  header, totals with borders; the trial balance gains its totals row.
- ledger-mcp reads wanderbooks.toml (next to --data-dir, or
  --books-toml): company name for headers, fiscal_year_start for the CPA
  package's fiscal year to date (end derived; Feb 29 rejected), and
  [books] receipt_threshold, which the server had been ignoring.
  config.json's receipt_policy still wins when set. New dependency: toml
  1.x (already in the lockfile via another crate).
- Store::journal_seq(); every report notes the journal seq it reflects.

Tests: company.rs unit tests (fiscal years incl. a July start, scaffold
parsing, bad settings); report.rs formatting tests; an MCP stdio test with
--books-toml (threshold 5.00 enforced, February-only P&L with account
lines, start defaults to the first entry, as-of balance sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:11:36 -07:00
swaitsandClaude Opus 5.5 78ac7099b8 feat: period-scoped P&L and as-of balance sheet; closes don't zero periods
The engine only had life-of-ledger P&L and balance sheet totals, so every
export labeled with a date range or as-of date (PDF, XLSX, CPA package)
printed all-time numbers under that label. Core now has:

- profit_and_loss_for_period(start, end, class?) and
  balance_sheet_as_of(as_of), each with a line per account
  (StatementLine: code, name, normal-side amount) and exact totals.
- Ledger::closing_entry_ids(). Period statements exclude closing entries.
  A close (Dr revenue / Cr expense / net to Retained Earnings, dated at
  period end) moves earnings; it doesn't earn or spend. Counting it made a
  closed period's P&L read as break-even, and made the cash flow show zero
  net income plus a fake "owner contribution" to Retained Earnings. The
  as-of balance sheet keeps closing entries: after a close the earnings
  belong in Retained Earnings, not current earnings.

Tests: tests/statements.rs, a two-month fixture with every expected number
computed by hand (Jan NI 1,700; Feb 880; balance sheets on four dates; a
closed January still reporting 1,700 and a cash flow with one financing
line). Mutation-checked: dropping the closing exclusion fails it (0 vs
1,700). Properties: period P&Ls partition a year at any split, and a
range's net income equals the change in current earnings across it, with
A = L + E on both dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 19:04:18 -07:00
swaitsandClaude Opus 5.5 54590918d3 feat: cash flow statement PDF, pdf export format, cash flow in CPA package
The cash flow statement was computed and exportable as csv/json/xlsx but
missing from every PDF path, so the CPA package carried two of the three
financial statements. Now:

- ledger-import: cash_flow_pdf renders the indirect-method statement
  (operating with net income, depreciation add-back, working-capital
  lines; investing; financing; net change reconciled to beginning and
  ending cash) from the computed CashFlowStatement, so the period and
  cash accounts are the caller's.
- books.export gains format "pdf" for profit_and_loss, balance_sheet, and
  cash_flow (base64, like xlsx), plus financing_accounts for cash_flow.
- books.export_cpa_package adds cash-flow.pdf (fiscal year to date, like
  the P&L) and requires cash_accounts: without them the statement has
  nothing to reconcile to.
- One cash_flow_statement helper resolves codes and dates for xlsx, pdf,
  and the package.

Test: MCP stdio test exports all three statement PDFs, checks the
cash_accounts and bad-report refusals, and unzips the CPA package to find
all five files. Known report gaps are logged in plan/STATUS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:57:12 -07:00
swaitsandClaude Opus 5.5 4ff5d1a40b feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.

- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
  and leaves the receipt filed, unlinked. The receipt policy holds: an
  entry that requires a receipt can't lose its last one (structured
  receipt_required error), so a replacement is attach-new, then
  unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
  attachment.withdrawn for an unlinked receipt or a filed document: hidden
  from list_receipts (unless include_withdrawn), never linkable again. The
  blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
  Before, re-linking silently moved a receipt off a posted entry,
  bypassing the policy; evidence now leaves an entry only through a
  recorded unlink. dispose_asset checks its receipt ids before posting
  instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
  stores migrate on open and replay rebuilds the projection.

Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the the first company books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 18:40:51 -07:00
swaits f19f981bb0 docs: STATUS entry for wanderbooks init 2026-10-12 01:07:13 +00:00
swaits a9bef14536 docs: README quickstart covers wanderbooks init 2026-10-12 01:07:11 +00:00
swaits 025b841268 feat: lockfile for ledger-cli deps 2026-10-12 01:07:07 +00:00
swaits 4362f6d9b2 feat: add ledger-cli to workspace members 2026-10-12 01:07:05 +00:00
swaits 91150ad882 test: init scaffold integration tests (5 green) 2026-10-12 01:07:02 +00:00
swaits 83ccafb448 feat: wanderbooks init scaffolds mise.toml + AGENT.md + books layout 2026-10-12 01:07:01 +00:00
swaits 8d3db46ae1 feat: ledger-cli crate (wanderbooks init binary) 2026-10-12 01:06:59 +00:00
swaits ca5357546c Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:27 +00:00
swaits 2815265ecd Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:26 +00:00
swaits 34eccd5cb9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:24 +00:00
swaits ec74507ed9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:23 +00:00
swaits 4a6c884714 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:21 +00:00
swaits 17efb5250f Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:19 +00:00
swaits 13caf3be79 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:18 +00:00
swaits c23677507f Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:16 +00:00
swaits d64fc88d05 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:14 +00:00
swaits 09cec2ce21 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:13 +00:00
swaits 46894c4c7f Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:11 +00:00
swaits 5b49df3011 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:09 +00:00
swaits 5e6867c015 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:07 +00:00
swaits 1da39ea6e5 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:05 +00:00
swaits 6352921c28 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:03 +00:00
swaits 266545bfb9 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:02 +00:00
swaits cfebb9e8b3 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:32:00 +00:00
swaits 6fe8814597 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:58 +00:00
swaits f97be98a44 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:56 +00:00
swaits c38d843633 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:55 +00:00
swaits 1a3af122c5 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:53 +00:00
swaits b2ae0e94f1 Payroll accounting, class tracking, CPA handoff package (48 MCP tools) 2026-10-11 15:31:51 +00:00
swaits 6bc5ffdafe Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:41 +00:00
swaits fb6419125f Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:38 +00:00
swaits b58f5dc169 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:36 +00:00
swaits ad760b9b80 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:34 +00:00
swaits ec716be076 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:32 +00:00
swaits bb33601fc2 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:30 +00:00
swaits 26199c872e Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:28 +00:00
swaits e0be84ba95 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:25 +00:00
swaits fb4dd2055d Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:24 +00:00
swaits 01a07aed46 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:22 +00:00
swaits 6f077b4cd8 Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:20 +00:00
swaits 01c2689adf Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:18 +00:00
swaits a2b23b5fba Period close, bank rec, AR/AP, multi-currency (41 MCP tools) 2026-10-11 08:15:16 +00:00
swaits daa61b96d8 Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:24 +00:00
swaits 69d5b87b33 Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:22 +00:00
swaits 0e53ab687a Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:20 +00:00
swaits c6ed2009ef Docs: refresh for public repo — rewrite README, update roadmap/STATUS/requirements 2026-10-11 07:35:18 +00:00
swaits 8f614b1ac3 README: remove 'private while it cooks' — repo is now public 2026-10-11 07:32:59 +00:00
swaits c1726d0234 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:55 +00:00
swaits 2fb471a7b9 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:53 +00:00
swaits 9a97834fd9 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:48 +00:00
swaits a0b5616da3 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:45 +00:00
swaits 58ab205617 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:41 +00:00
swaits e051195286 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:33 +00:00
swaits 6db7a99747 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:29 +00:00
swaits 6c4759dcfe Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:24 +00:00
swaits b50f6d2fd3 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:21 +00:00
swaits cbe46ad232 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:18 +00:00
swaits 5f63e521c0 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:11 +00:00
swaits 89f79270b0 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:02:02 +00:00
swaits 82db83e4d3 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:51 +00:00
swaits 5fa2118279 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:44 +00:00
swaits d154c2e80d Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:39 +00:00
swaits abf3c37ba5 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:37 +00:00
swaits 5b4252ca1e Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:32 +00:00
swaits 0348ce653e Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:30 +00:00
swaits 7fbd351dee Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:28 +00:00
swaits 1e9fe7fee5 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:26 +00:00
swaits 1ce4932b9f Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:24 +00:00
swaits f37457b557 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:22 +00:00
swaits 470e663f00 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:20 +00:00
swaits 3c3cab1479 Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:19 +00:00
swaits 52fe31364c Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan 2026-10-11 03:01:17 +00:00
swaits 8ee74eaf32 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:03 +00:00
swaits e41910a5b2 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:02 +00:00
swaits af3dc9a49a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:26:00 +00:00
swaits 22e75f55c3 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:59 +00:00
swaits 51e41baa61 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:58 +00:00
swaits d45ac4dc6e asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:56 +00:00
swaits 979257949a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:55 +00:00
swaits 80209a6ea0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:54 +00:00
swaits a32837f448 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:52 +00:00
swaits fbdad5eced asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:51 +00:00
swaits 3433c7015a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:50 +00:00
swaits b1efe32c13 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:49 +00:00
swaits 084f0b7581 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:48 +00:00
swaits bf61b3bc6c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:46 +00:00
swaits a5c08f2dcc asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:45 +00:00
swaits 848775dd15 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:44 +00:00
swaits e8e18ddfcf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:42 +00:00
swaits 5ad34f9dd3 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:40 +00:00
swaits fe330fcb3f asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:38 +00:00
swaits 42f6c5b1c2 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:37 +00:00
swaits e009811ea5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:36 +00:00
swaits df3b08429e asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:34 +00:00
swaits b16d72635c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:33 +00:00
swaits cfb664c83d asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:31 +00:00
swaits b7f21c602c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:29 +00:00
swaits 4a7fd628f5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:28 +00:00
swaits a287484fb8 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:26 +00:00
swaits c9ae74fcbf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:25 +00:00
swaits 3c82365e87 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:23 +00:00
swaits e8e1c65c86 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:22 +00:00
swaits eaf6538d1a asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:21 +00:00
swaits a8072e9fe1 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:19 +00:00
swaits 08f550d164 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:18 +00:00
swaits 33fe6bf5d0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:16 +00:00
swaits 659eff54cf asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:15 +00:00
swaits 3e6b2b8f92 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:13 +00:00
swaits dd9b6694c0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:12 +00:00
swaits dc3bc3de82 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:10 +00:00
swaits d56b0cf97f asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:08 +00:00
swaits c25694eebc asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:07 +00:00
swaits f1bf3fa7b0 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:06 +00:00
swaits 3e9f764a2c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:04 +00:00
swaits 3b39cd2135 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:03 +00:00
swaits b82a637a75 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:01 +00:00
swaits 33f9c67ea9 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:25:00 +00:00
swaits 8b9ed16fc1 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:58 +00:00
swaits 954ccd55e5 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:57 +00:00
swaits 4f0b5afb0c asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:55 +00:00
swaits 5fcc73a737 asset slice + testing initiative: fixed assets, property/mutation/fuzz testing, docs 2026-10-11 02:24:53 +00:00
swaits 1c0e899f68 test push 2026-10-11 02:24:46 +00:00
swaits 1db9fbc700 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:36 +00:00
swaits 6f99e81de2 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:34 +00:00
swaits ae7b51030a First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:32 +00:00
swaits 2dc66a7b84 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:31 +00:00
swaits f60c5a3a83 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:29 +00:00
swaits 6b55c74368 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:27 +00:00
swaits 1c47233fa9 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:24 +00:00
swaits a6bc773ca1 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:23 +00:00
swaits fe54135cf9 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:21 +00:00
swaits 966d27bdcb First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:19 +00:00
swaits 1819d12c19 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:17 +00:00
swaits c0c37d723b First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:15 +00:00
swaits 0b35794c1f First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:13 +00:00
swaits ef09610c75 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:12 +00:00
swaits 8a7daf50fc First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:10 +00:00
swaits 09038c5848 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:08 +00:00
swaits b9527baa2e First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:07 +00:00
swaits e90b09a6ea First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:05 +00:00
swaits 54fc4f6300 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:03 +00:00
swaits 9d3ce40123 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:37:01 +00:00
swaits ce1cb93440 First slice: ledger-store (journal+blobs+sqlite) and ledger-mcp (18 tools, receipt policy) 2026-10-11 01:36:59 +00:00
swaits 7e7448e319 Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:25 +00:00
swaits d5f12d9ecd Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:23 +00:00
swaits 9f55a2f396 Storage: hash-chained JSONL journal as source of truth, SQLite as disposable projection 2026-10-11 01:16:21 +00:00
swaits ae89634356 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:52 +00:00
swaits e96e625c3e QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:51 +00:00
swaits 7da619724a QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:49 +00:00
swaits 6e002f27d4 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:47 +00:00
swaits 3cb8ab9b35 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:45 +00:00
swaits 58127168a0 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:43 +00:00
swaits 0fe2db2c2e QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:41 +00:00
swaits 4151ac0887 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:40 +00:00
swaits 3c567756d4 QBO formats for import/export; server-side receipt enforcement policy in MCP spec 2026-10-11 01:09:38 +00:00
swaits f40dfd8abc Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:53 +00:00
swaits 88d155a427 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:51 +00:00
swaits 88cabe46c6 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:49 +00:00
swaits d191dcf4f0 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:47 +00:00
swaits 8e08fc5b47 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:45 +00:00
swaits 817cc6d613 Spec: AI-first MCP interface phase (ledger-mcp, receipt store, capability parity) 2026-10-11 01:07:43 +00:00
732 changed files with 345375 additions and 426 deletions

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# Every change: format, clippy and rustdoc with zero warnings, every test.
# Tools come from mise.toml, the same as on a laptop.
name: check
on:
push:
pull_request:
# A newer push to the same branch makes an older check pointless: it is
# cancelled, so only the newest reports.
concurrency:
group: check-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-24.04
# The runner's disk is small (the first run after P6 landed died in
# the linker: "No space left on device", ld killed by SIGBUS). A fresh
# runner gains nothing from incremental caches, and line tables keep
# file:line in a panic's backtrace at a fraction of full debug info.
env:
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
steps:
- uses: actions/checkout@v4
- name: Room for the build (the image ships toolchains we never use)
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
df -h / | tail -1
- uses: jdx/mise-action@v2
- name: GUI build dependencies
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev
- run: just check
- name: What the build used
if: always()
run: df -h / | tail -1
+59
View File
@@ -0,0 +1,59 @@
# The portable builds (plan P11a step 0, D-56): the workspace builds and
# passes its tests on macOS and Windows, not only on Linux. A workflow of
# its own per OS, not a job beside `check` or beside the other OS: GitHub
# serves a job's log only when its whole run has completed, and `check`
# takes over an hour (the Windows suite most of one), so a failure's reason
# was unreadable for that long while it sat in a shared run.
name: portable-macos
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: portable-macos-${{ github.ref }}
cancel-in-progress: true
jobs:
# The destination is one installation for macOS, Windows and Linux (D-56,
# P11a step 0): the workspace must build and pass its tests on each, not
# only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays
# the Linux job's; these run the build and the tests. The job tells
# which OS a failure is on; neither is allowed to hide the other.
portable:
name: portable (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
experimental: false
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
# Same disk discipline as above: no incremental caches, line tables only.
env:
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
# macOS: a Unix socket path holds 104 bytes, and the runner's default
# temp folder (/var/folders/...) plus a test home plus
# `Library/Application Support/wanderbooks/run/` is longer than that;
# the server then refuses the root ("too long for its sockets"). The
# tests' temp homes go under /tmp there. Windows ignores TMPDIR.
TMPDIR: /tmp
steps:
- uses: actions/checkout@v4
- name: Rust (the version mise.toml pins)
shell: bash
run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99
- name: Room for the build
shell: bash
run: df -h . | tail -1
- name: The server-only build (no viewer)
run: cargo build -p ledger-cli --no-default-features
- name: Everything compiles
run: cargo build --workspace --all-targets
- name: Tests
run: cargo test --workspace
- name: What the build used
if: always()
shell: bash
run: df -h . | tail -1
+59
View File
@@ -0,0 +1,59 @@
# The portable builds (plan P11a step 0, D-56): the workspace builds and
# passes its tests on macOS and Windows, not only on Linux. A workflow of
# its own per OS, not a job beside `check` or beside the other OS: GitHub
# serves a job's log only when its whole run has completed, and `check`
# takes over an hour (the Windows suite most of one), so a failure's reason
# was unreadable for that long while it sat in a shared run.
name: portable-windows
on:
push:
pull_request:
workflow_dispatch:
concurrency:
group: portable-windows-${{ github.ref }}
cancel-in-progress: true
jobs:
# The destination is one installation for macOS, Windows and Linux (D-56,
# P11a step 0): the workspace must build and pass its tests on each, not
# only here. `just check` (fmt, clippy, rustdoc, proofs' friends) stays
# the Linux job's; these run the build and the tests. The job tells
# which OS a failure is on; neither is allowed to hide the other.
portable:
name: portable (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
experimental: true
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental }}
# Same disk discipline as above: no incremental caches, line tables only.
env:
CARGO_INCREMENTAL: "0"
CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_PROFILE_TEST_DEBUG: line-tables-only
# macOS: a Unix socket path holds 104 bytes, and the runner's default
# temp folder (/var/folders/...) plus a test home plus
# `Library/Application Support/wanderbooks/run/` is longer than that;
# the server then refuses the root ("too long for its sockets"). The
# tests' temp homes go under /tmp there. Windows ignores TMPDIR.
TMPDIR: /tmp
steps:
- uses: actions/checkout@v4
- name: Rust (the version mise.toml pins)
shell: bash
run: rustup toolchain install 1.99 --profile minimal && rustup default 1.99
- name: Room for the build
shell: bash
run: df -h . | tail -1
- name: The server-only build (no viewer)
run: cargo build -p ledger-cli --no-default-features
- name: Everything compiles
run: cargo build --workspace --all-targets
- name: Tests
run: cargo test --workspace
- name: What the build used
if: always()
shell: bash
run: df -h . | tail -1
+123
View File
@@ -0,0 +1,123 @@
# Releases (plan P11a, D-24, D-26): on a version tag, build the Linux
# packages on the oldest system they claim (ubuntu-24.04, glibc 2.39; a
# program linked on a newer one does not start on an older one), attach
# them to the GitHub release, and add them to the apt and dnf repositories
# on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of
# the unit (the release key, Q-4) signs the manifest and the indexes. The
# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization:
# step 5); the Windows job is unit P11c. The release holds no key today.
# To see the artifacts without a tag: Actions > release > Run workflow,
# pick the branch; each job uploads its files as a run artifact.
name: release
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: write
jobs:
linux:
runs-on: ubuntu-24.04
# Same disk discipline as check.yml: no incremental caches.
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
- name: Room for the build (the image ships toolchains we never use)
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
/opt/hostedtoolcache/CodeQL /usr/local/.ghcup
df -h / | tail -1
- uses: jdx/mise-action@v2
with:
# Only what packaging needs, not the proof and fuzz tools.
install_args: "rust just cargo:cargo-packager cargo:cargo-generate-rpm"
- name: GUI build dependencies
run: sudo apt-get update && sudo apt-get install -y libxkbcommon-dev libgtk-3-dev apt-utils createrepo-c
- name: The tag is the program's version
id: version
run: |
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
tag="${GITHUB_REF_NAME#v}"
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
echo "tag $GITHUB_REF_NAME is not version $own" >&2
exit 1
fi
echo "version=$own" >> "$GITHUB_OUTPUT"
- name: Build the packages
run: just package-linux ${{ steps.version.outputs.version }}
- name: Attach them to the release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: |
v=${{ steps.version.outputs.version }}
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.AppImage target/packages/$v/*.deb target/packages/$v/*.rpm target/packages/$v/*.tar.gz
- name: Add them to the apt and dnf repositories (gh-pages)
if: github.ref_type == 'tag'
run: |
v=${{ steps.version.outputs.version }}
git fetch origin gh-pages || true
if git rev-parse --verify origin/gh-pages >/dev/null 2>&1; then
git worktree add repo origin/gh-pages
else
git worktree add --detach repo && git -C repo checkout --orphan gh-pages && git -C repo rm -rfq . || true
fi
mkdir -p repo/apt/pool repo/dnf
cp target/packages/$v/*.deb repo/apt/pool/
cp target/packages/$v/*.rpm repo/dnf/
deploy/packaging/repo-indexes.sh apt repo/apt
deploy/packaging/repo-indexes.sh dnf repo/dnf
cd repo
git config user.name "wanderbooks release"
git config user.email "release@wanderbooks.ai"
git add -A
git commit -m "wanderbooks $v"
git push origin HEAD:gh-pages
- name: Keep them with the run (a dispatch has no release)
uses: actions/upload-artifact@v4
with:
name: linux-packages
path: target/packages/${{ steps.version.outputs.version }}/
macos:
# UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and
# notarizes it. Apple silicon (macos-latest); an Intel build is not
# made yet.
runs-on: macos-latest
env:
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
with:
install_args: "rust just cargo:cargo-packager"
- name: The tag is the program's version
id: version
run: |
own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1)
tag="${GITHUB_REF_NAME#v}"
if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then
echo "tag $GITHUB_REF_NAME is not version $own" >&2
exit 1
fi
echo "version=$own" >> "$GITHUB_OUTPUT"
- name: Build the unsigned .dmg
run: just package-macos ${{ steps.version.outputs.version }}
- name: Attach it to the release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: |
v=${{ steps.version.outputs.version }}
gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v"
gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg
- name: Keep it with the run (a dispatch has no release)
uses: actions/upload-artifact@v4
with:
name: macos-dmg-unsigned
path: target/packages/${{ steps.version.outputs.version }}/*.dmg
+48
View File
@@ -0,0 +1,48 @@
# Weekly, and on demand: the slow layers (docs/testing.md): the proofs,
# the GnuCash oracle and the fuzz targets. Mutation testing is not run in
# CI (owner, 2026-10-29, D-59): it is a local run (`just mutants-*`), and
# P12 runs it whole on the owner's desktop.
name: scheduled
on:
schedule:
- cron: "17 6 * * 1"
workflow_dispatch:
jobs:
prove:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just prove
# The Kani harnesses too slow for prove's 30 minutes on four cores
# (justfile kani_slow; docs/verification.md), on a runner of their own.
prove-slow:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just prove-slow
oracle:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just oracle
fuzz:
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
target: [fuzz_journal_line, fuzz_entry_validate, fuzz_op_sequence, fuzz_core_serde, fuzz_store_open, fuzz_store_replay, fuzz_settings_replay, fuzz_import_csv, fuzz_config_parse, fuzz_extract_pdf, fuzz_extract_xlsx]
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- run: just fuzz ${{ matrix.target }} 600
# A crash's input is the finding: keep it (the 2026-10-26 run found one
# in fuzz_store_replay and kept nothing, so it had to be hunted again).
- if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-${{ matrix.target }}-artifacts
path: fuzz/artifacts
if-no-files-found: ignore
+111
View File
@@ -0,0 +1,111 @@
# The two-account, self-host deployment (plan 8.2 P9a, D-51): the books
# server runs as one account, an agent as another, and the agent reaches
# the books only through the server's HTTP door with a static bearer
# token. `crates/ledger-cli/tests/two_user.rs` is the check; this file
# makes the deployment it needs and runs it against real accounts. A
# laptop's `cargo test` skips those two tests (WB_TWO_USER unset); this
# job sets it, so a skip here is a failure's cousin: the job only goes
# green when both tests ran.
#
# The token is minted as on a real server: `token create` keeps it in a
# 0600 file under the root's run/ (ledger-host tokens.rs INVARIANT 3).
name: two-user
on:
push:
pull_request:
workflow_dispatch:
# A newer push makes an older run pointless: it is cancelled.
concurrency:
group: two-user-${{ github.ref }}
cancel-in-progress: true
jobs:
two-user:
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
WB_PORT: "17411"
WB: /usr/local/bin/wanderbooks
steps:
- uses: actions/checkout@v4
- uses: jdx/mise-action@v2
- name: Build the server-only executable and the test
run: |
cargo build --release -p ledger-cli --no-default-features
cargo test -p ledger-cli --test two_user --no-run
- name: Two accounts, one data root only one can read
run: |
set -euo pipefail
sudo useradd --system --create-home --home-dir /var/lib/wanderbooks \
--shell /usr/sbin/nologin wanderbooks
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/wanderbooks
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/desktop
sudo install -d -o wanderbooks -g wanderbooks -m 0700 /srv/desktop-home
sudo install -m 0755 target/release/wanderbooks "$WB"
- name: The deployment, the move, and the check
run: |
set -euo pipefail
as_books() { sudo -u wanderbooks -H env HOME=/var/lib/wanderbooks "$@"; }
wait_open() {
for _ in $(seq 1 240); do
grep -q "The books are open" "$1" && return 0
sleep 0.5
done
echo "the server did not open:" >&2
cat "$1" >&2
return 1
}
stop_servers() {
as_books "$WB" close --root /srv/desktop >/dev/null 2>&1 || true
as_books "$WB" close --root /srv/wanderbooks >/dev/null 2>&1 || true
}
trap stop_servers EXIT
# The machine the books come from: books of this version, made by
# adopting the repository's fixture (so the move changes no event).
# Its own home, so the two roots are not "somewhere else" to one
# another (root::setup records one root per account home).
as_books "$WB" setup --root /srv/desktop --home /srv/desktop-home
as_books "$WB" serve --root /srv/desktop > /tmp/desktop.log 2>&1 &
wait_open /tmp/desktop.log
sudo cp -a crates/ledger-cli/tests/fixtures/p2-served /tmp/fixture
# The server account adopts it, so it must own the copy: adoption
# takes the source's lock (root.rs lock_source), which the runner's
# ownership from the checkout refused it (the job's first runs).
sudo chown -R wanderbooks:wanderbooks /tmp/fixture
as_books "$WB" books adopt /tmp/fixture --root /srv/desktop --id acme --yes
as_books "$WB" close --root /srv/desktop
# The backup clone that is the transport (plan D-31): owned by the
# server account, which adopts it (and locks it), and readable by
# the agent account, because the test reads its journal to know
# the head the move must keep.
sudo cp -a /srv/desktop/books/acme /tmp/source
sudo chown -R wanderbooks:wanderbooks /tmp/source
sudo chmod -R a+rX /tmp/source
# The server's machine: the same books, moved, behind `enforced`.
as_books "$WB" setup --root /srv/wanderbooks
as_books "$WB" serve --root /srv/wanderbooks --isolation enforced \
--http "127.0.0.1:$WB_PORT" > /tmp/tenant.log 2>&1 &
wait_open /tmp/tenant.log
as_books "$WB" books adopt /tmp/source --root /srv/wanderbooks --id acme --yes
as_books "$WB" books create tunnel --root /srv/wanderbooks
# Tokens for the agent account (the test), kept in the root's run/.
as_books "$WB" token create --root /srv/wanderbooks --book acme --name ci >/dev/null
TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book acme \
--name ci)
as_books "$WB" token create --root /srv/wanderbooks --book tunnel --name ci >/dev/null
WRITE_TOKEN=$(as_books "$WB" token print --root /srv/wanderbooks --book tunnel \
--name ci)
# The check, as the agent account, over the loopback door.
WB_TWO_USER=1 \
WB_TENANT_ROOT=/srv/wanderbooks \
WB_SERVER_URL="http://127.0.0.1:$WB_PORT" \
WB_BOOK=acme \
WB_TOKEN="$TOKEN" \
WB_WRITE_BOOK=tunnel \
WB_WRITE_TOKEN="$WRITE_TOKEN" \
WB_SOURCE_DIR=/tmp/source \
cargo test -p ledger-cli --test two_user -- --nocapture --test-threads=1
+6 -1
View File
@@ -1,4 +1,9 @@
/target/ /target/
Cargo.lock
*.orig *.orig
.DS_Store .DS_Store
site/dist/
.netlify/
# cargo-mutants output (just mutants*)
/mutants.out*/
/mutants-*/
# bookspec's own workspace target
+107 -10
View File
@@ -12,9 +12,13 @@ what they haven't demonstrated.
semantics, built to become a full bookkeeping application (engine now, UI semantics, built to become a full bookkeeping application (engine now, UI
later). The reference points: later). The reference points:
- **Semantic oracle:** GnuCash. It is open source with open file formats - **Spec:** accounting principles, written down as proofs
(XML/SQLite), which makes it both the spec and the reference implementation (`docs/verification.md`). That is the only authority.
we diff against. See `docs/oracle.md`. - **Cross-check oracles:** GnuCash (open source, open file formats), the
old Google Sheet, QBO/Xero exports. Useful to diff against, *never*
trusted to be right: a disagreement is adjudicated from principles, and
nothing may rest on another program's behavior alone. See
`docs/oracle.md`, "Who is right".
- **Workflow reference:** QuickBooks Online and Xero — for *what the UI must - **Workflow reference:** QuickBooks Online and Xero — for *what the UI must
do*, never for code. We observe behavior; we write our own implementation. do*, never for code. We observe behavior; we write our own implementation.
- **Method reference:** storytold/ArtCraft's app-cloning pipeline - **Method reference:** storytold/ArtCraft's app-cloning pipeline
@@ -37,6 +41,10 @@ later). The reference points:
IRS publications for tax-adjacent logic later. GnuCash itself is GPL — IRS publications for tax-adjacent logic later. GnuCash itself is GPL —
read its docs, never copy its code. This repo is MIT OR Apache-2.0. read its docs, never copy its code. This repo is MIT OR Apache-2.0.
Never disassemble or copy from proprietary software. Never disassemble or copy from proprietary software.
6. **Accounting laws are proved, not just tested.** Arithmetic that money
flows through lives in `kernel.rs` and carries Kani proofs; the laws
it serves carry Lean proofs. `just prove` stays green. See
"Formal methods" below.
## Verification protocol (read this twice) ## Verification protocol (read this twice)
@@ -60,6 +68,76 @@ numbers**. The analog of storytold's screenshot-in-the-loop:
- The randomized invariant test (`random_balanced_journals_preserve_invariants`) - The randomized invariant test (`random_balanced_journals_preserve_invariants`)
is the always-on property oracle. Extend it when you add invariants. is the always-on property oracle. Extend it when you add invariants.
## Formal methods: the bar (read this three times)
Tests check the cases someone thought of; proofs check all of them. The
first formal pass on this engine (2026-10, `docs/verification.md`) paid
for itself several times over, on code that already had 190+ tests,
property tests, mutation testing, and 11.5M fuzz executions:
- **Negative line amounts were accepted by the engine.** Only the MCP
layer refused them. Any other caller could post them.
- **A large enough amount crashed the engine** (a `Decimal` overflow
panic while summing lines) instead of returning an error.
- **Currency conversion could crash** the same way.
- **The verifier itself had a soundness bug.** Kani 0.68 mis-models
`copy_from_slice` of symbolic length (model-checking/kani#4904): it
reported a reversal that doesn't net to zero. We didn't wave it away.
Replaying the counterexample natively, then shrinking it to a 15-line
crate, proved it was the tool, not the books. Now a canary harness
watches for the fix, and covers prove no proof is vacuous.
- **Writing the spec simplified the code.** `credit_line` and
`closing_line` turned out to be the same function. Lean also proved
that no draft can exist in a closed period, the invariant that lets
the store journal a post before applying it.
So the bar, for every agent, every change:
0. **Specs come from authorities, not memory.** Every rule the engine
enforces is in `docs/spec/rules.md` with its source: Pacioli for the
mechanics of double entry, the IRS (vendored verbatim, public domain)
for records, periods, 1099s, capitalization, partnerships, and payroll,
FASB's Codification (cited, never copied) for GAAP. A rule no authority
states is marked CONVENTION or CHOICE (bookspec's word for a
decision), with who decided and when.
Spec blocks in the code (```text GOAL/SOURCE/INVARIANTS/SCENARIOS)
carry a `SOURCE:` line. Never write a spec from what the code happens
to do, or from what you remember; find the document, or ask.
1. **Money arithmetic goes in `kernel.rs`, generic over `Amount`.** If
you write a sum, a sign flip, a split, or a rounding plug anywhere
else, move it into the kernel. The engine calls the kernel. It never
gets a second copy.
2. **Every kernel function gets a Kani harness** in `src/proofs.rs`, on
fixed arrays (no symbolic-length `Vec` growth, no `copy_from_slice`),
with a `kani::cover!` showing the interesting case is reached.
3. **Every new law gets a Lean theorem** in `verify/lean`. Laws about
the books over time are theorems about `Reachable` states. A new
`Ledger` command is a new `Step` constructor, with its preservation
proof. No `sorry`, ever; `Audit.lean` lists every headline theorem.
4. **Every kernel instantiation gets a bridge property** in
`tests/kernel_bridge.rs`, so what Kani proved on `i64` holds on
`Decimal`.
5. **A failing proof is a finding, not an obstacle.** Get the
counterexample, replay it natively, and decide on evidence: bug in
our code, bug in the spec, or bug in the tool. Never weaken an
assertion, raise a bound, or delete a harness to make red go green
without writing down why in `docs/verification.md`.
6. **More, not less.** When you touch an area the proofs don't cover
yet (A/R and A/P documents, payroll, depreciation, FX, persistence),
leave it with more formal coverage than you found it. Proptest,
mutation testing, fuzzing, Kani, and Lean all stack. Use all of them.
`just check` and `just prove` both pass before a batch of work touching
`ledger-core` is pushed: once, at the end of the batch, with everything
they find fixed in one pass. Inside a batch (a merge, a fix, a unit's
commits), the check is proportional to the change: the workspace
compiles and the tests the change touched pass. The owner's rule
(plan D-60, 2026-10-29): the first wave's five branches took twelve
hours to land with a full gate around every merge, and the one gate at
the end found the same five tests. GitHub's `check` job runs `just
check` on every push and is the exhaustive gate in the background.
## Parity tracking ## Parity tracking
`docs/parity.md` is the parity catalog: every semantic feature we intend to `docs/parity.md` is the parity catalog: every semantic feature we intend to
@@ -78,15 +156,23 @@ clone, with status. Rules:
`post_entry`, `reverse_entry`, `add_account`). Struct fields stay private; `post_entry`, `reverse_entry`, `add_account`). Struct fields stay private;
no other mutation path may exist. no other mutation path may exist.
- `ledger-core` depends only on `rust_decimal`, `thiserror`, `chrono`, - `ledger-core` depends only on `rust_decimal`, `thiserror`, `chrono`,
`serde`, `serde_json`. New dependencies need justification in the commit `serde`, `serde_json`, and `jiff` (the bundled time-zone database for
message. No I/O, no networking, no time-source in the core. `company.timezone`: "today" is computed by the caller and passed in;
- Future crates (`ledger-store`, `ledger-import`, `ledger-oracle`, `books-app`) the core still reads no clock). New dependencies need justification in
the commit message. No I/O, no networking, no time-source in the core.
- Future crates (`ledger-store`, `ledger-import`, `ledger-reader`, `ledger-oracle`, `books-app`)
depend inward toward the core, never the reverse. See `docs/architecture.md`. depend inward toward the core, never the reverse. See `docs/architecture.md`.
## Working here ## Working here
- Rust stable, edition 2024. `cargo test` must pass; `cargo clippy` should be - `mise install` sets up every tool (pinned in `mise.toml`): Rust,
clean; `cargo fmt` before committing. just, Kani, Lean, cargo-mutants, cargo-fuzz. If a recipe needs a tool,
it goes in `mise.toml`; nothing gets installed by hand.
- Rust stable, edition 2024. `just check` must pass: fmt, clippy with zero
warnings, every test. `just prove` must pass for core changes. Both run
once per batch, before the push, never around every merge or commit
inside one (D-60); the cheap checks (compile, the touched tests, clippy
on the touched crate) run as you go.
- Conventional commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`. - Conventional commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`.
One logical change per commit. The commit message explains the *accounting* One logical change per commit. The commit message explains the *accounting*
reasoning, not just the code. reasoning, not just the code.
@@ -100,13 +186,24 @@ clone, with status. Rules:
| Read this | When you need | | Read this | When you need |
|---|---| |---|---|
| `plan/served.md` | The plan every remaining unit is built from: section 8.0 (status, order, recipes), 8.2 (the units), 10 (decisions) |
| `plan/HANDOFF.md` | The prompt for the implementing team, and how to resume the paused worktrees |
| `docs/requirements.md` | What the engine must do (R-1…), what is future work | | `docs/requirements.md` | What the engine must do (R-1…), what is future work |
| `docs/architecture.md` | Crate layout, command pattern, dependency rules | | `docs/architecture.md` | Crate layout, command pattern, dependency rules |
| `docs/oracle.md` | How we prove correctness: golden files, property tests, GnuCash diffing | | `docs/oracle.md` | How we check correctness: golden files, property tests, fuzzing, cross-checks against other implementations (and who is right) |
| `docs/spec/` | The authorities every rule comes from (`rules.md`), with public-domain sources vendored verbatim |
| `docs/verification.md` | The formal proofs: every accounting law, its Kani harness and Lean theorem, what they found |
| `docs/parity.md` | Feature catalog and the non-regression floor | | `docs/parity.md` | Feature catalog and the non-regression floor |
| `docs/capability-parity.md` | Parity vs the GSheet+GDrive system + the cutover rule |
| `docs/mcp-interface.md` | The MCP tool surface: the primary interface spec |
| `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec |
| `docs/testing.md` | Every test layer, each unit's test record, and what P12 must mutate |
| `docs/parties-and-attachments.md` | The party directory, roles, the TIN vault, attachments to any subject |
| https://git.swaits.com/swaits/bookspec | The formal accounting specification's own repository: README, examples, checker, runner (P13); pinned in `crates/ledger-core/Cargo.toml` |
| `deploy/README.md` | Running the books on a machine of your own (P9a) |
| `docs/glossary.md` | Debit/credit and friends, for agents new to accounting | | `docs/glossary.md` | Debit/credit and friends, for agents new to accounting |
| `plan/STATUS.md` | Where the project stands right now | | `plan/STATUS.md` | Where the project stands right now |
| `plan/roadmap.md` | Phase plan: semantics → oracle harness → egui app → workflow parity | | `plan/roadmap.md` | Where the project has been and is going: the installed app, then the hosted service |
## License ## License
Generated
+9625
View File
File diff suppressed because it is too large. Load diff
+27 -1
View File
@@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/ledger-core"] members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-reader", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host", "crates/ledger-package"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
@@ -9,3 +9,29 @@ repository = "https://git.swaits.com/swaits/wanderbooks"
[workspace.lints.rust] [workspace.lints.rust]
missing_docs = "warn" missing_docs = "warn"
# `cargo kani` sets cfg(kani) for the proof harnesses.
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(kani)"] }
# Argon2id at the plan's 64 MiB, three passes, takes seconds unoptimized:
# its crates are built optimized in debug and test builds too.
[profile.dev.package.argon2]
opt-level = 3
[profile.dev.package.blake2]
opt-level = 3
# `wanderbooks update` hashes and verifies a whole program (P11a); its
# tests do it over a debug build's hundreds of megabytes.
[profile.dev.package.sha2]
opt-level = 3
[profile.dev.package.minisign]
opt-level = 3
[profile.dev.package.minisign-verify]
opt-level = 3
# The program that ships in the packages (P11a): symbols stripped, so the
# AppImage, .deb and .rpm are not carrying debug tables.
[profile.release]
strip = "symbols"
+125 -27
View File
@@ -1,50 +1,148 @@
# wanderbooks # wanderbooks
Canonical double-entry bookkeeping, rebuilt from scratch — the storytold way: Canonical double-entry bookkeeping, rebuilt from scratch. The engine is
an engine verified against oracles, with automated parity tracking, on its verified against oracles, parity is tracked automatically, and the primary
way to becoming a self-hosted bookkeeping app that never charges rent. interface is AI-first: you talk to an agent, the agent drives the books
through MCP. Self-hosted, no rent.
Private while it cooks. ## What it is
- **`ledger-core`** — the semantics engine. Exact decimal money, five
account types, balanced journal entries, immutable posted log with
reversing entries, trial balance, P&L, balance sheet, the statement of
cash flows (direct method, with the reconciliation and noncash
schedules), fixed-asset register with straight-line depreciation.
- **`ledger-store`** — durable storage. The source of truth is an
append-only, hash-chained JSONL journal; SQLite is a disposable
projection rebuilt from it; receipts live in a content-addressed blob
store. Drafts are durable, corrupt projections self-heal.
- **`ledger-mcp`** — the AI-first interface. 122 MCP tools (2026-10-28), served by the
books server (`wanderbooks server`) on the data root's local sockets and
reached through the agents' bridge (`wanderbooks mcp --book ID`): draft →
preview → post workflow, server-side receipt
policy (>$25), anti-splitting detection, asset management, digest for
human review, plus read-only resources for dashboards.
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
Desktop) and XLSX reports.
- **`ledger-config`** — the company settings in a books dir's
`wanderbooks.toml`.
- **`ledger-gui`** — the viewer: a read-only, live window on the books for owners and their
accountant (egui). Answers first (cash, runway, profit, what needs
attention), then every number drills down to its lines, its entry, the
rendered receipt, and the journal history. Follows the journal live:
a change the agent makes appears in milliseconds. `--demo` opens a
realistic demo company.
## Verification
This is the storytold way applied to accounting: agents verify against
oracles, never ship what they haven't demonstrated.
- About 2,950 tests across the two workspaces (2026-10-29): invariant tests encoding accounting law, golden-file
report snapshots, property tests (proptest), end-to-end MCP tests through
a real books server.
- **Formal proofs of the accounting laws** (`just prove`,
`docs/verification.md`). Kani model-checks the engine's arithmetic
kernel exhaustively within bounds. Lean 4 proves that, after any
sequence of commands, the trial balance balances, the accounting
equation holds, posted entries are forever, closed periods stay
frozen, and a close moves exactly the net income to equity. The first
pass found three engine bugs that every test, fuzzer, and mutant had
missed: negative amounts accepted, and two crashes on oversized
amounts. It also caught a soundness bug in the model checker itself.
- Mutation testing via cargo-mutants, a local run (paused for development
until the P12 max-testing pass, D-33; not a CI job, D-59); fuzzing via
cargo-fuzz (11.5M+ executions, zero crashes), weekly in CI.
- GnuCash is *a* behavioral cross-check, never the authority and never
a code source (clean room, MIT OR Apache-2.0). Accounting principles,
proved, are the spec. Where any other program disagrees, we work out
from principles who is right.
## Status ## Status
Phase 1: the semantics engine. `ledger-core` implements the accounting laws Parity is 12 of 12 against the spreadsheet workflow it replaced
in Rust — exact decimal money, five account types, balanced journal entries, (`docs/capability-parity.md`); the books cut over on 2026-10-20 and the
immutable posted log with reversing entries, trial balance, P&L, and balance Sheet is frozen as of 2026-10-11. The remaining work, its order and its
sheet — with 14 invariant tests and golden-file report snapshots. hand-off are `plan/served.md` section 8.0; the destination is one
installed app for Mac, Windows and Linux (P11), then the hosted service
the plan describes and does not build. Today the agent path runs on
Linux and macOS: tokens live in a 0600 file under the private data root
(D-63); the rest of the host is Unix-only, so Windows is WSL2 only until
its sockets and data root are ported.
See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
[`plan/roadmap.md`](plan/roadmap.md) for where they're going. [`plan/roadmap.md`](plan/roadmap.md) for where it's going.
## Quickstart ## Quickstart
```bash ```bash
cargo test cargo test # whole suite
cargo run -p ledger-gui -- --demo # the viewer, on a demo company
```
New books, one command (installs Rust and wanderbooks if needed, makes the
books' private folder, starts books called `acme` and lets Claude Code reach
them; nothing needs sudo):
```bash
curl -fsSL https://wanderbooks.ai/install.sh | sh -s -- acme
``` ```
That's the whole test suite: validation rejections, immutability, the (Linux and macOS. On Windows run it inside WSL2.)
accounting equation on a fixture company, a 300-entry randomized invariant
test, and the golden oracle test. Already installed, three commands: `wanderbooks setup`, `wanderbooks books
create acme`, `wanderbooks connect claude --book acme` (or `connect codex`).
Then talk to the agent; `wanderbooks gui acme` shows the books, and its
Books menu backs them up, restores, starts new books and closes them. With
no terminal (the AppImage, a menu entry), opening the app with no books
shows a first-run page that does the same three steps (the private folder,
an owner passphrase, the first books) and connects an agent; a panel along
the bottom of the window then runs Claude Code or Codex in the books' own
folder, `~/Documents/Wanderbooks/<id>` (optional: any agent can use the
books).
Updating: an AppImage, or a `wanderbooks` you put somewhere yourself, brings
itself to the newest release with `wanderbooks update` (it checks the
download against our release key, keeps the version it replaces, and the
open books restart onto the new one when idle); `wanderbooks rollback` goes
back, unless a book has since moved to a newer journal format. A `.deb`,
`.rpm` or AUR install is updated by its package manager and `update` says
so. The Linux packages are built here with `just package-linux`
(docs/packaging.md); none is published yet.
## Developing
Every tool the recipes use (Rust, just, Kani, Lean, cargo-mutants,
cargo-fuzz, Node for deploys) is pinned in `mise.toml`:
```sh
mise install
just # lists the recipes: check, build, prove, demo, ...
```
## Layout ## Layout
``` ```
crates/ledger-core/ # the engine: money, accounts, journal, ledger, reports crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
docs/ # requirements, architecture, oracle design, parity, glossary crates/ledger-store/ # journal + SQLite projection + blob store
plan/ # STATUS.md, roadmap.md crates/ledger-mcp/ # MCP server: 129 tools, resources, handbook, receipt policy
AGENTS.md # operating manual for agents working here crates/ledger-import/ # QBO CSV import, IIF/XLSX export
crates/ledger-config/ # wanderbooks.toml company settings
crates/ledger-cli/ # `wanderbooks`: the one executable (every mode)
crates/ledger-host/ # the host, the agents' bridge, the owner's commands
crates/ledger-package/ # the shared read-only view: the CPA package, the invoice PDF, Needs attention
crates/ledger-vault/ # the TIN vault (XChaCha20-Poly1305 under the book's key)
crates/ledger-gui/ # the viewer: read-only, live (egui)
verify/lean/ # the accounting laws as Lean theorems (`just prove`)
# the formal accounting specification lives in its own repository, https://git.swaits.com/swaits/bookspec (pinned dev-dependency, P13)
fuzz/, oracle/ # fuzz targets; the GnuCash differential (`just oracle`)
deploy/ # the self-host recipe and system unit (P9a)
site/ # wanderbooks.ai and install.sh
docs/ # requirements, architecture, oracle, parity, testing, verification, spec, glossary
plan/ # served.md (the plan), STATUS.md, roadmap.md, HANDOFF.md, gui.md
AGENTS.md # operating manual for agents working here
``` ```
## The idea, briefly
storytold/ArtCraft showed that agents can clone full applications with the
right machinery: screenshot-in-the-loop verification, render oracles, and
automated parity floors. `wanderbooks` adapts that pipeline to bookkeeping,
where the oracle is semantic instead of visual — GnuCash (open source, open
formats) is both the spec and the reference implementation we diff against,
and two engines fed the same books must produce byte-identical reports.
Details in [`docs/oracle.md`](docs/oracle.md).
## License ## License
MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`. MIT OR Apache-2.0. See `LICENSE-MIT` and `LICENSE-APACHE`.
+64
View File
@@ -0,0 +1,64 @@
[package]
name = "ledger-cli"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "wanderbooks: the one executable (viewer, books server, host, agents' bridge, owner's commands)."
[[bin]]
name = "wanderbooks"
path = "src/main.rs"
[features]
default = ["gui"]
# The viewer. Off in the server-only build for another machine (plan
# D-22, P9), so a headless machine carries no window libraries.
gui = ["dep:ledger-gui"]
[dependencies]
ledger-host = { path = "../ledger-host" }
ledger-mcp = { path = "../ledger-mcp" }
ledger-store = { path = "../ledger-store" }
ledger-gui = { path = "../ledger-gui", optional = true }
[dev-dependencies]
base64 = "0.22"
chrono = "0.4"
# Every test that drives the books server's tools: the CPA package they
# open, the outputSchemas they check against, the random cases, and the
# database read_only_tools_change_nothing compares by its rows.
jsonschema = { version = "0.58", default-features = false }
libc = "0.2"
proptest = "1"
reqwest = "0.13"
rusqlite = { version = "0.32", features = ["bundled"] }
rust_decimal = "1"
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
ledger-config = { path = "../ledger-config" }
ledger-core = { path = "../ledger-core" }
ledger-package = { path = "../ledger-package" }
ledger-import = { path = "../ledger-import" }
ledger-vault = { path = "../ledger-vault" }
serde_json = "1"
sha2 = "0.10"
tempfile = "3"
zip = "9.0.1"
minisign = "0.10.0"
[lints]
workspace = true
# The .rpm (P11a step 3), built by cargo-generate-rpm from the release
# program: `just package-linux`. Paths are relative to the workspace root.
[package.metadata.generate-rpm]
name = "wanderbooks"
summary = "Double-entry bookkeeping your agent keeps and you can read"
license = "MIT OR Apache-2.0"
url = "https://wanderbooks.ai"
assets = [
{ source = "target/release/wanderbooks", dest = "/usr/bin/wanderbooks", mode = "0755" },
{ source = "deploy/packaging/install-method-rpm", dest = "/usr/share/wanderbooks/install-method", mode = "0644" },
{ source = "deploy/packaging/wanderbooks-static.desktop", dest = "/usr/share/applications/wanderbooks.desktop", mode = "0644" },
]
+569
View File
@@ -0,0 +1,569 @@
//! `wanderbooks`: the one executable (plan D-22). Its modes are the
//! viewer (`gui`), the books server (`server`: the one way books are
//! served, D-44), the host and the agents' bridge (`host`, `mcp`), and the
//! owner's commands (`setup`, `books`, `connect`, `token`, `passphrase`,
//! `serve`, `close`, `doctor`, `upload`, `download`). A build without the
//! `gui` feature (the server-only build for another machine) has no
//! viewer.
//!
//! From nothing to the first post: `wanderbooks setup`, `wanderbooks books
//! create ID`, `wanderbooks connect claude --book ID` (then talk to the
//! agent).
use std::env;
use std::path::PathBuf;
fn print_usage() {
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID]");
eprintln!(
" wanderbooks books list | create ID | adopt DATA-DIR --id ID | restore BACKUP | back-up ID | upgrade ID | put-aside ID DATA-DIR [PATH...]"
);
eprintln!(" wanderbooks connect claude|codex --book ID");
eprintln!(
" wanderbooks token create --book ID | list | revoke --book ID | check --book ID | print --book ID"
);
eprintln!(" wanderbooks mcp --book ID [--token NAME]");
eprintln!(" wanderbooks passphrase");
eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE");
eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER");
eprintln!(" wanderbooks serve | close | doctor");
eprintln!(" wanderbooks update [--check] | rollback");
eprintln!();
eprintln!(" setup make the books' private folder (only your account can open it)");
eprintln!(" gui open the live, read-only view of the books (a book of the data root)");
eprintln!(
" books list, start, adopt, restore, back up or upgrade books (`books --help`)"
);
eprintln!(" connect let Claude Code or Codex reach the books");
eprintln!(" token make, list or revoke an agent's token for books (`token --help`)");
eprintln!(" mcp an agent's way to the books, over stdio (what `connect` registers)");
eprintln!(" passphrase set or change the owner passphrase (typed at a terminal)");
eprintln!(" upload send a file for an upload an agent began (books.begin_upload)");
eprintln!(" download save a copy of a stored file or of one the books handed over");
eprintln!(" serve hold the books open until Ctrl-C");
eprintln!(" close close the books now: everything holding them lets go");
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
eprintln!(" update bring this program to the newest release (`rollback` goes back)");
}
/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone
/// word names the book of the data root to show (`--book`), never a
/// folder; nothing, the root's book; anything else is the viewer's own
/// flags, passed as they are.
#[cfg(feature = "gui")]
fn gui_args(args: Vec<String>) -> Vec<String> {
match args.as_slice() {
[book] if !book.starts_with('-') => vec!["--book".into(), book.clone()],
_ => args,
}
}
/// The viewer's owner (ledger-gui owner.rs): its Books menu, done as the
/// command line does it (the books server, the agents' own commands).
#[cfg(all(feature = "gui", unix))]
struct ViewerOwner(ledger_host::commands::Common);
#[cfg(all(feature = "gui", unix))]
impl ledger_gui::owner::Owner for ViewerOwner {
fn close_books(&self) -> Result<String, String> {
ledger_host::commands::close_books(&self.0)
}
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
ledger_host::commands::new_books_and_home(&self.0, id, currency)
}
fn back_up_now(&self, book: &str) -> Result<Vec<String>, String> {
ledger_host::commands::back_up_now(&self.0, book, false)
}
fn plan_restore(
&self,
source: &str,
id: Option<String>,
replace: bool,
) -> Result<ledger_gui::owner::RestorePlan, String> {
let p = ledger_host::commands::plan_restore(&self.0, source, id, replace, false)?;
Ok(ledger_gui::owner::RestorePlan {
source: p.source,
id: p.id,
replace: p.replace,
head: p.head,
lines: p.lines,
})
}
fn restore(&self, plan: &ledger_gui::owner::RestorePlan) -> Result<Vec<String>, String> {
ledger_host::commands::carry_out_restore(
&self.0,
&ledger_host::commands::RestorePlan {
source: plan.source.clone(),
id: plan.id.clone(),
replace: plan.replace,
head: plan.head.clone(),
origin: None,
lines: plan.lines.clone(),
},
)
}
fn connect(&self, agent: ledger_gui::owner::Agent, book: &str) -> Result<Vec<String>, String> {
let agent = match agent {
ledger_gui::owner::Agent::Claude => "claude",
ledger_gui::owner::Agent::Codex => "codex",
};
ledger_host::commands::connect_and_home(&self.0, agent, book)
}
fn first_run(&self) -> ledger_gui::owner::FirstRun {
let s = ledger_host::commands::first_run_state(&self.0);
ledger_gui::owner::FirstRun {
root: s.root,
books: s.books,
passphrase_set: s.passphrase_set,
}
}
fn set_up(&self) -> Result<Vec<String>, String> {
ledger_host::commands::set_up_root(&self.0)
}
fn set_passphrase(&self, new: &str) -> Result<(), String> {
ledger_host::commands::set_first_passphrase(&self.0, new)
}
fn hold(&self, book: &str) {
ledger_host::commands::hold_for_viewer(&self.0, Some(book.to_string()));
}
fn agent_start(
&self,
agent: ledger_gui::owner::Agent,
book: &str,
) -> Result<ledger_gui::owner::AgentStart, String> {
use ledger_gui::owner::{AgentStart, Installer};
let agent = match agent {
ledger_gui::owner::Agent::Claude => "claude",
ledger_gui::owner::Agent::Codex => "codex",
};
Ok(match ledger_host::commands::start_agent(agent, book)? {
ledger_host::commands::AgentStart::Ready {
label,
program,
args,
dir,
env,
} => AgentStart::Ready {
label: label.to_string(),
program,
args,
dir,
env,
},
ledger_host::commands::AgentStart::Missing(i) => AgentStart::Missing(Installer {
label: i.label.to_string(),
command: i.command.to_string(),
page: i.page.to_string(),
}),
})
}
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
ledger_host::commands::upgrade_format(&self.0, book, passphrase)
}
}
/// `wanderbooks gui`: the viewer, in this process. For books of the data
/// root it holds them open while it runs, and its Books menu acts on them.
#[cfg(feature = "gui")]
fn run_gui(args: Vec<String>) -> i32 {
let args = gui_args(args);
#[cfg(unix)]
{
let value = |flag: &str| {
// The last given, as the viewer reads it.
args.iter()
.rposition(|a| a == flag)
.and_then(|i| args.get(i + 1))
.cloned()
};
// Only the viewer's own flags for books of the data root, each with
// its value: anything else (help, a mistake, the demo) holds
// nothing.
let mut known = true;
let mut rest = args.iter();
while let Some(a) = rest.next() {
if !matches!(a.as_str(), "--root" | "--book" | "--today") || rest.next().is_none() {
known = false;
break;
}
}
let root = value("--root").map(PathBuf::from);
let book = value("--book");
if known {
// The viewer asks about a format upgrade in its own window, makes
// the first books when there are none, and starts the agent in
// its dock.
let common = ledger_host::commands::Common {
root: root.clone(),
..Default::default()
};
ledger_gui::owner::set_owner(std::sync::Arc::new(ViewerOwner(common.clone())));
if ledger_gui::book_in_root(root.as_deref(), book.as_deref()).is_ok() {
ledger_host::commands::hold_for_viewer(&common, book);
}
}
}
match ledger_gui::run("wanderbooks gui", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_gui::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// `wanderbooks gui` in a build without the viewer.
#[cfg(not(feature = "gui"))]
fn run_gui(_args: Vec<String>) -> i32 {
eprintln!("error: this wanderbooks is the server-only build: it has no viewer");
2
}
/// `wanderbooks setup [--root PATH] [--home DIR]`: make the books'
/// private folder (plan 3.4): `--root` if given, else
/// `$XDG_DATA_HOME/wanderbooks`, else the platform's default under the
/// account's home (`--home`, else the system's user records). The folder's
/// location is never printed: nothing an agent is shown names it.
fn run_setup(args: Vec<String>) -> i32 {
let mut root: Option<PathBuf> = None;
let mut home: Option<PathBuf> = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let slot = match arg.as_str() {
"--root" => &mut root,
"--home" => &mut home,
"--help" | "-h" => {
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
eprintln!(
" --root where the books' private folder goes (default: your data folder)"
);
eprintln!(" --home the account's home (default: from the system's user records)");
return 0;
}
other => {
eprintln!("unknown argument: {other}");
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
return 2;
}
};
match args.next() {
Some(value) => *slot = Some(PathBuf::from(value)),
None => {
eprintln!("error: {arg} needs a path");
return 1;
}
}
}
let account = ledger_store::root::account_home().ok();
let home = match home.or_else(|| account.clone()) {
Some(home) => home,
None => {
eprintln!("error: {}", ledger_store::root::RootError::NoHome);
return 1;
}
};
if account.as_ref() != Some(&home) {
eprintln!(
"note: --home isn't this account's home, so this account's books server won't \
look there (tests and unusual setups only)"
);
}
// XDG_DATA_HOME is this account's, for this account's home: with
// another home given (tests), it is never read, so it can't aim at
// this account's real books.
let xdg = if account.as_ref() == Some(&home) {
env::var_os("XDG_DATA_HOME").map(PathBuf::from)
} else {
None
};
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
Ok((found, report)) => {
let what = if report.created {
"made"
} else if report.tightened {
"already there; made private again (other users could open it)"
} else {
"already there"
};
println!("The books' private folder: {what}. Only your account can open it.");
if report.recorded {
println!("It isn't in the default place, so where it is was noted for next time.");
}
owner_passphrase(&found)
}
Err(e) => {
eprintln!("error: {e}");
1
}
}
}
/// After setup: the owner passphrase, asked for at a terminal if none is
/// set yet; elsewhere (a script, an agent) only said how to set it.
#[cfg(unix)]
fn owner_passphrase(root: &ledger_store::root::Root) -> i32 {
let auth = match root.auth_dir() {
Ok(dir) => dir,
Err(e) => {
eprintln!("error: {e}");
return 1;
}
};
match ledger_store::passphrase::is_set(&auth) {
Ok(true) => return 0,
Ok(false) => {}
Err(e) => {
eprintln!("error: {e}");
return 1;
}
}
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
println!("No owner passphrase yet: set one in a terminal with `wanderbooks passphrase`.");
return 0;
}
println!(
"Now the owner passphrase: what the books ask you for before anything only you may do."
);
match ledger_host::commands::set_passphrase_at_terminal(&auth) {
Ok(()) => {
println!("The owner passphrase is set.");
0
}
Err(why) => {
eprintln!(
"error: {why}. The folder is made; set the passphrase with `wanderbooks passphrase`."
);
1
}
}
}
#[cfg(not(unix))]
fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 {
0
}
/// `wanderbooks server`: the books server for the data root, on its
/// local sockets (plan 3.9). The host starts it.
#[cfg(unix)]
fn run_server(args: Vec<String>) -> i32 {
match ledger_mcp::local::run("wanderbooks server", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// The modes that open and hold the books (plan 3.9): the host, the
/// agents' bridge, and the owner's commands.
#[cfg(unix)]
fn run_local(mode: &str, args: Vec<String>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
"host" => ledger_host::host::run(&prog, args),
"mcp" => ledger_host::bridge::run(&prog, args),
"serve" => ledger_host::commands::serve(&prog, args),
"close" => ledger_host::commands::close(&prog, args),
"doctor" => ledger_host::commands::doctor(&prog, args),
"token" => ledger_host::commands::token(&prog, args),
"connect" => ledger_host::commands::connect(&prog, args),
"passphrase" => ledger_host::commands::passphrase(&prog, args),
"update" => {
ledger_host::update::update(&prog, ledger_host::update::program_version(), args)
}
"rollback" => ledger_host::update::rollback(&prog, args),
_ => ledger_host::commands::books(&prog, args),
};
match result {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// `upload` and `download`: the agent's file commands, whose words may
/// be paths that aren't text.
#[cfg(unix)]
fn run_files(mode: &str, args: Vec<std::ffi::OsString>) -> i32 {
let prog = format!("wanderbooks {mode}");
let result = match mode {
"upload" => ledger_host::commands::upload(&prog, args),
_ => ledger_host::commands::download(&prog, args),
};
match result {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
Some(exit) => exit.0,
None => {
eprintln!("error: {e}");
1
}
},
}
}
/// The file commands, where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_files(_mode: &str, _args: Vec<std::ffi::OsString>) -> i32 {
eprintln!("error: opening the books this way comes to this system with P11");
2
}
/// The modes that hold the books, where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
eprintln!("error: opening the books this way comes to this system with P11");
2
}
/// `wanderbooks server` where local sockets aren't built yet.
#[cfg(not(unix))]
fn run_server(_args: Vec<String>) -> i32 {
eprintln!("error: the books server's local sockets come to this system with P11");
2
}
/// Whether the program was launched with nothing to say and no terminal
/// to say it to: a menu entry, a double-click, an AppImage (whose launcher
/// runs the desktop file's command and appends what it was given, so the
/// desktop file says plain `wanderbooks`). At a terminal it is the usage,
/// as ever.
fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool {
words.is_empty() && !stdin_is_terminal
}
fn main() {
ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION"));
// A file's path may be any name the system takes: the file commands
// get the words as given. Everything else is text.
let given: Vec<std::ffi::OsString> = env::args_os().skip(1).collect();
if let Some(mode @ ("upload" | "download")) = given.first().and_then(|m| m.to_str()) {
let mode = mode.to_string();
std::process::exit(run_files(&mode, given[1..].to_vec()));
}
let Ok(words) = given
.into_iter()
.map(std::ffi::OsString::into_string)
.collect::<Result<Vec<String>, _>>()
else {
eprintln!("error: an argument isn't UTF-8 text");
std::process::exit(2);
};
// A bare launch is the viewer.
if is_bare_launch(&words, std::io::IsTerminal::is_terminal(&std::io::stdin())) {
std::process::exit(run_gui(Vec::new()));
}
// The modes run in this process and take their own flags.
let mut all = words.clone().into_iter();
match all.next().as_deref() {
Some("gui") => std::process::exit(run_gui(all.collect())),
Some("setup") => std::process::exit(run_setup(all.collect())),
Some("server") => std::process::exit(run_server(all.collect())),
Some(
mode @ ("host" | "mcp" | "serve" | "close" | "books" | "doctor" | "token" | "connect"
| "passphrase" | "update" | "rollback"),
) => {
let mode = mode.to_string();
std::process::exit(run_local(&mode, all.collect()))
}
_ => {}
}
if words.first().is_some_and(|w| w == "--help" || w == "-h") {
print_usage();
return;
}
// The books-folder scaffold is gone (P2 slice 8): said plainly.
if let Some(gone @ ("new" | "init")) = words.first().map(String::as_str) {
eprintln!(
"`wanderbooks {gone}` is gone: books now live in a private folder. Start them with `wanderbooks setup`, `wanderbooks books create ID` and `wanderbooks connect claude --book ID`."
);
std::process::exit(2);
}
// A mode named after a flag: it must come first.
match words
.iter()
.map(String::as_str)
.find(|w| !w.starts_with('-'))
{
Some(
mode @ ("gui" | "setup" | "server" | "host" | "mcp" | "serve" | "connect" | "close"
| "books" | "doctor" | "token" | "passphrase" | "update" | "rollback"
| "upload" | "download"),
) => {
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
std::process::exit(2);
}
_ => {
print_usage();
std::process::exit(2);
}
}
}
#[cfg(all(test, feature = "gui"))]
mod gui_args_tests {
use super::gui_args;
fn v(args: &[&str]) -> Vec<String> {
args.iter().map(|a| a.to_string()).collect()
}
#[test]
fn a_lone_word_names_a_book_never_a_folder() {
assert_eq!(gui_args(v(&["acme"])), v(&["--book", "acme"]));
assert_eq!(gui_args(v(&["."])), v(&["--book", "."]));
assert_eq!(gui_args(v(&[])), v(&[]));
}
#[test]
fn flags_and_anything_else_pass_as_they_are() {
for args in [
v(&["--demo"]),
v(&["--root", "x"]),
v(&["a", "b"]),
v(&["--help"]),
] {
assert_eq!(gui_args(args.clone()), args);
}
}
}
#[cfg(test)]
mod bare_launch_tests {
use super::is_bare_launch;
#[test]
fn only_nothing_at_all_with_no_terminal_is_the_viewer() {
assert!(is_bare_launch(&[], false));
// At a terminal it is the usage, as ever.
assert!(!is_bare_launch(&[], true));
// Anything said is a mode or a mistake, never the viewer.
assert!(!is_bare_launch(&["--help".to_string()], false));
assert!(!is_bare_launch(&["update".to_string()], false));
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a
//! JSON file in `agent_evals/`, played against a real books server through
//! the wire as an agent reaches it. The rule they enforce is the point of
//! the result envelope: **an agent that begins with `books.start` can do
//! its work by calling only what the previous result's `next_steps` named.**
//!
//! A transcript is `{ "setup": [..], "settings": "<toml>", "steps": [..] }`.
//! `setup` calls are made directly (the chart and the books an agent finds
//! when it arrives; they are not the agent's moves). A step is one of:
//!
//! - `{"call": "books.start", "args": {..}}`: only the first step, and
//! only `books.start`.
//! - `{"follow": "books.x", "with": {..}}`: the previous result's
//! `next_steps` must name tool `books.x`; the call is made with that
//! step's `args` and `with` laid over them (what the agent decides: an
//! amount, a date).
//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps`
//! must name that prompt; it is fetched with `prompts/get`.
//! - `{"expect_next_steps_contains": "books.x"}` and
//! `{"expect": {"/json/pointer": value}}` on the previous result.
//!
//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the
//! upload_id of that file, uploaded first as an agent does, and
//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x":
//! "/pointer"}`.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("The result
//! envelope") and plan 4.6.
mod common;
use common::{Books, Wire, answer_of};
use serde_json::{Map, Value, json};
use std::collections::HashMap;
/// A value with `$upload` and `$var` laid in.
fn resolve(w: &mut Wire, vars: &HashMap<String, Value>, v: &Value) -> Value {
match v {
Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => {
let u = &o["$upload"];
let text = u["text"].as_str().expect("an upload has text");
json!(w.upload(
text.as_bytes(),
u["name"].as_str().unwrap_or("file.pdf"),
u["mime"].as_str().unwrap_or("application/pdf"),
u["purpose"].as_str().unwrap_or("receipt"),
))
}
Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars
.get(o["$var"].as_str().unwrap())
.unwrap_or_else(|| panic!("no saved value {}", o["$var"]))
.clone(),
Value::Object(o) => Value::Object(
o.iter()
.map(|(k, x)| (k.clone(), resolve(w, vars, x)))
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()),
other => other.clone(),
}
}
/// Play one transcript; returns the tools it called after `books.start`.
fn play(name: &str, script: &Value) -> Vec<String> {
let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str());
let mut w = books.session("agent-evals");
let mut vars: HashMap<String, Value> = HashMap::new();
for s in script["setup"].as_array().into_iter().flatten() {
let args = resolve(&mut w, &vars, &s["args"]);
let tool = s["call"].as_str().unwrap();
let out =
answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}"));
for (var, pointer) in s["save"].as_object().into_iter().flatten() {
vars.insert(
var.clone(),
out.pointer(pointer.as_str().unwrap())
.unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}"))
.clone(),
);
}
}
let mut last: Option<Value> = None;
let mut called = Vec::new();
for (i, step) in script["steps"]
.as_array()
.expect("steps")
.iter()
.enumerate()
{
let here = format!("{name} step {i}");
if let Some(tool) = step["call"].as_str() {
assert_eq!(
i, 0,
"{here}: only the first step may call without following"
);
assert_eq!(tool, "books.start", "{here}: the first call is books.start");
let args = resolve(&mut w, &vars, &step["args"]);
last =
Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}")));
} else if let Some(want) = step["follow"].as_str() {
let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
let offered = prev["next_steps"].as_array().cloned().unwrap_or_default();
let named = offered
.iter()
.find(|s| s["tool"] == json!(want))
.unwrap_or_else(|| {
panic!(
"{here}: {want} is not in the previous result's next_steps: {:?}",
offered
.iter()
.map(|s| s["tool"].clone())
.collect::<Vec<_>>()
)
});
let mut args: Map<String, Value> =
named["args"].as_object().cloned().unwrap_or_default();
for (k, v) in step["with"].as_object().into_iter().flatten() {
args.insert(k.clone(), resolve(&mut w, &vars, v));
}
let out = answer_of(&w.call_raw(want, Value::Object(args)))
.unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}"));
called.push(want.to_string());
last = Some(out);
} else if let Some(want) = step["follow_prompt"].as_str() {
let prev = last
.as_ref()
.unwrap_or_else(|| panic!("{here}: follow before start"));
assert!(
prev["next_steps"]
.as_array()
.into_iter()
.flatten()
.any(|s| s["prompt"] == json!(want)),
"{here}: prompt {want} is not in next_steps"
);
let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}}));
let text = got["result"]["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}"));
assert!(text.contains("books."), "{here}: {text}");
} else if let Some(tool) = step["expect_next_steps_contains"].as_str() {
let prev = last.as_ref().unwrap();
assert!(
prev["next_steps"]
.as_array()
.into_iter()
.flatten()
.any(|s| s["tool"] == json!(tool)),
"{here}: next_steps lack {tool}: {}",
prev["next_steps"]
);
} else if let Some(expect) = step["expect"].as_object() {
let prev = last.as_ref().unwrap();
for (pointer, want) in expect {
assert_eq!(
prev.pointer(pointer),
Some(want),
"{here}: {pointer} in {prev}"
);
}
} else {
panic!("{here}: not a step: {step}");
}
}
called
}
/// Every transcript follows only what it was told, and ends where it says.
#[test]
fn agent_evals_follow_only_next_steps() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals");
let mut files: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
let names: Vec<String> = files
.iter()
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
[
"draft_awaiting_a_receipt",
"month_end_close",
"onboarding_to_first_post",
"receipt_backed_post",
"reconciliation",
"reversal"
],
"the six evals the plan names"
);
for (name, path) in names.iter().zip(&files) {
let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
.unwrap_or_else(|e| panic!("{name}: {e}"));
let called = play(name, &script);
assert!(!called.is_empty(), "{name} calls nothing");
}
}
@@ -0,0 +1,102 @@
{
"description": "A draft that waits for its receipt, and posts when it arrives.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "Landlord, January",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "40.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "40.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.set_draft_state",
"with": {
"state": "awaiting_evidence",
"note": "the landlord's receipt"
}
},
{
"expect": {
"/state": "awaiting_evidence"
}
},
{
"follow": "books.attach_receipt",
"with": {
"upload_id": {
"$upload": {
"name": "receipt.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 a receipt for rent",
"purpose": "receipt"
}
}
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
@@ -0,0 +1,83 @@
{
"description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
},
{
"call": "books.create_period",
"args": {
"name": "2025-12",
"start": "2025-12-01",
"end": "2025-12-31"
}
}
],
"steps": [
{
"call": "books.start",
"args": {
"focus": "close"
}
},
{
"expect_next_steps_contains": "books.list_periods"
},
{
"follow": "books.list_periods",
"with": {}
},
{
"follow": "books.close_period",
"with": {}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.close_period",
"with": {}
},
{
"expect": {
"/status": "closed"
}
},
{
"expect_next_steps_contains": "books.verify"
},
{
"follow": "books.verify",
"with": {}
},
{
"expect": {
"/status": "ok"
}
}
]
}
@@ -0,0 +1,102 @@
{
"description": "A new book: onboarding to the first posted entry, following only next_steps.",
"steps": [
{
"call": "books.start",
"args": {
"agent": {
"model": "scripted",
"reasoning_effort": "none",
"harness": "agent_evals"
}
}
},
{
"expect": {
"/state": "needs_onboarding"
}
},
{
"expect_next_steps_contains": "books.set_setting"
},
{
"follow_prompt": "onboard"
},
{
"follow": "books.set_setting",
"with": {
"changes": [
{
"key": "company.name",
"value": "Eval Co"
}
],
"decided_by": "Avery Quill"
}
},
{
"expect_next_steps_contains": "books.add_account"
},
{
"follow": "books.add_account",
"with": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"follow": "books.add_account",
"with": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-05",
"description": "Opening capital",
"no_party_reason": "opening balance"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "debit",
"amount": "10.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "3000",
"side": "credit",
"amount": "10.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
},
{
"expect_next_steps_contains": "books.show"
}
]
}
@@ -0,0 +1,96 @@
{
"description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"expect_next_steps_contains": "books.draft_entry"
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "January rent",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "40.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "40.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect_next_steps_contains": "books.attach_receipt"
},
{
"follow": "books.attach_receipt",
"with": {
"upload_id": {
"$upload": {
"name": "receipt.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 a receipt for rent",
"purpose": "receipt"
}
}
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
@@ -0,0 +1,148 @@
{
"description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
},
{
"call": "books.draft_entry",
"args": {
"date": "2026-01-05",
"description": "Opening capital",
"no_party_reason": "opening balance"
},
"save": {
"entry": "/entry_id"
}
},
{
"call": "books.add_line",
"args": {
"entry_id": {
"$var": "entry"
},
"account": "1000",
"side": "debit",
"amount": "10.00"
}
},
{
"call": "books.add_line",
"args": {
"entry_id": {
"$var": "entry"
},
"account": "3000",
"side": "credit",
"amount": "10.00"
}
},
{
"call": "books.post_entry",
"args": {
"entry_id": {
"$var": "entry"
},
"confirmed": true
}
},
{
"call": "books.file_document",
"args": {
"category": "statement",
"file_name": "jan.pdf",
"mime_type": "application/pdf",
"upload_id": {
"$upload": {
"name": "jan.pdf",
"mime": "application/pdf",
"text": "%PDF-1.4 January statement",
"purpose": "document"
}
}
},
"save": {
"statement": "/attachment_id"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"expect_next_steps_contains": "books.record_statement"
},
{
"follow": "books.record_statement",
"with": {
"attachment_id": {
"$var": "statement"
},
"account": "1000",
"period_start": "2026-01-01",
"period_end": "2026-01-31",
"opening_balance": "0.00",
"closing_balance": "10.00"
}
},
{
"follow": "books.mark_cleared",
"with": {
"entry_id": {
"$var": "entry"
},
"line_index": 0,
"cleared": true
}
},
{
"follow": "books.reconcile",
"with": {
"account": "1000",
"statement_date": "2026-01-31",
"statement_balance": "10.00",
"statement_attachment": {
"$var": "statement"
}
}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.reconcile",
"with": {}
},
{
"expect": {
"/status": "reconciled"
}
}
]
}
@@ -0,0 +1,94 @@
{
"description": "A posted entry reversed: previewed, confirmed, never edited.",
"settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n",
"setup": [
{
"call": "books.add_account",
"args": {
"code": "1000",
"name": "Cash",
"account_type": "asset"
}
},
{
"call": "books.add_account",
"args": {
"code": "3000",
"name": "Capital",
"account_type": "equity"
}
},
{
"call": "books.add_account",
"args": {
"code": "6000",
"name": "Rent",
"account_type": "expense"
}
}
],
"steps": [
{
"call": "books.start",
"args": {}
},
{
"follow": "books.draft_entry",
"with": {
"date": "2026-01-10",
"description": "Coffee beans",
"no_party_reason": "test"
}
},
{
"follow": "books.add_line",
"with": {
"account": "6000",
"side": "debit",
"amount": "10.00"
}
},
{
"follow": "books.add_line",
"with": {
"account": "1000",
"side": "credit",
"amount": "10.00"
}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"follow": "books.post_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
},
{
"follow": "books.reverse_entry",
"with": {
"date": "2026-01-11",
"description": "Reverse: entered in error"
}
},
{
"expect": {
"/status": "preview"
}
},
{
"follow": "books.reverse_entry",
"with": {}
},
{
"expect": {
"/status": "posted"
}
}
]
}
+547
View File
@@ -0,0 +1,547 @@
//! The agent surface over the wire (plan P5): `books.start` v2, the
//! journal in words, source corrections, the prompts and the resource
//! templates. Every call goes through a real books server as an agent
//! reaches it (`common.rs`).
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the
//! journal in words (P5)", "The result envelope", "Prompts", "Resources")
//! and plan 4.2, 4.4, 4.5.
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
use std::path::Path;
const SETTINGS: &str = "[company]\nname = \"Surface Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("6000", "Rent", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A posted entry of `amount` rent paid from cash (under the receipt
/// threshold); its id.
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let id = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
id
}
/// `books.journal_history` reads the journal in order, in words; filters
/// by subject and type; pages with `more`; refuses a bad subject; and a
/// refusal changes nothing.
#[test]
fn journal_history_traces_what_the_books_did() {
let books = Books::with("p5-history", "USD", Some(SETTINGS));
let mut w = books.session("p5");
chart(&mut w);
let first = post(&mut w, "2026-01-05", "January rent", "10.00");
let second = post(&mut w, "2026-01-06", "Printer paper", "11.00");
let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap();
let lines = all["lines"].as_array().unwrap();
let seqs: Vec<u64> = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
assert!(
seqs.windows(2).all(|p| p[0] < p[1]),
"journal order: {seqs:?}"
);
assert_eq!(all["head_seq"], *seqs.last().unwrap());
assert_eq!(all["more"], false);
let posted = lines
.iter()
.find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first))
.unwrap_or_else(|| panic!("no entry.posted for {first}: {all}"));
assert!(
posted["summary"].as_str().unwrap().contains("January rent"),
"{posted}"
);
// A subject keeps the lines about it, and only those.
let about = answer_of(&w.call(
"books.journal_history",
json!({"subject": format!("entry:{second}")}),
))
.unwrap();
let about = about["lines"].as_array().unwrap();
assert!(about.len() >= 3, "created, lines, posted: {about:?}");
assert!(
about.iter().all(|l| l["entry_id"] == json!(second)),
"{about:?}"
);
// A type group, and an exact type.
let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap();
assert!(
group["lines"]
.as_array()
.unwrap()
.iter()
.all(|l| l["type"].as_str().unwrap().starts_with("entry."))
);
let exact =
answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap();
assert_eq!(exact["lines"].as_array().unwrap().len(), 2);
// Paging: two at a time, from where the last page ended.
let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap();
assert_eq!(page["lines"].as_array().unwrap().len(), 2);
assert_eq!(page["more"], true);
let after = page["lines"][1]["seq"].as_u64().unwrap();
let next = answer_of(&w.call(
"books.journal_history",
json!({"after_seq": after, "limit": 2}),
))
.unwrap();
assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1);
// A bad subject is refused with the shapes; the journal is unchanged.
let before = books.journal();
let err =
answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err();
assert!(err.contains("entry:12"), "{err}");
assert_eq!(books.journal(), before);
// It is a read: no head in the envelope.
assert!(all.get("head").is_none(), "{all}");
}
/// A source-only correction over the wire: the three tools take `reason`
/// (or `revises` with it), the words stay, the journal says so, and a
/// change of the words is not a correction.
#[test]
fn a_source_only_revision_over_the_wire() {
let books = Books::with("p5-source", "USD", Some(SETTINGS));
let mut w = books.session("p5");
let convention = |by: &str, body: &str| {
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
"body_md": body, "decided_by": by, "decided_on": "2026-10-20"})
};
answer_of(&w.call(
"books.set_convention",
convention("Owner", "Reconcile, then close."),
))
.unwrap();
let same = answer_of(&w.call(
"books.set_convention",
convention("Pat Smith", "Reconcile, then close."),
))
.unwrap_err();
assert!(
same.contains("already says exactly that") && same.contains("reason"),
"{same}"
);
let mut fixed = convention("Pat Smith", "Reconcile, then close.");
fixed["reason"] = json!("the CPA decided it");
fixed["citation"] = json!("IRS Pub. 538");
let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap();
assert_eq!(
out["convention"]["source"]["decided_by"], "Pat Smith",
"{out}"
);
assert_eq!(out["convention"]["revision"], 1, "the words did not change");
let mut other = convention("Pat Smith", "Close first.");
other["reason"] = json!("x");
let err = answer_of(&w.call("books.set_convention", other)).unwrap_err();
assert!(err.contains("more than its source"), "{err}");
// A decision and an open item.
let decision = answer_of(&w.call(
"books.record_decision",
json!({"subject": "Materiality", "decision_md": "Five hundred dollars.",
"decided_by": ["Owner"], "decided_on": "2026-10-20"}),
))
.unwrap()["id"]
.clone();
let fixed_decision = json!({"revises": decision, "reason": "both decided it",
"decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"});
answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap();
let d = answer_of(&w.call("books.decisions", json!({}))).unwrap();
assert_eq!(
d["decisions"][0]["decided_by"],
json!(["Owner", "Pat Smith"]),
"{d}"
);
assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars.");
let no_reason = answer_of(&w.call(
"books.record_decision",
json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}),
))
.unwrap_err();
assert!(no_reason.contains("reason"), "{no_reason}");
let item = answer_of(&w.call(
"books.raise_item",
json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}),
))
.unwrap()["id"]
.clone();
answer_of(&w.call(
"books.raise_item",
json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith",
"decided_on": "2026-10-21"}),
))
.unwrap();
let items = answer_of(&w.call("books.open_items", json!({}))).unwrap();
assert_eq!(items["items"][0]["title"], "Ask the CPA");
assert_eq!(
items["items"][0]["raised"]["decided_by"], "Pat Smith",
"{items}"
);
// The journal says what happened, in order.
let h = answer_of(&w.call(
"books.journal_history",
json!({"types": ["convention.", "decision.", "open_item."]}),
))
.unwrap();
let types: Vec<&str> = h["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect();
assert_eq!(
types,
[
"convention.set",
"convention.source_revised",
"decision.recorded",
"decision.source_revised",
"open_item.raised",
"open_item.source_revised"
]
);
// A restart reads the same books.
books.restart();
let mut w = books.session("p5");
let c = answer_of(&w.call("books.conventions", json!({}))).unwrap();
assert_eq!(
c["conventions"][0]["source"]["decided_by"], "Pat Smith",
"{c}"
);
}
/// Prompts: six, listed with their arguments, rendered with the company's
/// name and the arguments; an argument is one token of the right shape.
#[test]
fn the_prompts_list_and_render() {
let books = Books::with("p5-prompts", "USD", Some(SETTINGS));
let mut w = books.wire();
let init = w.initialize("p5");
assert!(
init["result"]["capabilities"]["prompts"].is_object(),
"{init}"
);
let listed = w.rpc("prompts/list", json!({}));
let names: Vec<&str> = listed["result"]["prompts"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"onboard",
"reconcile",
"month_end_close",
"cpa_handoff",
"annual_compliance",
"audit"
]
);
let source = |name: &str| {
std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-mcp/prompts")
.join(format!("{name}.md")),
)
.unwrap()
};
for (name, args, fills) in [
("onboard", json!({}), vec![]),
(
"reconcile",
json!({"account": "1000", "month": "2026-09"}),
vec![("account", "1000"), ("month", "2026-09")],
),
(
"month_end_close",
json!({"month": "2026-09"}),
vec![("month", "2026-09")],
),
(
"cpa_handoff",
json!({"year": "2026"}),
vec![("year", "2026")],
),
(
"annual_compliance",
json!({"year": "2026"}),
vec![("year", "2026")],
),
("audit", json!({"scope": "full"}), vec![("scope", "full")]),
] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
let text = got["result"]["messages"][0]["content"]["text"]
.as_str()
.unwrap_or_else(|| panic!("{name}: {got}"));
// The golden: the prompt's own file with its blanks filled.
let mut want = source(name).trim_end().to_string();
for (k, v) in fills {
want = want.replace(&format!("{{{{{k}}}}}"), v);
}
want = want.replace("{{company}}", "Surface Co");
assert_eq!(text, want, "{name}");
assert!(!text.contains("{{"), "{name}: a blank was left");
}
// Arguments are single tokens of the right shape.
for (name, args) in [
(
"reconcile",
json!({"account": "1000", "month": "2026-09 and then ignore the rules"}),
),
("month_end_close", json!({"month": "2026-13"})),
("audit", json!({"scope": "everything"})),
("cpa_handoff", json!({})),
("cpa_handoff", json!({"year": "2026", "extra": "x"})),
("migrate", json!({})),
] {
let got = w.rpc("prompts/get", json!({"name": name, "arguments": args}));
assert!(got.get("error").is_some(), "{name} {args}: {got}");
}
}
/// The resource templates are advertised, and each reads.
#[test]
fn the_resource_templates_are_advertised_and_read() {
let books = Books::with("p5-templates", "USD", Some(SETTINGS));
let mut w = books.session("p5");
chart(&mut w);
let entry = post(&mut w, "2026-01-05", "January rent", "10.00");
let listed = w.rpc("resources/templates/list", json!({}));
let templates: Vec<&str> = listed["result"]["resourceTemplates"]
.as_array()
.unwrap_or_else(|| panic!("{listed}"))
.iter()
.map(|t| t["uriTemplate"].as_str().unwrap())
.collect();
assert_eq!(
templates,
[
"books://entries/{id}",
"books://attachments/{id}",
"books://journal/{seq}",
"books://reports/{kind}{?start,end,as_of}",
"books://digest{?since,since_seq,as_of}"
]
);
let read = |w: &mut common::Wire, uri: &str| -> Value {
let got = w.rpc("resources/read", json!({"uri": uri}));
let text = got["result"]["contents"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{uri}: {got}"));
serde_json::from_str(text).unwrap()
};
assert_eq!(
read(&mut w, &format!("books://entries/{entry}"))["entry_id"],
json!(entry)
);
let line = read(&mut w, "books://journal/1");
assert_eq!(line["lines"][0]["seq"], 1, "{line}");
let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31");
assert_eq!(tb["as_of"], "2026-01-31", "{tb}");
let pl = read(
&mut w,
"books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31",
);
assert_eq!(pl["start"], "2026-01-01", "{pl}");
let digest = read(&mut w, "books://digest?since_seq=1");
assert!(
digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object()
);
let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"}));
assert!(gone.get("error").is_some(), "{gone}");
}
/// `books.start` v2: who is asking, the drafts, reconciliation, backup,
/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo
/// company; the agent's self-report goes to the access log only.
#[test]
fn books_start_v2_is_whole_and_fits() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
let books = Books::placed("p5-start-v2", &fixture);
let mut w = books.session("claude-code");
let raw = w.call(
"books.start",
json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}),
);
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert!(start["book"]["id"].is_string());
assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/"));
assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp"));
books.home.assert_hidden(text);
assert_eq!(start["you"]["kind"], "agent", "{start}");
assert_eq!(start["you"]["client_id"], "claude-code");
assert!(!start["you"]["scopes"].as_array().unwrap().is_empty());
assert!(start["drafts"]["building"].is_u64());
assert!(start["drafts"]["awaiting_evidence"].is_array());
for k in ["tracked", "never_reconciled", "behind"] {
assert!(start["reconciliation"][k].is_array(), "{k}");
}
assert!(start["backup"]["stale"].is_boolean());
assert_eq!(start["prompts"].as_array().unwrap().len(), 6);
assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12);
assert!(start["needs_attention"]["counts"].is_object());
for s in start["next_steps"].as_array().unwrap() {
assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}");
assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}");
}
// The envelope: a read has no head of the envelope's (its own stays).
assert!(start["head"]["seq"].is_u64());
// The self-report is in the access log, beside the call.
let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap();
let agent = log["records"]
.as_array()
.unwrap()
.iter()
.find(|r| r["subject"] == "books.start" && r.get("agent").is_some())
.unwrap_or_else(|| panic!("no agent in the log: {log}"));
assert_eq!(agent["agent"]["model"], "opus");
assert_eq!(agent["agent"]["harness"], "claude-code");
// And nowhere else: not in the answer.
assert!(!text.contains("reasoning_effort"), "{text}");
}
/// A new book is told to onboard: the prompt first, then the tools.
#[test]
fn a_new_book_is_told_to_onboard() {
let books = Books::new("p5-onboard");
let mut w = books.session("p5");
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
assert_eq!(start["state"], "needs_onboarding");
let steps = start["next_steps"].as_array().unwrap();
assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}");
assert!(steps.iter().any(|s| s["tool"] == "books.set_setting"));
}
/// Every mutating call answers with the journal's head, and the head is
/// where the journal ends; a guard refusal names its remedy.
#[test]
fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() {
let books = Books::with("p5-head", "USD", Some(SETTINGS));
let mut w = books.session("p5");
let added = answer_of(&w.call(
"books.add_account",
json!({"code": "7000", "name": "Misc", "account_type": "expense"}),
))
.unwrap();
let head = added["head"]["seq"].as_u64().unwrap();
let start = answer_of(&w.call("books.start", json!({}))).unwrap();
assert_eq!(
start["head"]["seq"],
json!(head),
"the head the call answered is the journal's"
);
assert!(!added["next_steps"].as_array().unwrap().is_empty());
// A confirm with no preview is refused as stale_preview, naming the
// tool that previews.
chart(&mut w);
let draft = answer_of(&w.call(
"books.draft_entry",
json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.clone();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}),
))
.unwrap();
}
let refused = w.call_raw(
"books.post_entry",
json!({"entry_id": draft, "confirmed": true}),
);
assert_eq!(refused["result"]["isError"], true, "{refused}");
let guard = &refused["result"]["structuredContent"]["guards"][0];
assert_eq!(guard["code"], "stale_preview", "{refused}");
assert_eq!(guard["remedy_tool"], "books.post_entry");
}
/// Done-when (3): `books.start` is at most 8 KiB on the demo company too
/// (the books the viewer ships with, a loose end of every kind), and says
/// what it cut. (The demo books are the viewer's: not in the server-only
/// build.)
#[cfg(feature = "gui")]
#[test]
fn books_start_fits_8_kib_on_the_demo_books() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("p5-start-demo");
let source = tempfile::Builder::new()
.prefix("wb-p5-demo-")
.tempdir()
.unwrap();
ledger_gui::demo::build(source.path(), today).unwrap();
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
let mut wire = place.start(None);
wire.initialize("p5");
let raw = wire.call("books.start", json!({}));
let text = raw["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.len() <= 8 * 1024,
"books.start is {} bytes",
text.len()
);
let start = answer_of(&raw).unwrap();
assert_eq!(start["state"], "active");
assert!(
!start["needs_attention"]["items"]
.as_array()
.unwrap()
.is_empty(),
"the demo has loose ends: {start}"
);
assert!(
start["needs_attention"]["counts"]
.as_object()
.unwrap()
.len()
>= 2
);
}
+428
View File
@@ -0,0 +1,428 @@
//! The viewer's "Needs attention" and `books.start`'s next steps are one
//! list: both come from `ledger_package::attention`, the viewer over its
//! `Snapshot`, the server over its `Store`. This drives both on the same
//! books (the demo company, a book written entry by entry, and that book
//! after its stored file and its database are damaged) and compares them
//! item for item.
#![cfg(feature = "gui")]
use std::collections::HashSet;
use std::time::Instant;
use chrono::{Datelike, NaiveDate};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::{Source, data};
use rust_decimal::Decimal;
use serde_json::Value;
use serde_json::json;
mod common;
const SETTINGS: &str = "[company]\nname = \"Attention Parity LLC\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\nparties = \"require\"\n";
fn usd(cents: i64) -> Money {
Money::new(Decimal::new(cents, 2), Currency::USD)
}
fn start(wire: &mut common::Wire) -> Value {
common::answer_of(&wire.call("books.start", json!({"focus": "daily"})))
.unwrap_or_else(|e| panic!("books.start: {e}"))
}
/// The items the viewer draws and the next steps the server lists: the
/// server's attention steps are the ones whose `why` is one of the viewer's
/// titles, and they must be the same titles, in the same order. The viewer
/// has at most 12 (books.start lists the top 12). Returns the viewer's
/// titles.
fn compare(place: &common::Place, wire: &mut common::Wire, today: NaiveDate) -> Vec<String> {
let answer = start(wire);
let books = data::load_once(&Source {
data_dir: place.books_data(),
today: Some(today),
})
.unwrap_or_else(|e| panic!("the viewer can't read the books: {e}"));
let viewer: Vec<String> = books.attention.iter().map(|a| a.title.clone()).collect();
assert!(
viewer.len() <= 12,
"the fixture is meant to fit books.start's 12 attention steps: {viewer:?}"
);
let steps = answer["next_steps"]
.as_array()
.unwrap_or_else(|| panic!("no next_steps: {answer}"));
let server: Vec<String> = steps
.iter()
.filter_map(|s| s["why"].as_str())
.filter(|why| viewer.iter().any(|t| t == why))
.map(str::to_string)
.collect();
assert_eq!(server, viewer, "books.start: {answer}");
// Item for item: severity, title, and detail are the viewer's own; the
// prompt names the books and says what to do.
for (a, title) in books.attention.iter().zip(&viewer) {
assert_eq!(&a.title, title);
assert!(a.prompt.contains("wanderbooks"), "{}", a.prompt);
}
viewer
}
/// A book written entry by entry, pseudo-randomly but deterministically (a
/// fixed seed, so a failure reproduces): a chart, three drafts, posted
/// entries without receipts, one that names no counterparty, and one with
/// a filed receipt (the blob the damage test removes).
fn write_random(dir: &std::path::Path, today: NaiveDate) {
let _ = std::fs::remove_dir_all(dir);
let mut st = ledger_store::Store::create(dir, Currency::USD).unwrap();
let account = |code: &str, name: &str, ty, st: &mut ledger_store::Store| {
st.add_account(code, name, ty, None, false).unwrap()
};
let checking = account("1000", "Checking", AccountType::Asset, &mut st);
let owners = account("2100", "Due to owners", AccountType::Liability, &mut st);
let capital = account("3000", "Capital", AccountType::Equity, &mut st);
let revenue = account("4000", "Design", AccountType::Revenue, &mut st);
let supplies = account("6100", "Supplies", AccountType::Expense, &mut st);
let contractors = account("6200", "Contractors", AccountType::Expense, &mut st);
// A member paid a cost personally: money owed to an owner.
let post = |st: &mut ledger_store::Store, day: NaiveDate, dr, cr, cents: i64| {
let e = st.create_entry(day, "Entry").unwrap();
st.add_line(e, dr, Side::Debit, usd(cents)).unwrap();
st.add_line(e, cr, Side::Credit, usd(cents)).unwrap();
st.post_entry(e).unwrap();
e
};
post(
&mut st,
today - chrono::Days::new(40),
supplies,
checking,
12_345,
);
// Three drafts, alternating accounts.
for i in 0..3i64 {
let e = st
.create_entry(today - chrono::Days::new(30 - i as u64), "A draft")
.unwrap();
st.add_line(
e,
if i % 2 == 0 { supplies } else { contractors },
Side::Debit,
usd(5_000 + i),
)
.unwrap();
st.add_line(e, checking, Side::Credit, usd(5_000 + i))
.unwrap();
}
// Posted without receipts, one naming no counterparty.
for i in 0..4i64 {
let day = today - chrono::Days::new(20 - i as u64);
let e = st.create_entry(day, "No receipt").unwrap();
st.add_line(
e,
if i % 2 == 0 { supplies } else { contractors },
Side::Debit,
usd(7_000 + i),
)
.unwrap();
st.add_line(e, checking, Side::Credit, usd(7_000 + i))
.unwrap();
st.post_entry(e).unwrap();
}
// A reverse of the first entry (its original carries the evidence).
let first = st.ledger().posted_entries().next().map(|e| e.id()).unwrap();
st.reverse_entry(first, today - chrono::Days::new(1), "Wrong account")
.unwrap();
// One entry with a receipt, so not every posted entry is flagged.
let e = st
.create_entry(today - chrono::Days::new(3), "With receipt")
.unwrap();
st.add_line(e, revenue, Side::Credit, usd(50_000)).unwrap();
st.add_line(e, checking, Side::Debit, usd(50_000)).unwrap();
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
st.post_entry(e).unwrap();
let _ = (owners, capital);
drop(st);
}
/// A blob's file in the content-addressed store (`<aa>/<bb>/<sha>`).
fn blob_path(data_dir: &std::path::Path, sha: &str) -> std::path::PathBuf {
data_dir
.join("blobs")
.join(&sha[0..2])
.join(&sha[2..4])
.join(sha)
}
/// The viewer's items and the server's steps, and both name `expected`.
fn compare_and_expect(
place: &common::Place,
wire: &mut common::Wire,
today: NaiveDate,
expected: &str,
) {
let viewer = compare(place, wire, today);
assert!(
viewer.iter().any(|t| t.contains(expected)),
"the viewer should say {expected:?}: {viewer:?}"
);
}
#[test]
fn the_viewer_and_books_start_see_the_same_attention() {
let today = ledger_core::settings::today_in(None);
// The demo company (ledger_gui::demo): the books the viewer ships with,
// with a few loose ends of every kind. Built in a folder of its own and
// adopted into the data root, as old books come in.
{
let place = common::Place::new("attention-parity-demo");
let source = tempfile::Builder::new()
.prefix("wb-attention-demo-")
.tempdir()
.unwrap();
ledger_gui::demo::build(source.path(), today).unwrap();
std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap();
std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap();
let mut wire = place.start(None);
wire.initialize("attention-parity");
compare(&place, &mut wire, today);
// What the real integrity check costs books.start: the cold call
// (which also opens the store) and a warm one, and how many stored
// files it hashes (one per distinct blob, as the viewer does).
let books = data::load_once(&Source {
data_dir: place.books_data(),
today: Some(today),
})
.unwrap();
let blobs: HashSet<&str> = books
.attachments
.iter()
.map(|a| a.blob_sha.as_str())
.collect();
let cold = Instant::now();
let _ = start(&mut wire);
let cold = cold.elapsed();
let warm = Instant::now();
let _ = start(&mut wire);
let warm = warm.elapsed();
println!(
"books.start on the demo: {} attachments, {} distinct blobs; cold {cold:?}, warm {warm:?}",
books.attachments.len(),
blobs.len()
);
}
// A book written entry by entry, then damaged: a stored file removed
// and the database changed from outside. Both lists must still agree,
// and both must name the damage (a clean-books-only test missed this).
{
let place = common::Place::new("attention-parity-random");
write_random(&place.books_data(), today);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-parity");
compare(&place, &mut wire, today);
// The stored file: remove its bytes (the blob store never deletes;
// the test does, on its own books).
let books = data::load_once(&Source {
data_dir: place.books_data(),
today: Some(today),
})
.unwrap();
let sha = books
.attachments
.first()
.expect("the fixture filed a receipt")
.blob_sha
.clone();
let path = blob_path(&place.books_data(), &sha);
std::fs::remove_file(&path).unwrap();
compare_and_expect(&place, &mut wire, today, "damaged or missing");
// The database: change a projected row the journal doesn't say.
let db = ledger_store::database_in_use(&place.books_data());
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute(
"UPDATE accounts SET name = name || ' (changed outside the journal)' \
WHERE id = (SELECT MIN(id) FROM accounts)",
[],
)
.unwrap();
drop(conn);
compare_and_expect(&place, &mut wire, today, "doesn't match the journal");
}
}
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent is on
/// the viewer's list and in `books.start`'s next steps, from the one
/// engine, with the step naming `books.mark_sent`.
#[test]
fn an_unsent_invoice_is_on_both_lists() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-unsent-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
st.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let client = st.add_customer("Harbor Client").unwrap();
st.create_invoice_with(
client,
today - chrono::Days::new(40),
today + chrono::Days::new(20),
vec![ledger_core::arap::DocumentLine {
description: "Consulting".into(),
amount: Decimal::new(50_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
ledger_core::arap::InvoiceFields {
number: Some("INV-0001".into()),
..Default::default()
},
)
.unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-unsent-parity");
compare_and_expect(&place, &mut wire, today, "Invoice INV-0001 was never sent");
// The step offers the fix: books.mark_sent on that invoice.
let answer = start(&mut wire);
let step = answer["next_steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["why"] == json!("Invoice INV-0001 was never sent"))
.unwrap_or_else(|| panic!("no unsent step: {answer}"));
assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}");
assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}");
}
/// Plan P4 step 6 (R-F): a draft that says it waits for its evidence is
/// one item, "draft awaiting receipt", and a draft still being built is
/// another, "draft not posted": both on the viewer's list and in
/// `books.start`'s next steps, from the one engine.
#[test]
fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-drafts-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = st
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let rent = st
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let mut draft = |what: &str| {
let e = st.create_entry(today - chrono::Days::new(5), what).unwrap();
st.add_line(e, rent, Side::Debit, usd(4_000)).unwrap();
st.add_line(e, cash, Side::Credit, usd(4_000)).unwrap();
e
};
let waiting = draft("Figma annual plan");
let _building = draft("Coworking day pass");
st.set_draft_state(
waiting,
ledger_core::DraftState::AwaitingEvidence {
note: "invoice due".into(),
},
)
.unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-drafts-parity");
compare_and_expect(
&place,
&mut wire,
today,
&format!("Draft #{waiting} awaiting receipt: Figma annual plan"),
);
let titles: Vec<String> = start(&mut wire)["next_steps"]
.as_array()
.unwrap()
.iter()
.filter_map(|s| s["why"].as_str().map(str::to_string))
.collect();
assert!(
titles.contains(&"Draft not posted: Coworking day pass".to_string()),
"{titles:?}"
);
}
/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end
/// attestation due" on the viewer's list and in `books.start`'s next steps,
/// from the one engine; the fiscal year that ended closed with no audit
/// for the handoff is "Full audit due before the CPA handoff"; an
/// attestation clears the month. Books that never declared `audits` get
/// none of them (they cannot act on them).
#[test]
fn audit_items_are_on_both_lists_once_audits_are_declared() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-audit-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let year = today.year() - 1;
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap();
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = st
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let capital = st
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let e = st.create_entry(day(1, 5), "Capital").unwrap();
st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap();
st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap();
st.post_entry(e).unwrap();
let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
st.close_period(jan).unwrap();
let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap();
st.close_period(dec).unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-audit-parity");
let titles = compare(&place, &mut wire, today);
assert!(
titles
.iter()
.any(|t| t.starts_with("Month-end attestation due")),
"{titles:?}"
);
assert!(
titles
.iter()
.any(|t| t.starts_with("Full audit due before the CPA handoff")),
"{titles:?}"
);
// Audit and attest January: that month's item goes, the year's stays.
let run = common::answer_of(&wire.call(
"books.audit",
json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}),
))
.unwrap();
assert_eq!(run["status"], "audited", "{run}");
common::answer_of(&wire.call(
"books.attest_period",
json!({"period": "Jan", "method": {"reasoning_effort": "high"}}),
))
.unwrap();
let after = compare(&place, &mut wire, today);
let due: Vec<&String> = after
.iter()
.filter(|t| t.starts_with("Month-end attestation due"))
.collect();
assert_eq!(due.len(), 1, "{after:?}");
assert!(due[0].contains("Dec"), "{after:?}");
// The step offers the audit.
let answer = start(&mut wire);
let step = answer["next_steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["why"] == json!(due[0].as_str()))
.unwrap_or_else(|| panic!("no step: {answer}"));
assert_eq!(step["tool"], json!("books.audit"), "{answer}");
assert_eq!(step["args"]["scope"], json!("month"), "{answer}");
}
+419
View File
@@ -0,0 +1,419 @@
//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9):
//! a deterministic run and workpaper, claims weighed against the bar when
//! asked, the CPA package that waits for a counted audit, and books that
//! have not declared `audits`.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit
//! and attestations") and rules.md B-45. Every call goes through a real
//! books server as an agent reaches it (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
/// Cash at 1000, the default 25.00 receipt threshold.
const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("4000", "Sales", "revenue"),
("6000", "Supplies", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A posted entry of `amount` supplies paid from cash on `date`.
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let r = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap();
let id = r["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
id
}
fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 {
let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt");
answer_of(&w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
))
.unwrap()["attachment_id"]
.as_u64()
.unwrap()
}
fn confirm_post(w: &mut common::Wire, id: u64) {
let r = answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
assert_eq!(r["status"], "posted", "{r}");
}
/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with
/// none. Returns (receipted entry, its receipt, the bare entry).
fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) {
let books = Books::with(tag, "USD", Some(SETTINGS));
let mut w = books.session("p8");
chart(&mut w);
let cap = answer_of(&w.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}),
))
.unwrap()["entry_id"]
.as_u64()
.unwrap();
for (account, side) in [("1000", "debit"), ("3000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}),
))
.unwrap();
}
receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01");
confirm_post(&mut w, cap);
let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00");
let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05");
confirm_post(&mut w, paper);
// The 60.00 purchase is posted while the books' receipt threshold was
// higher, as it could have been before the rule was tightened.
set_threshold(&mut w, "1000.00");
let bare = post(&mut w, "2026-03-07", "Toner", "60.00");
confirm_post(&mut w, bare);
set_threshold(&mut w, "25.00");
(books, w, paper, rid, bare)
}
fn set_threshold(w: &mut common::Wire, amount: &str) {
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.evidence.threshold", "value": amount}]}),
))
.unwrap();
}
fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value {
answer_of(&w.call(
"books.audit",
json!({"scope": scope, "through": through, "confirmed": true}),
))
.unwrap_or_else(|e| panic!("books.audit: {e}"))
}
fn codes(out: &Value) -> Vec<String> {
out["findings"]
.as_array()
.unwrap()
.iter()
.map(|f| f["code"].as_str().unwrap().to_string())
.collect()
}
/// The same books, day and scope give the same run, findings and
/// workpaper; asking again files and journals nothing; an entry makes it a
/// new audit.
#[test]
fn audit_is_deterministic() {
let (books, mut w, _, _, bare) = setup("p8-deterministic");
let preview = answer_of(&w.call(
"books.audit",
json!({"scope": "full", "through": "2026-03-31"}),
))
.unwrap();
assert_eq!(preview["status"], "preview");
assert!(preview["run"].is_null());
assert!(
preview["checks"]
.as_array()
.unwrap()
.iter()
.any(|c| c["code"] == "evidence_missing")
);
let first = audit(&mut w, "full", "2026-03-31");
assert_eq!(first["status"], "audited", "{first}");
assert!(
codes(&first).contains(&"evidence_missing".to_string()),
"the bare 60.00 entry has no receipt: {first}"
);
let run = first["run"].clone();
assert_eq!(run["at_current_head"], true);
let journal = books.journal();
let again = audit(&mut w, "full", "2026-03-31");
assert_eq!(again["status"], "already_run");
assert_eq!(again["run"]["id"], run["id"]);
assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]);
assert_eq!(again["findings"], first["findings"]);
assert_eq!(books.journal(), journal, "asking again journals nothing");
// The workpaper is the same twice, JSON and PDF.
let id = run["id"].clone();
let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
assert_eq!(one["workpaper"], two["workpaper"]);
assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]);
assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]);
let pdf = w.fetch(&one["pdf"]);
assert!(pdf.starts_with(b"%PDF"));
// The audit fixes nothing and moves no figure; an entry makes it stale.
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
assert_eq!(tb["balanced"], true);
let extra = post(&mut w, "2026-03-09", "Stamps", "10.00");
confirm_post(&mut w, extra);
let third = audit(&mut w, "full", "2026-03-31");
assert_eq!(third["status"], "audited");
assert_ne!(third["run"]["id"], run["id"]);
let _ = bare;
}
/// A reading that differs from the entry never counts; one that agrees
/// counts only at the bar, and the bar is read when asked.
#[test]
fn attestations_count_against_the_bar_when_asked() {
let (_books, mut w, paper, rid, _) = setup("p8-bar");
let good = answer_of(&w.call(
"books.attest_receipt",
json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05",
"method": {"model": "opus", "reasoning_effort": "high"}}),
))
.unwrap();
assert_eq!(good["attestation"]["counted"], true, "{good}");
assert_eq!(good["attestation"]["by"], "test");
let wrong = answer_of(&w.call(
"books.attest_receipt",
json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05",
"method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}),
))
.unwrap();
assert_eq!(wrong["attestation"]["disagrees"], true);
assert_eq!(wrong["attestation"]["counted"], false);
assert!(
wrong["why_not_counted"]
.as_str()
.unwrap()
.contains("differs")
);
let low = answer_of(&w.call(
"books.review_entry",
json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}),
))
.unwrap();
assert_eq!(low["attestation"]["counted"], false);
assert!(low["why_not_counted"].as_str().unwrap().contains("high"));
let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap();
assert_eq!(none["attestation"]["counted"], false, "no claim, no count");
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
assert_eq!(listed["attestations"].as_array().unwrap().len(), 4);
assert_eq!(listed["counted"], 1);
assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true");
let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2);
// The bar is read when asked: lowered, the medium review counts.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}),
))
.unwrap();
let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true");
assert_eq!(after["attestations"][0]["counted"], true);
assert_eq!(after["attestations"][1]["counted"], false);
// Refused: bad method words, an unposted entry.
let bad = w.call(
"books.review_entry",
json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}),
);
assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort"));
}
/// A period is attested only on an audit that still stands.
#[test]
fn a_period_is_attested_on_a_standing_audit() {
let (_books, mut w, paper, _, _) = setup("p8-period");
answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap();
let method = json!({"reasoning_effort": "high"});
let none = w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
);
assert!(answer_of(&none).unwrap_err().contains("books.audit"));
let run = audit(&mut w, "month", "2026-03-31");
let ok = answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
))
.unwrap();
assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]);
assert_eq!(
ok["attestation"]["counted"], true,
"a month needs the effort only"
);
// A change since the audit: refused until it is run again.
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
confirm_post(&mut w, extra);
let stale = w.call(
"books.attest_period",
json!({"period": "2026-03", "method": method}),
);
assert!(answer_of(&stale).unwrap_err().contains("books.audit"));
let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap();
assert_eq!(listing["run"]["at_current_head"], false);
let _ = paper;
}
/// D-9: the CPA package waits for a counted audit for the handoff; a
/// reason overrides it and is journaled as a decision; the bar is the
/// whole bar for a year.
#[test]
fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() {
let (_books, mut w, paper, rid, bare) = setup("p8-cpa");
// Unset, the package warns until the books hold an audit run; the
// owner who wants it refused from the start says so.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "refuse"}]}),
))
.unwrap();
let export = |w: &mut common::Wire, extra: Value| {
let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"],
"include_receipts": false, "include_tins": false});
for (k, v) in extra.as_object().unwrap() {
args[k] = v.clone();
}
w.call("books.export_cpa_package", args)
};
let refused = export(&mut w, json!({}));
let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap();
assert_eq!(err["code"], "audit_required", "{err}");
assert_eq!(err["remedy_tool"], "books.audit");
// A reason lets it go, and is a decision in the journal.
let before = answer_of(&w.call("books.decisions", json!({}))).unwrap();
answer_of(&export(
&mut w,
json!({"without_audit_reason": " the CPA asked for it early "}),
))
.unwrap();
let after = answer_of(&w.call("books.decisions", json!({}))).unwrap();
assert_eq!(
after["decisions"].as_array().unwrap().len(),
before["decisions"].as_array().unwrap().len() + 1
);
assert!(after.to_string().contains("without a counted audit"));
// An audit alone does not open it; nor one attested below the bar.
answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap();
audit(&mut w, "cpa_handoff", "2026-03-31");
assert!(answer_of(&export(&mut w, json!({}))).is_err());
answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}),
))
.unwrap();
assert!(
answer_of(&export(&mut w, json!({}))).is_err(),
"a year-level audit needs the adversarial check and the verifiers"
);
answer_of(&w.call(
"books.attest_period",
json!({"period": "2026-03", "method": {"reasoning_effort": "high",
"adversarial": true, "verifier_agents": 2}}),
))
.unwrap();
let made = answer_of(&export(&mut w, json!({}))).unwrap();
assert!(made["file"]["sha256"].is_string(), "{made}");
// A change since: the audit no longer stands, and the package waits again.
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
confirm_post(&mut w, extra);
assert!(answer_of(&export(&mut w, json!({}))).is_err());
// Under warn the package is made and says so.
answer_of(&w.call(
"books.set_setting",
json!({"decided_by": "Avery Quill",
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}),
))
.unwrap();
let warned = answer_of(&export(&mut w, json!({}))).unwrap();
assert!(
warned["warnings"]
.to_string()
.contains("No counted audit stands behind this package"),
"{warned}"
);
let _ = (paper, rid, bare);
}
/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`)
/// open with no upgrade; the audit's preview works, its run and every
/// attestation are refused with format_upgrade_pending, nothing is
/// journaled, and the CPA package is not held up by an audit it cannot ask
/// for.
#[test]
fn audits_wait_for_the_upgrade() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p8_before");
let only = tempfile::tempdir().unwrap();
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
common::copy_private(&src.join("blobs"), &only.path().join("blobs"));
let books = Books::placed("p8-waits", only.path());
let mut w = books.session("p8");
let preview = answer_of(&w.call(
"books.audit",
json!({"scope": "full", "through": "2026-02-28"}),
))
.unwrap();
assert_eq!(preview["status"], "preview");
let journal = books.journal();
for (tool, args) in [
(
"books.audit",
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
),
(
"books.review_entry",
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
),
(
"books.attest_receipt",
json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00",
"date": "2026-01-01"}),
),
] {
let e = answer_of(&w.call(tool, args)).unwrap_err();
assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}");
}
assert_eq!(books.journal(), journal, "a refusal journals nothing");
// The package is held up only by what it always was: an open draft.
let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"],
"include_receipts": false, "include_tins": false});
let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap();
assert!(made["file"]["sha256"].is_string(), "{made}");
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
assert!(listed["attestations"].as_array().unwrap().is_empty());
}
File diff suppressed because it is too large. Load diff
+152
View File
@@ -0,0 +1,152 @@
//! `wanderbooks connect` makes the book's books home (P11b, D-27): a folder in
//! the account's Documents holding the agent's pointer and nothing of the
//! books. Run with a home of the test's own: nothing here touches the account's
//! real Documents or data root.
#![cfg(unix)]
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const KNOBS: &str =
"close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300";
struct Home {
dir: tempfile::TempDir,
}
impl Home {
fn new() -> Self {
let dir = tempfile::Builder::new()
.prefix("wb-books-home-")
.tempdir()
.unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["setup", "--home"])
.arg(dir.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
Self { dir }
}
fn root(&self) -> PathBuf {
ledger_store::root::default_root(self.dir.path())
}
fn cmd(&self, mode: &str, path: &Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_wanderbooks"));
c.arg(mode)
.arg("--root")
.arg(self.root())
.args(["--test-knobs", KNOBS])
.env_clear()
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
.env("HOME", self.dir.path())
.stdin(Stdio::null());
c
}
}
/// A stand-in `claude` that accepts whatever it is told.
fn fake_agent(home: &Home) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let bin = home.dir.path().join("agent-bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join("claude");
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
}
#[test]
fn connect_makes_the_books_home_and_it_holds_no_books_data() {
let home = Home::new();
let out = home
.cmd("books", Path::new("/nonexistent"))
.args(["create", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let docs = home.dir.path().join("Documents/Wanderbooks/beta");
// An agent that isn't installed: said, and no folder made for nothing.
let out = home
.cmd("connect", Path::new("/nonexistent"))
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(!out.status.success());
assert!(!docs.exists(), "a home for an agent that isn't there");
// Connected: the user-scope entry as ever, and the books home beside it.
let bin = fake_agent(&home);
let out = home
.cmd("connect", &bin)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let said = String::from_utf8_lossy(&out.stdout);
assert!(
said.contains("Claude Code reaches the books beta"),
"{said}"
);
assert!(
said.contains(&format!("The books home is {}", docs.display())),
"{said}"
);
let mut names: Vec<String> = Vec::new();
for dir in [docs.clone(), docs.join(".claude")] {
for e in std::fs::read_dir(&dir).unwrap() {
let p = e.unwrap().path();
if p.is_file() {
names.push(
p.strip_prefix(&docs)
.unwrap()
.to_string_lossy()
.into_owned(),
);
}
}
}
names.sort();
assert_eq!(
names,
[
".claude/settings.json",
".mcp.json",
"AGENTS.md",
"CLAUDE.md"
]
);
// Nothing in it names the data root, a token or a key.
let root = home.root();
for n in &names {
let text = std::fs::read_to_string(docs.join(n)).unwrap();
assert!(!text.contains(root.to_str().unwrap()), "{n}: {text}");
assert!(!text.contains("wb_"), "{n} holds a token: {text}");
assert!(!text.contains("--root"), "{n}: {text}");
}
let mcp: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(docs.join(".mcp.json")).unwrap()).unwrap();
assert_eq!(
mcp["mcpServers"]["wanderbooks-beta"]["args"],
serde_json::json!(["mcp", "--book", "beta"])
);
// Run again: still four files, the rule once.
let out = home
.cmd("connect", &bin)
.args(["claude", "--book", "beta"])
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let settings: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(docs.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["permissions"]["allow"],
serde_json::json!(["mcp__wanderbooks-beta__*"])
);
}
File diff suppressed because it is too large. Load diff
+265
View File
@@ -0,0 +1,265 @@
//! The re-audit's engineering mirror (plan P10; `docs/runbooks/re-audit.md`):
//! every defect the 2026-10-16 audit found (1 to 20), P0 (21, 22) and P3
//! (23, 24) has a named regression test somewhere in the workspace, and the
//! runbook names only tools and commands that exist.
//!
//! The list below is the single place that says which test stands guard for
//! which defect. A test renamed or deleted fails here, so a defect's
//! reproduction cannot be lost quietly. This proves the tests exist, are
//! real `#[test]`s and are not ignored; that they pass is `cargo test`'s
//! job (the runbook's step 4 runs them by name).
//!
//! SOURCE: plan/served.md 8.0 ("defect n") and P10 step 2.
use std::path::{Path, PathBuf};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
fn read(rel: &str) -> String {
std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
}
/// `(defect, test name, file)`. Defect 2 is closed by D-12 and D-47 (the
/// rule it now shows is that a reversal needs no receipt of its own);
/// defect 20 (the migration scripts) was retired at the cutover and is
/// checked separately below.
const DEFECTS: [(u32, &str, &str); 23] = [
(
1,
"defect1_two_member_llc_first_year_regression",
"crates/ledger-core/tests/spec_cash_flow.rs",
),
(
2,
"a_reversal_needs_no_receipt_of_its_own",
"crates/ledger-cli/tests/drafts.rs",
),
(
3,
"get_receipt_default_has_text_and_no_bytes",
"crates/ledger-cli/tests/evidence.rs",
),
(
4,
"same_bytes_one_record_many_links",
"crates/ledger-store/tests/attachments.rs",
),
(
5,
"a_draft_waits_for_its_receipt_and_posts",
"crates/ledger-cli/tests/drafts.rs",
),
(
6,
"digest_since_seq_is_posting_time",
"crates/ledger-cli/tests/drafts.rs",
),
(
7,
"the_readme_lists_what_the_iif_writes_differently",
"crates/ledger-package/tests/package.rs",
),
(
8,
"add_account_warns_without_tax_category_defect_8",
"crates/ledger-cli/tests/spec_tax_policy.rs",
),
(
9,
"defect9_a_new_schedule_spreads_by_the_day",
"crates/ledger-core/tests/schedules.rs",
),
(
10,
"annotations_replay_and_project",
"crates/ledger-store/tests/attachments.rs",
),
(
11,
"defect11_the_directory_shows_a_members_tin_last4",
"crates/ledger-cli/tests/spec_vault.rs",
),
(
12,
"payment_methods_round_trip_and_classify_payments",
"crates/ledger-cli/tests/spec_1099.rs",
),
(
13,
"four_contributed_devices_each_take_the_lower_of_value_and_basis",
"crates/ledger-core/tests/spec_asset_basis.rs",
),
(
14,
"defect14_party_totals_take_a_basis",
"crates/ledger-cli/tests/spec_party_totals.rs",
),
(
15,
"rename_account_end_to_end_defect_15",
"crates/ledger-cli/tests/mcp_journeys.rs",
),
(
16,
"one_default_period_everywhere_defect_16",
"crates/ledger-cli/tests/mcp_journeys.rs",
),
(
17,
"the_audit_odd_cent_never_piles_up_across_closes",
"crates/ledger-core/tests/odd_cent.rs",
),
(
18,
"undecided_is_not_not_reportable",
"crates/ledger-core/tests/form1099.rs",
),
(
19,
"no_tool_takes_a_path",
"crates/ledger-mcp/src/server_tests.rs",
),
(
21,
"as_of_package_iif_stops_at_as_of",
"crates/ledger-package/tests/package.rs",
),
(
22,
"defect22_depreciation_through_mid_final_month_is_prorated_not_plugged",
"crates/ledger-core/tests/spec_assets_payroll.rs",
),
(
23,
"a_vendor_marked_no_1099_is_not_undecided",
"crates/ledger-cli/tests/spec_1099.rs",
),
(
24,
"defect24_an_accrual_through_another_liability_counts_once",
"crates/ledger-core/tests/spec_party_totals.rs",
),
];
/// Defects 1 to 24 each have a named test that exists, is a test, and is
/// not ignored (defect 20 is the retired scripts, below).
#[test]
fn every_defect_reproduction_is_a_named_test() {
let mut covered: Vec<u32> = DEFECTS.iter().map(|d| d.0).collect();
covered.push(20);
covered.sort_unstable();
assert_eq!(
covered,
(1..=24).collect::<Vec<u32>>(),
"a defect is missing"
);
let mut missing = Vec::new();
for (defect, name, file) in DEFECTS {
let text = read(file);
let lines: Vec<&str> = text.lines().collect();
let needle = format!("fn {name}(");
let Some(at) = lines.iter().position(|l| {
l.trim_start().starts_with(&needle)
|| l.trim_start().starts_with(&format!("pub {needle}"))
}) else {
missing.push(format!("defect {defect}: no `fn {name}` in {file}"));
continue;
};
// The attributes and doc lines above the function, up to the
// previous blank line or item.
let above: String = lines[at.saturating_sub(8)..at].join("\n");
let in_proptest = text.contains("proptest!");
if !above.contains("#[test]") && !in_proptest {
missing.push(format!(
"defect {defect}: `{name}` in {file} is not a #[test]"
));
}
if above.contains("#[ignore") {
missing.push(format!("defect {defect}: `{name}` in {file} is ignored"));
}
}
assert!(missing.is_empty(), "{}", missing.join("\n"));
}
/// Defect 20, the migration scripts, was retired at the cutover: there is
/// no tool for them, and none may come back as a way past the journal.
/// The defect 19 mirror (`import_local_file` and every path parameter) is
/// `no_tool_takes_a_path` above.
#[test]
fn defect_20_no_migration_tool_exists() {
let scopes = read("crates/ledger-mcp/src/scopes.rs");
for line in scopes.lines().filter(|l| l.contains("(\"books.")) {
for banned in [
"migrate",
"import_local_file",
"import_sheet",
"verify_sheet",
] {
assert!(!line.contains(banned), "a retired tool is back: {line}");
}
}
}
/// The runbook names only tools and commands that exist: each
/// `books.<name>` is in the scopes table (the 8.0.3 list of tools), each
/// `wanderbooks <command>` is a command `main.rs` dispatches, and each
/// test file it names is a file.
#[test]
fn the_re_audit_runbook_names_only_what_exists() {
let runbook = read("docs/runbooks/re-audit.md");
let scopes = read("crates/ledger-mcp/src/scopes.rs");
let main = read("crates/ledger-cli/src/main.rs");
let mut tools = Vec::new();
let mut rest = runbook.as_str();
while let Some(i) = rest.find("books.") {
let tail = &rest[i..];
let end = tail
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.'))
.unwrap_or(tail.len());
let name = tail[..end].trim_end_matches('.');
// Prose such as "books.start's" ends at the apostrophe; a bare
// "books." (the company's books followed by a full stop) is not a name.
if name.len() > "books.".len() && !tools.contains(&name.to_string()) {
tools.push(name.to_string());
}
rest = &tail[end.max(1)..];
}
assert!(
tools.len() > 20,
"the runbook should name its tools: {tools:?}"
);
let unknown: Vec<&String> = tools
.iter()
.filter(|t| !scopes.contains(&format!("(\"{t}\"")))
.collect();
assert!(
unknown.is_empty(),
"tools the runbook names that do not exist: {unknown:?}"
);
for cmd in ["gui", "connect"] {
if runbook.contains(&format!("wanderbooks {cmd}")) {
assert!(main.contains(&format!("\"{cmd}\"")), "no command {cmd}");
}
}
assert!(
runbook.contains("wanderbooks gui"),
"step 6 names the viewer"
);
for step in 1..=8 {
assert!(
runbook.contains(&format!("## Step {step}.")),
"the runbook lost step {step}"
);
}
// The runbook must not point at the data root or tell anyone to read it.
assert!(!runbook.contains("wanderbooks.v"), "names a database file");
}
File diff suppressed because it is too large. Load diff
+779
View File
@@ -0,0 +1,779 @@
//! Drafts, the evidence gate and the preview digest over the wire (plan
//! P4; rules.md B-43, D-47, D-50): a draft waits for its receipt and
//! posts when it arrives, a discard unblocks a close, a report and the CPA
//! package say when drafts are open, a reversal needs no receipt of its
//! own, and every tool with `confirmed` takes the `preview` it returned
//! and refuses a stale one.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — drafts",
//! "The preview digest") and rules.md B-43. Every call goes through a real
//! books server as an agent reaches it (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::{Value, json};
/// Cash at 1000, the default 25.00 receipt threshold.
const SETTINGS: &str = "[company]\nname = \"Drafts Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\n";
fn chart(w: &mut common::Wire) {
for (code, name, ty) in [
("1000", "Cash", "asset"),
("3000", "Capital", "equity"),
("4000", "Sales", "revenue"),
("6000", "Rent", "expense"),
] {
answer_of(&w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
))
.unwrap();
}
}
/// A draft of `amount` rent paid from cash on `date`.
fn draft(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
let r = answer_of(&w.call(
"books.draft_entry",
json!({"date": date, "description": what, "no_party_reason": "test"}),
))
.unwrap();
let id = r["entry_id"].as_u64().unwrap();
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
))
.unwrap();
}
id
}
fn a_receipt(w: &mut common::Wire, entry: u64) -> u64 {
let bytes = b"%PDF-1.4 a receipt";
let upload = w.upload(bytes, "receipt.pdf", "application/pdf", "receipt");
answer_of(&w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
))
.unwrap()["attachment_id"]
.as_u64()
.unwrap()
}
fn setup(tag: &str) -> (Books, common::Wire) {
let books = Books::with(tag, "USD", Some(SETTINGS));
let mut w = books.session("p4");
chart(&mut w);
(books, w)
}
/// The refusal a call met, parsed when it is structured.
fn refusal(r: &Value) -> Value {
let text = answer_of(r).expect_err("expected a refusal");
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
/// A draft finished but waiting for its receipt says so, is listed as
/// missing its evidence, is refused at the post, and posts the moment the
/// receipt is attached.
#[test]
fn a_draft_waits_for_its_receipt_and_posts() {
let (_books, mut w) = setup("p4-waits");
let id = draft(&mut w, "2026-03-01", "Figma annual plan", "100.00");
let set = answer_of(&w.call(
"books.set_draft_state",
json!({"entry_id": id, "state": "awaiting_evidence", "note": "Figma invoice due"}),
))
.unwrap();
assert_eq!(set["state"], "awaiting_evidence");
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
let d = &listed["drafts"][0];
assert_eq!(d["entry_id"], id);
assert_eq!(d["state"], "awaiting_evidence");
assert_eq!(d["note"], "Figma invoice due");
assert_eq!(d["total"], "100.00");
assert_eq!(d["missing_evidence"], true);
let waiting = answer_of(&w.call(
"books.list_drafts",
json!({"state": "awaiting_evidence", "as_of": "2026-03-31"}),
))
.unwrap();
assert_eq!(waiting["drafts"].as_array().unwrap().len(), 1);
let building = answer_of(&w.call("books.list_drafts", json!({"state": "building"}))).unwrap();
assert!(building["drafts"].as_array().unwrap().is_empty());
// The preview names the need; the confirm is refused with the error the
// MCP always gave.
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
assert_eq!(p["status"], "preview");
assert_eq!(p["receipt_required"], true);
assert_eq!(p["receipt_missing"], true);
let digest = p["preview"].as_str().unwrap().to_string();
let r = w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "preview": digest}),
);
let err = refusal(&r);
assert_eq!(err["code"], "receipt_required", "{err}");
// The receipt arrives; the same draft posts.
a_receipt(&mut w, id);
let r = w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let posted = answer_of(&r).unwrap();
assert_eq!(posted["status"], "posted");
let listed = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
assert!(listed["drafts"].as_array().unwrap().is_empty());
}
/// B-43: the structured `receipt_required` error is the one the MCP built
/// before the gate moved into the store.
#[test]
fn the_gate_moved_and_the_mcp_error_is_unchanged() {
let (_books, mut w) = setup("p4-error");
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
let r = w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
let err = refusal(&r);
assert_eq!(err["code"], "receipt_required");
assert_eq!(err["entry_id"], id);
assert_eq!(err["debits"], "100.00");
assert_eq!(
err["policy"],
"receipts required when entry debits are strictly greater than 25.00"
);
assert_eq!(err["remedy_tool"], "books.attach_receipt");
assert!(
err["message"]
.as_str()
.unwrap()
.starts_with(&format!("entry {id} moves 100.00 in debits ("))
);
assert!(
err["remedy_hint"]
.as_str()
.unwrap()
.contains("then retry books.post_entry with confirmed=true")
);
assert!(err.get("split_group").is_none());
}
/// D-47: a reversal posts at once with no receipt of its own; its evidence
/// is its original's.
#[test]
fn a_reversal_needs_no_receipt_of_its_own() {
let (_books, mut w) = setup("p4-reversal");
let id = draft(&mut w, "2026-03-01", "Rent", "100.00");
a_receipt(&mut w, id);
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
let r = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": id, "date": "2026-03-05", "description": "Undo", "confirmed": true}),
))
.unwrap();
assert_eq!(r["status"], "posted");
let d = answer_of(&w.call("books.digest", json!({"since": "2026-03-01"}))).unwrap();
for e in d["entries"].as_array().unwrap() {
if e["reversal_of"].is_number() {
assert_eq!(e["receipt_required"], false, "{e}");
assert_eq!(e["receipt_missing"], false, "{e}");
}
}
}
/// A period holding an open draft cannot close; discarded (previewed, then
/// confirmed with its digest), it can.
#[test]
fn discard_unblocks_the_close_over_the_wire() {
let (_books, mut w) = setup("p4-discard");
let seed = draft(&mut w, "2026-03-01", "Seed", "10.00");
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": seed, "confirmed": true}),
))
.unwrap();
let per = answer_of(&w.call(
"books.create_period",
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
))
.unwrap()["period_id"]
.as_u64()
.unwrap();
let id = draft(&mut w, "2026-03-20", "Entered twice", "40.00");
let r = w.call(
"books.close_period",
json!({"period_id": per, "confirmed": true}),
);
assert!(
answer_of(&r).is_err(),
"a close is refused with a draft open"
);
let p = answer_of(&w.call(
"books.discard_draft",
json!({"entry_id": id, "reason": "entered twice"}),
))
.unwrap();
assert_eq!(p["status"], "preview");
assert_eq!(p["holds_open"], json!(["2026-03"]));
let digest = p["preview"].as_str().unwrap().to_string();
// A reason is the preview's; a different one is a different preview.
let r = w.call_raw(
"books.discard_draft",
json!({"entry_id": id, "reason": "something else", "confirmed": true, "preview": digest}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
let done = answer_of(&w.call_raw(
"books.discard_draft",
json!({"entry_id": id, "reason": "entered twice", "confirmed": true, "preview": digest}),
))
.unwrap();
assert_eq!(done["status"], "discarded");
assert!(
answer_of(&w.call("books.list_drafts", json!({}))).unwrap()["drafts"]
.as_array()
.unwrap()
.is_empty()
);
let closed = answer_of(&w.call(
"books.close_period",
json!({"period_id": per, "confirmed": true}),
))
.unwrap();
assert_eq!(closed["status"], "closed");
// Only a draft: the posted seed is never discarded.
let r = w.call(
"books.discard_draft",
json!({"entry_id": seed, "reason": "no"}),
);
assert!(answer_of(&r).unwrap_err().contains("posted"));
}
/// A report says what is open as of its end; strict refuses instead.
#[test]
fn report_warns_on_draft_before_as_of() {
let (_books, mut w) = setup("p4-report");
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
let open = &tb["as_of_status"]["drafts_open_on_or_before"];
assert_eq!(open[0]["entry_id"], id);
assert_eq!(open[0]["state"], "building");
let early = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-01"}))).unwrap();
assert!(
early["as_of_status"]["drafts_open_on_or_before"]
.as_array()
.unwrap()
.is_empty(),
"a draft dated after the report's end is not open in it"
);
for tool in [
"books.trial_balance",
"books.balance_sheet",
"books.profit_and_loss",
"books.cash_flow",
] {
let args = match tool {
"books.trial_balance" | "books.balance_sheet" => {
json!({"as_of": "2026-03-31", "strict": true})
}
_ => json!({"start": "2026-03-01", "end": "2026-03-31", "strict": true}),
};
let r = w.call(tool, args);
let why = answer_of(&r).expect_err(tool);
assert!(
why.contains("strict") && why.contains("draft"),
"{tool}: {why}"
);
}
// Posted, the report is clean and strict gives it.
a_receipt(&mut w, id);
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
))
.unwrap();
let ok = answer_of(&w.call(
"books.trial_balance",
json!({"as_of": "2026-03-31", "strict": true}),
));
// Cash is not reconciled, so strict still names it: the refusal says
// so and not "draft".
if let Err(why) = ok {
assert!(!why.contains("draft"), "{why}");
}
}
/// The CPA package is refused while a draft dated in it is open, unless
/// the human says why; the reason is journaled as a decision.
#[test]
fn cpa_package_refuses_open_draft() {
let (books, mut w) = setup("p4-cpa");
let id = draft(&mut w, "2026-03-10", "Open", "40.00");
let args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"]});
let err = refusal(&w.call("books.export_cpa_package", args.clone()));
assert_eq!(err["code"], "open_drafts", "{err}");
assert!(err["message"].as_str().unwrap().contains(&format!("#{id}")));
// A draft dated after as_of does not stop it.
let before = books.journal().len();
let mut allowed = args.clone();
allowed["allow_open_drafts"] = json!({"reason": "the CPA wants it before the receipt comes"});
let out = answer_of(&w.call("books.export_cpa_package", allowed)).unwrap();
assert!(out["file"].is_object(), "{out}");
let journal = String::from_utf8(books.journal()).unwrap();
assert!(books.journal().len() > before);
assert!(journal.contains("decision.recorded"));
assert!(journal.contains("the CPA wants it before the receipt comes"));
}
/// D-50: a confirm takes the preview it was shown; without it, or after a
/// changed argument or a write by another session, it is refused with
/// `stale_preview` before anything is written.
#[test]
fn stale_preview_refused() {
let (books, mut w) = setup("p4-stale");
let id = draft(&mut w, "2026-03-01", "Rent", "10.00");
// A draft's preview is the draft's state: a line added since is stale.
let p = answer_of(&w.call("books.preview_entry", json!({"entry_id": id}))).unwrap();
let digest = p["preview"].as_str().unwrap().to_string();
// The same preview from post_entry itself.
let p2 = answer_of(&w.call("books.post_entry", json!({"entry_id": id}))).unwrap();
assert_eq!(p2["preview"], p["preview"]);
// No preview at all.
let r = w.call_raw(
"books.post_entry",
json!({"entry_id": id, "confirmed": true}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
// A line added since the preview.
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": "6000", "side": "debit", "amount": "1.00"}),
))
.unwrap();
answer_of(&w.call(
"books.add_line",
json!({"entry_id": id, "account": "1000", "side": "credit", "amount": "1.00"}),
))
.unwrap();
let before = books.journal();
let r = w.call_raw(
"books.post_entry",
json!({"entry_id": id, "confirmed": true, "preview": digest}),
);
let err = refusal(&r);
assert_eq!(err["code"], "stale_preview");
assert_eq!(err["remedy_tool"], "books.post_entry");
assert_eq!(books.journal(), before, "a stale confirm writes nothing");
// An operation's preview moves with the journal's head: a post by
// another session in between forces a re-preview.
let other = draft(&mut w, "2026-03-02", "Other", "5.00");
let first = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
));
assert!(first.is_err(), "a draft is not reversible");
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": other, "confirmed": true}),
))
.unwrap();
let p = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x"}),
))
.unwrap();
let digest = p["preview"].as_str().unwrap().to_string();
// The same call with another description is another preview...
let r = w.call_raw(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "y",
"confirmed": true, "preview": digest}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
// ...and so is a write between preview and confirm.
answer_of(&w.call(
"books.add_account",
json!({"code": "6100", "name": "Software", "account_type": "expense"}),
))
.unwrap();
let r = w.call_raw(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
"confirmed": true, "preview": digest}),
);
assert_eq!(refusal(&r)["code"], "stale_preview");
// Re-previewed, it confirms.
let r = w.call(
"books.reverse_entry",
json!({"entry_id": other, "date": "2026-03-03", "description": "x",
"confirmed": true}),
);
assert_eq!(answer_of(&r).unwrap()["status"], "posted");
}
/// Every tool that takes `confirmed` takes `preview` (D-50, from P4 on).
#[test]
fn every_confirm_tool_takes_a_preview() {
let books = Books::new("p4-schema");
let mut w = books.session("p4");
let tools = w.rpc("tools/list", json!({}))["result"]["tools"].clone();
let mut confirm = Vec::new();
for t in tools.as_array().expect("tools") {
let props = &t["inputSchema"]["properties"];
if props.get("confirmed").is_some() {
confirm.push(t["name"].as_str().unwrap().to_string());
assert!(
props.get("preview").is_some(),
"{} takes confirmed but not preview",
t["name"]
);
}
}
for tool in [
"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.discard_draft",
] {
assert!(confirm.iter().any(|t| t == tool), "{tool} has no confirmed");
}
}
/// Defect 6: an entry dated in the past but posted just now is in the
/// digest asked for by the journal sequence number, and not in the one
/// asked for by date.
#[test]
fn digest_since_seq_is_posting_time() {
let (_books, mut w) = setup("p4-digest");
let early = draft(&mut w, "2026-01-05", "Backdated", "20.00");
let head =
answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap()["head_seq"]
.as_u64()
.unwrap();
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": early, "confirmed": true}),
))
.unwrap();
let by_seq = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
let ids: Vec<u64> = by_seq["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![early]);
assert!(by_seq["head_seq"].as_u64().unwrap() > head);
let by_date = answer_of(&w.call("books.digest", json!({"since": "2026-02-01"}))).unwrap();
assert!(by_date["entries"].as_array().unwrap().is_empty());
// Nothing after the new head.
let none =
answer_of(&w.call("books.digest", json!({"since_seq": by_seq["head_seq"]}))).unwrap();
assert!(none["entries"].as_array().unwrap().is_empty());
// An account touched whose net movement is zero is listed, not lost.
let second = draft(&mut w, "2026-01-06", "Refund", "20.00");
// (reverse the sign on the same accounts: debit cash, credit rent)
answer_of(&w.call(
"books.post_entry",
json!({"entry_id": second, "confirmed": true}),
))
.unwrap();
let rev = answer_of(&w.call(
"books.reverse_entry",
json!({"entry_id": second, "date": "2026-01-07", "description": "Undo",
"confirmed": true}),
))
.unwrap();
assert_eq!(rev["status"], "posted");
let d = answer_of(&w.call("books.digest", json!({"since_seq": head}))).unwrap();
let zero: Vec<&str> = d["zero_net_accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert!(
zero.is_empty(),
"the first entry still moves rent: {zero:?}"
);
let only_pair = answer_of(&w.call(
"books.digest",
json!({"since": "2026-01-06", "as_of": "2026-01-31"}),
))
.unwrap();
let zero: Vec<&str> = only_pair["zero_net_accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
assert_eq!(zero, vec!["1000 Cash", "6000 Rent"]);
assert_eq!(only_pair["drafts_by_state"], json!({}));
assert_eq!(only_pair["never_reconciled"], json!(["1000"]));
}
/// The viewer and the server ask one function whether an entry lacks its
/// evidence (plan P4 step 2, I-5): on random books under three policies
/// the viewer's missing-evidence set equals the server's, and both equal
/// what the viewer decided before the gate moved (an entry's origin read
/// from the journal's events: a hand entry or a recurring draft is
/// covered; a document's, a payment's, a close, a schedule month's, a
/// depreciation's, a payroll's and a reversal are not).
#[cfg(feature = "gui")]
#[test]
fn gui_and_server_gate_agree() {
use chrono::NaiveDate;
use ledger_core::settings::{Change, Source};
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_gui::data;
use rust_decimal::Decimal;
use std::collections::{BTreeMap, BTreeSet};
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD);
for (n, (threshold, required, exempt)) in [
("0.00", vec![], vec![]),
("25.00", vec!["6200"], vec!["6300"]),
("100.00", vec![], vec!["6300", "6100"]),
]
.into_iter()
.enumerate()
{
let root = tempfile::Builder::new()
.prefix(&format!("wb-p4-gate-{n}-"))
.tempdir()
.unwrap();
let dir = root.path().join("book");
std::fs::create_dir_all(&dir).unwrap();
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
st.change_settings(
&[
Change {
key: "policy.evidence.threshold".into(),
value: Some(json!(threshold)),
},
Change {
key: "policy.evidence.required_accounts".into(),
value: Some(json!(required)),
},
Change {
key: "policy.evidence.exempt_accounts".into(),
value: Some(json!(exempt)),
},
],
&Source {
decided_by: "test".into(),
decided_on: day(1, 1),
citation: None,
note: None,
},
)
.unwrap();
let mut acct =
|code: &str, name: &str, ty| st.add_account(code, name, ty, None, false).unwrap();
let bank = acct("1000", "Bank", AccountType::Asset);
let prepaid = acct("1300", "Prepaid", AccountType::Asset);
let capital = acct("3000", "Capital", AccountType::Equity);
let sales = acct("4000", "Sales", AccountType::Revenue);
let expenses = [
acct("6100", "Supplies", AccountType::Expense),
acct("6200", "Contractors", AccountType::Expense),
acct("6300", "Fees", AccountType::Expense),
];
let seed = st.create_entry(day(1, 1), "Capital").unwrap();
st.add_line(seed, bank, Side::Debit, usd(10_000_000))
.unwrap();
st.add_line(seed, capital, Side::Credit, usd(10_000_000))
.unwrap();
st.post_entry(seed).unwrap();
// A deterministic pseudo-random run of hand entries.
let mut x: u64 = 0x2545F4914F6CDD1D ^ (n as u64);
let mut next = move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
let mut posted = Vec::new();
for i in 0..36u32 {
let e = st
.create_entry(day(2, 1 + (i % 27)), &format!("Entry {i}"))
.unwrap();
let cents = 100 + (next() % 30_000) as i64;
let ex = expenses[(next() % 3) as usize];
st.add_line(e, ex, Side::Debit, usd(cents)).unwrap();
st.add_line(e, bank, Side::Credit, usd(cents)).unwrap();
if next() % 3 == 0 {
st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
}
// Some stay drafts.
if next() % 6 != 0 {
st.post_entry(e).unwrap();
posted.push(e);
}
}
for e in posted.iter().take(5) {
st.reverse_entry(*e, day(3, 2), "Reversed").unwrap();
}
let customer = st.add_customer("Fernhill Labs").unwrap();
let inv = st
.create_invoice(
customer,
day(3, 3),
day(4, 2),
vec![ledger_core::arap::DocumentLine {
description: "Design".into(),
amount: Decimal::new(250_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
st.receive_payment(inv, day(3, 20), Decimal::new(100_000, 2), bank)
.unwrap();
let sched = st
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd(120_000),
start: day(1, 1),
months: 12,
balance_account: prepaid,
pl_account: expenses[0],
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
st.post_schedule_month(sched, day(1, 31)).unwrap();
let _ = sales;
let per = st.create_period("2026-01", day(1, 1), day(1, 31)).unwrap();
let _ = st.close_period(per);
st.register_recurring(ledger_core::NewRecurring {
description: "Figma".into(),
lines: vec![
(expenses[0], Side::Debit, usd(4_000)),
(bank, Side::Credit, usd(4_000)),
],
frequency: ledger_core::Frequency::Monthly,
start: day(5, 10),
end: None,
})
.unwrap();
st.run_recurring(day(5, 31)).unwrap();
// What the server decides: the engine, with the receipts filed.
let server: BTreeSet<u64> = (1..st.ledger().next_entry_id())
.filter(|id| {
let Some(e) = st.ledger().get_entry(*id) else {
return false;
};
ledger_core::evidence::requirement(st.ledger(), e, st.ledger().settings())
.is_required()
&& st.list_attachments(Some(*id)).unwrap().is_empty()
})
.collect();
// What the viewer decided before: origin from the journal's events.
let policy = ledger_core::evidence::Policy::from_settings(st.ledger().settings());
let journal = String::from_utf8(std::fs::read(dir.join("journal.jsonl")).unwrap()).unwrap();
let mut refs: BTreeMap<u64, Vec<String>> = BTreeMap::new();
for line in journal.lines() {
let v: Value = serde_json::from_str(line).unwrap();
let ty = v["type"].as_str().unwrap().to_string();
let p = &v["payload"];
let mut ids = Vec::new();
if ty.starts_with("entry.") {
ids.extend(p["id"].as_u64());
}
ids.extend(p["entry_id"].as_u64());
ids.extend(p["closing_entry"].as_u64());
ids.extend(p["entry"]["id"].as_u64());
for id in ids {
refs.entry(id).or_default().push(ty.clone());
}
}
let closings = st.ledger().closing_entry_ids();
let old: BTreeSet<u64> = (1..st.ledger().next_entry_id())
.filter(|id| {
let Some(e) = st.ledger().get_entry(*id) else {
return false;
};
let has = |t: &str| refs.get(id).is_some_and(|v| v.iter().any(|x| x == t));
let by_hand = !closings.contains(id)
&& !has("entry.reversed")
&& !has("schedule.posted")
&& !has("asset.depreciation_posted")
&& !has("asset.disposed")
&& !has("bill.created")
&& !has("bill.paid")
&& !has("invoice.created")
&& !has("invoice.payment_received")
&& !has("payroll.run")
&& !has("payroll.taxes_remitted");
by_hand
&& e.reversal_of().is_none()
&& st.list_attachments(Some(*id)).unwrap().is_empty()
&& policy.receipt_required(st.ledger(), e)
})
.collect();
drop(st);
let books = data::load_once(&data::Source {
data_dir: dir.clone(),
today: Some(day(6, 1)),
})
.unwrap();
let viewer: BTreeSet<u64> = (1..=books.ledger.next_entry_id())
.filter(|id| books.entry(*id).is_some_and(|e| e.missing_receipt))
.collect();
assert!(!server.is_empty(), "policy {threshold}: nothing to compare");
assert_eq!(viewer, server, "policy {threshold}: viewer vs server");
assert_eq!(server, old, "policy {threshold}: engine vs the old reading");
}
}
/// Books an older version wrote hold a reversal and drafts (the fixture
/// `p4_before`): opened under this version, with no upgrade, the figures
/// are the writer's, the reversal needs no receipt of its own, and the
/// drafts are open and building.
#[test]
fn legacy_reversed_replays_identically() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p4_before");
let only = tempfile::tempdir().unwrap();
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
let books = Books::placed("p4-legacy", only.path());
let mut w = books.session("p4");
let d = answer_of(&w.call("books.digest", json!({"since": "2026-01-01"}))).unwrap();
let reversals: Vec<&Value> = d["entries"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["reversal_of"].is_number())
.collect();
assert_eq!(reversals.len(), 1);
assert_eq!(reversals[0]["receipt_required"], false);
assert_eq!(reversals[0]["receipt_missing"], false);
let drafts = answer_of(&w.call("books.list_drafts", json!({}))).unwrap();
let open: Vec<&Value> = drafts["drafts"].as_array().unwrap().iter().collect();
assert_eq!(open.len(), 3, "{drafts}");
assert!(open.iter().all(|d| d["state"] == "building"));
assert_eq!(d["drafts_by_state"], json!({"building": 3}));
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-12-31"}))).unwrap();
assert_eq!(tb["balanced"], true);
}
+469
View File
@@ -0,0 +1,469 @@
//! The evidence pipeline over the wire (plan P6): a receipt's text and
//! pages, annotations, finding a file, consolidating duplicates, and the
//! backfill command.
//!
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools —
//! evidence", "Tools — receipts & documents") and rules.md B-44. Every
//! call goes through a real books server as an agent reaches it
//! (`common.rs`).
mod common;
use common::{Books, answer_of};
use serde_json::json;
use std::path::{Path, PathBuf};
/// The settings a test's books start with: a high receipt threshold (so a
/// simple posting needs no receipt) and cash at 1000.
const SETTINGS: &str = "[company]\nname = \"Evidence Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\ncash_accounts = [\"1000\"]\nreceipt_threshold = \"1000.00\"\n";
/// A PDF with one page that says the word "Rent" (the extractor's own
/// typesetter, so the text is real and the page renders).
fn a_pdf() -> Vec<u8> {
use ledger_import::report::{ReportMeta, Row, RowKind, statement};
use rust_decimal::Decimal;
let st = statement(
"Receipt",
"March 2026".to_string(),
&["Total"],
vec![Row {
kind: RowKind::Line,
label: "Rent".into(),
amounts: vec![Some(Decimal::new(10000, 2))],
currency: true,
rule_above: false,
}],
);
let meta = ReportMeta {
company: "Evidence Co".into(),
basis: "Accrual".into(),
prepared_on: chrono::NaiveDate::from_ymd_opt(2026, 3, 2).unwrap(),
journal_seq: 1,
};
ledger_import::statement_pdf(&st, &meta).unwrap()
}
/// A draft with two lines, and the accounts made for it.
fn a_draft(w: &mut common::Wire) -> u64 {
for (code, name, ty) in [("1000", "Cash", "asset"), ("6000", "Rent", "expense")] {
let r = w.call(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
);
if let Err(e) = answer_of(&r) {
assert!(e.contains("already"), "{e}");
}
}
let r = w.call(
"books.draft_entry",
json!({"date": "2026-03-01", "description": "March rent"}),
);
let id = answer_of(&r).unwrap()["entry_id"].as_u64().unwrap();
for (account, side, amount) in [("6000", "debit", "100.00"), ("1000", "credit", "100.00")] {
let r = w.call(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
answer_of(&r).unwrap();
}
id
}
/// File `bytes` on `entry` through an upload, as an agent does (D-46).
fn file(w: &mut common::Wire, entry: u64, bytes: &[u8], name: &str, mime: &str) -> u64 {
let upload = w.upload(bytes, name, mime, "receipt");
let r = w.call(
"books.attach_receipt",
json!({"entry_id": entry, "upload_id": upload}),
);
answer_of(&r).unwrap()["attachment_id"].as_u64().unwrap()
}
/// A PNG's width and height, from its IHDR.
fn png_size(bytes: &[u8]) -> (u32, u32) {
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "not a PNG");
let w = u32::from_be_bytes(bytes[16..20].try_into().unwrap());
let h = u32::from_be_bytes(bytes[20..24].try_into().unwrap());
(w, h)
}
/// A book folder with two live receipts of one file's bytes, filed by an
/// older version on two entries (plan P6 step 4: legacy duplicates). With
/// `withdraw_first`, the first is already withdrawn, as a duplicate of the
/// second, so the second is its entry's only evidence.
fn legacy_duplicates(only: &str, withdraw_first: bool) -> (tempfile::TempDir, PathBuf) {
use ledger_core::{AccountType, Currency, Money, Side};
let root = tempfile::Builder::new()
.prefix(&format!("wb-p6-{only}-"))
.tempdir()
.unwrap();
let dir = root.path().join("book");
std::fs::create_dir_all(&dir).unwrap();
let mut s = ledger_store::Store::create(&dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let expense = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let mut entries = Vec::new();
for (d, what) in [("2026-03-01", "Rent one"), ("2026-03-02", "Rent two")] {
let e = s
.create_entry(
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap(),
what,
)
.unwrap();
let m = Money::new("100.00".parse().unwrap(), Currency::USD);
s.add_line(e, expense, Side::Debit, m).unwrap();
s.add_line(e, cash, Side::Credit, m).unwrap();
s.post_entry(e).unwrap();
entries.push(e);
}
drop(s);
let bytes = b"one receipt, filed twice by an older version";
let (sha, _) = ledger_store::BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
for (id, entry, name) in [(1u64, entries[0], "one.txt"), (2, entries[1], "two.txt")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": entry,
"category": "receipts", "file_name": name,
"mime_type": "text/plain", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
if withdraw_first {
w.append(
"receipt.unlinked",
json!({"attachment_id": 1, "entry_id": entries[0], "reason": "duplicate"}),
)
.unwrap();
w.append(
"attachment.withdrawn",
json!({"attachment_id": 1, "reason": "duplicate of #2", "duplicate_of": 2}),
)
.unwrap();
}
drop(w);
for f in [
ledger_store::db_file(),
ledger_store::LOCK_FILE.to_string(),
format!("{}-wal", ledger_store::db_file()),
format!("{}-shm", ledger_store::db_file()),
] {
let _ = std::fs::remove_file(dir.join(f));
}
(root, dir)
}
/// Plan P6 step 3: the default answer is what the file is, the first 8 KiB
/// of its text and its pages; never its bytes, and small enough to read.
#[test]
fn get_receipt_default_has_text_and_no_bytes() {
let books = Books::with("p6-meta", "USD", Some(SETTINGS));
let mut w = books.session("evidence");
let entry = a_draft(&mut w);
let pdf = a_pdf();
let id = file(&mut w, entry, &pdf, "receipt.pdf", "application/pdf");
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let out = answer_of(&r).unwrap();
assert_eq!(out["text_status"], "ok", "{out}");
assert_eq!(out["pages"], 1, "{out}");
let text = out["text"].as_str().unwrap();
assert!(text.contains("Rent"), "{text}");
assert!(text.len() <= 8 * 1024);
assert_eq!(out["text_truncated"], false);
assert!(out.get("file").is_none(), "no file in the default answer");
assert!(
serde_json::to_string(&out).unwrap().len() < 8 * 1024,
"the default answer is under 8 KiB"
);
assert!(!serde_json::to_string(&out).unwrap().contains("base64"));
// view text: the whole text, no file.
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "text"}),
);
let out = answer_of(&r).unwrap();
assert_eq!(out["text"].as_str().unwrap(), text);
// The resource reads the same: metadata plus text.
let r = w.rpc(
"resources/read",
json!({"uri": format!("books://attachments/{id}")}),
);
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
assert!(text.contains("\"attachment_id\""), "{text}");
assert!(text.contains("Rent"), "{text}");
}
/// Plan P6 step 3: one page as a PNG, handed over; page numbers start at
/// 1; a page past the last is refused.
#[test]
fn page_image_is_a_png_of_the_page() {
let books = Books::with("p6-page", "USD", Some(SETTINGS));
let mut w = books.session("evidence");
let entry = a_draft(&mut w);
let id = file(&mut w, entry, &a_pdf(), "receipt.pdf", "application/pdf");
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "page_image", "page": 1, "max_px": 300}),
);
let out = answer_of(&r).unwrap();
let file = &out["file"];
assert_eq!(file["mime_type"], "image/png");
let png = w.fetch(file);
let (width, height) = png_size(&png);
assert!(width.max(height) <= 300, "{width}x{height}");
assert!(width > 0 && height > 0);
// The page resource gives a PNG of the page too (its size its own:
// a resource takes no max_px).
let r = w.rpc(
"resources/read",
json!({"uri": format!("books://attachments/{id}/page/1")}),
);
let contents = &r["result"]["contents"][0];
assert_eq!(contents["mimeType"], "image/png", "{contents}");
let encoded = contents["blob"].as_str().unwrap();
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
png_size(&bytes);
// Past the last page: refused.
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "page_image", "page": 2}),
);
assert!(answer_of(&r).is_err());
// bytes still hands the file over, unchanged.
let r = w.call(
"books.get_receipt",
json!({"attachment_id": id, "view": "bytes"}),
);
let out = answer_of(&r).unwrap();
assert_eq!(w.fetch(&out["file"]), a_pdf());
}
/// Plan P6 steps 4 and 5: annotations, the engine's numbers, the three
/// ways to find a file, and a settlement recorded.
#[test]
fn annotations_and_searches() {
let books = Books::with("p6-find", "USD", Some(SETTINGS));
let mut w = books.session("evidence");
let entry = a_draft(&mut w);
let text = b"Harbor Design LLC\nInvoice H-1042\nHosting, 42.00, 2026-03-01\n";
let id = file(&mut w, entry, text, "harbor.txt", "text/plain");
let r = w.call(
"books.annotate_attachment",
json!({"attachment_id": id, "fields": {
"receipt_number": 7, "payee": "Harbor Design LLC", "amount": "42.00",
"date": "2026-03-01", "instrument": "Check 1042",
"source": "the owner's email", "reimbursable_to": "Avery Quill"
}, "note": "read on 2026-03-02"}),
);
let out = answer_of(&r).unwrap();
assert_eq!(out["fields"]["receipt_number"], 7);
assert_eq!(out["fields"]["amount"], "42.00");
assert_eq!(out["next_receipt_number"], 8);
// Meta carries the annotation and the number.
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let out = answer_of(&r).unwrap();
assert_eq!(out["receipt_number"], 7);
assert_eq!(out["annotation"]["payee"], "Harbor Design LLC");
// Found three ways.
for args in [
json!({"receipt_number": 7}),
json!({"sha256": ledger_store::journal::sha256_hex(text)}),
json!({"text": "harbor design"}),
] {
let r = w.call("books.find_attachment", args.clone());
let out = answer_of(&r).unwrap();
assert!(
out["attachments"]
.as_array()
.unwrap()
.iter()
.any(|a| a["attachment_id"] == id),
"{args}: {out}"
);
}
// Exactly one selector.
let r = w.call(
"books.find_attachment",
json!({"sha256": ledger_store::journal::sha256_hex(text), "text": "x"}),
);
assert!(answer_of(&r).is_err());
// A settlement is recorded on the paying entry and read back.
let pay = a_draft(&mut w);
let r = w.call(
"books.set_entry_settles",
json!({"entry_id": pay, "settles": [entry]}),
);
let out = answer_of(&r).unwrap();
assert_eq!(out["settles"], json!([entry]));
let r = w.call(
"books.set_entry_settles",
json!({"entry_id": pay, "settles": [pay]}),
);
assert!(answer_of(&r).is_err(), "an entry settles not itself");
let r = w.call("books.verify", json!({}));
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
}
/// Plan P6 step 4 and rules.md B-44 item 5: consolidation never leaves an
/// entry the receipt policy covers with no evidence.
#[test]
fn consolidation_never_removes_an_entrys_only_evidence() {
let (_root, fixture) = legacy_duplicates("dupes-only", true);
let books = Books::placed("p6-dupes-only", &fixture);
let mut w = books.session("evidence");
let before = books.journal();
let r = w.call("books.consolidate_duplicates", json!({"confirmed": true}));
let why = answer_of(&r).unwrap_err();
assert!(why.contains("receipt_required"), "{why}");
assert_eq!(books.journal(), before, "a refusal journals nothing");
}
/// Plan P6 step 4: the kept file takes every subject the duplicates had;
/// the others are withdrawn as duplicates of it, and nothing is deleted.
#[test]
fn consolidation_links_the_kept_file_to_every_subject() {
let (_root, fixture) = legacy_duplicates("dupes-live", false);
let books = Books::placed("p6-dupes-live", &fixture);
let mut w = books.session("evidence");
let sha = {
let r = w.call("books.get_receipt", json!({"attachment_id": 2}));
answer_of(&r).unwrap()["sha256"]
.as_str()
.unwrap()
.to_string()
};
let r = w.call("books.consolidate_duplicates", json!({}));
let preview = answer_of(&r).unwrap();
assert_eq!(preview["status"], "preview");
assert_eq!(preview["groups"][0]["keep"], 1);
assert_eq!(preview["groups"][0]["withdraw"], json!([2]));
let before = books.journal();
assert_eq!(before, books.journal(), "a preview journals nothing");
let r = w.call("books.consolidate_duplicates", json!({"confirmed": true}));
let out = answer_of(&r).unwrap();
assert_eq!(out["status"], "consolidated", "{out}");
assert_eq!(out["withdrawn"], 1);
// The second entry is still evidenced, by the kept file.
let r = w.call("books.list_receipts", json!({"entry_id": 2}));
let out = answer_of(&r).unwrap();
assert_eq!(out["receipts"].as_array().unwrap().len(), 1, "{out}");
assert_eq!(out["receipts"][0]["attachment_id"], 1);
// The withdrawn one is found, marked, and names what it duplicates.
let r = w.call("books.find_attachment", json!({"sha256": sha}));
let out = answer_of(&r).unwrap();
let found = out["attachments"].as_array().unwrap();
assert_eq!(found.len(), 2, "{out}");
let gone = found.iter().find(|a| a["attachment_id"] == 2).unwrap();
assert_eq!(gone["withdrawn"], true);
let r = w.call("books.verify", json!({}));
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
}
/// Done when (1): every attachment of the `p2-served` fixture gives a
/// default `books.get_receipt` answer under 8 KiB.
#[test]
fn every_p2_served_attachment_has_a_small_default_answer() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served");
let books = Books::placed("p6-p2-served", &fixture);
let mut w = books.session("evidence");
let mut seen = 0;
for id in 1..=64u64 {
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let Ok(out) = answer_of(&r) else { continue };
seen += 1;
let size = serde_json::to_string(&out).unwrap().len();
assert!(size < 8 * 1024, "attachment {id}: {size} bytes");
assert_eq!(out["text_truncated"], false, "attachment {id}");
assert!(out.get("file").is_none(), "attachment {id}");
}
assert!(seen >= 60, "the fixture's attachments: {seen}");
}
/// Done when (4): the backfill reads an older book's files, one
/// attachment at a time, and a second run journals nothing.
#[test]
fn the_backfill_reads_an_old_fixtures_files_once() {
let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books/p6_evidence");
let books = Books::placed("p6-backfill", &fixture);
// The fixture is from before P6: upgrade it (the owner's step), then
// the backfill can journal extraction records.
books.stop();
{
let mut s = ledger_store::Store::open(&books.dir()).unwrap();
s.upgrade_format().unwrap();
s.verify().unwrap();
}
books.start();
let mut w = books.session("evidence");
// Attachment 1 is a PDF (one page with text), 2 a text file, 3 a
// Markdown document.
for id in 1..=2 {
let before = books.journal();
let r = w.call("books.get_receipt", json!({"attachment_id": id}));
let out = answer_of(&r).unwrap();
assert_eq!(out["text_status"], "not_extracted", "{out}");
let answer = books.home.admin(ledger_mcp::admin::Request::Extract {
id: books.id.clone(),
attachment_id: id,
});
match answer {
ledger_mcp::admin::Response::Extracted { status, .. } => assert_eq!(status, "ok"),
other => panic!("{other:?}"),
}
assert_ne!(books.journal(), before, "the backfill journals a record");
}
// The PDF's text is readable now, and its pages render.
let r = w.call("books.get_receipt", json!({"attachment_id": 1}));
let out = answer_of(&r).unwrap();
assert_eq!(out["text_status"], "ok", "{out}");
assert_eq!(out["pages"], 1, "{out}");
assert!(out["text"].as_str().unwrap().contains("Rent"), "{out}");
// The command itself, on the Markdown document (attachment 3), and
// again: a second run journals nothing. The test's own server is
// stopped first: the command opens the books and closes them as the
// host does.
books.stop();
let run = || {
std::process::Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["books", "extract", &books.id, "3", "--root"])
.arg(books.home.root())
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", books.home.dir.path())
.output()
.unwrap()
};
let before = books.journal();
let out = run();
assert!(out.status.success(), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stdout).contains("Read attachment 3"),
"{out:?}"
);
assert_ne!(books.journal(), before, "the command journals a record");
let after = books.journal();
let out = run();
assert!(out.status.success(), "{out:?}");
assert_eq!(books.journal(), after, "an extract journals once");
books.start();
let mut w = books.session("evidence");
let r = w.call("books.verify", json!({}));
assert_eq!(answer_of(&r).unwrap()["status"], "ok");
let r = w.call("books.get_receipt", json!({"attachment_id": 3}));
assert_eq!(answer_of(&r).unwrap()["text_status"], "ok");
}
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 46 Software paid by member 9 2025-09-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 39 Design work 8 2025-08-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 42 Bank fee 8 2025-08-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 18 Consulting 4 2025-04-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 27 Bank fee 5 2025-05-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 44 Design work 9 2025-09-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 6 Software paid by member 1 2025-01-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 20 Supplies 4 2025-04-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 36 Software paid by member 7 2025-07-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 50 Supplies 10 2025-10-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 2 Capital in B 2025-01-02
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 41 Software paid by member 8 2025-08-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 47 Bank fee 9 2025-09-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 61 Software paid by member 12 2025-12-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 60 Supplies 12 2025-12-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 38 Consulting 8 2025-08-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 10 Supplies 2 2025-02-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 31 Software paid by member 6 2025-06-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 52 Bank fee 10 2025-10-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 34 Design work 7 2025-07-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 48 Consulting 10 2025-10-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 64 Insurance year 2025-02-01
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 9 Design work 2 2025-02-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 32 Bank fee 6 2025-06-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 15 Supplies 3 2025-03-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 14 Design work 3 2025-03-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 51 Software paid by member 10 2025-10-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 21 Software paid by member 4 2025-04-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 43 Consulting 9 2025-09-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 8 Consulting 2 2025-02-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 57 Bank fee 11 2025-11-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 53 Consulting 11 2025-11-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 23 Consulting 5 2025-05-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 17 Bank fee 3 2025-03-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 19 Design work 4 2025-04-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 7 Bank fee 1 2025-01-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 45 Supplies 9 2025-09-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 58 Consulting 12 2025-12-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 30 Supplies 6 2025-06-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 56 Software paid by member 11 2025-11-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 63 Laptop 2025-03-01
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 62 Bank fee 12 2025-12-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 12 Bank fee 2 2025-02-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 25 Supplies 5 2025-05-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 4 Design work 1 2025-01-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 49 Design work 10 2025-10-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 22 Bank fee 4 2025-04-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 3 Consulting 1 2025-01-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 5 Supplies 1 2025-01-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 28 Consulting 6 2025-06-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 11 Software paid by member 2 2025-02-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 1 Capital in A 2025-01-02
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 37 Bank fee 7 2025-07-23
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 33 Consulting 7 2025-07-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 29 Design work 6 2025-06-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 13 Consulting 3 2025-03-03
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 35 Supplies 7 2025-07-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 26 Software paid by member 5 2025-05-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 54 Design work 11 2025-11-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 16 Software paid by member 3 2025-03-18
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 55 Supplies 11 2025-11-13
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 24 Design work 5 2025-05-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 59 Design work 12 2025-12-08
@@ -0,0 +1 @@
%PDF-1.4 fictional receipt 40 Supplies 8 2025-08-13
+470
View File
@@ -0,0 +1,470 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-21T21:28:50Z","type":"store.created"}
{"payload":{"features":[],"format":2},"prev":"6f671350b51dcf342c70a96d74fe2e7a51201bf076f8fdf9f56e91ae713b1b93","seq":2,"ts":"2026-10-21T21:28:50Z","type":"journal.format_set"}
{"payload":{"changes":[{"key":"company.name","value":"Fernhill Labs LLC"},{"key":"company.entity_type","value":"multi_member_llc"},{"key":"company.fiscal_year_start","value":"01-01"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-10-21"}},"prev":"0553dd22c23902d6585f2bbaed018c059a9aea58df7406d404bf4137d5ecede1","seq":3,"ts":"2026-10-21T21:28:50Z","type":"settings.set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"108d387a4869358c663077990d942b4ffb5644635236c93a8ad344a298a50227","seq":4,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1300","currency":"USD","id":2,"name":"Prepaid","parent":null,"placeholder":false},"prev":"44a88cabb5d1cf52f7d20efd8faf2ffaac5b2bd65a54988899e27e05cc4deecf","seq":5,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1500","currency":"USD","id":3,"name":"Equipment","parent":null,"placeholder":false},"prev":"e3a5983c198273610292fd01475cf9b94597fad49941dc143ff78c156226cc36","seq":6,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1510","currency":"USD","id":4,"name":"Accumulated depreciation","parent":null,"placeholder":false},"prev":"7776e448d58efadfdb096e404dd3eec87310b250b25fc97630679346f6b4172f","seq":7,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":5,"name":"Accounts receivable","parent":null,"placeholder":false},"prev":"58ca33a2fb8230622c6df46c491accbbe82e52d8be43a49a96f599dee1c9460d","seq":8,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts payable","parent":null,"placeholder":false},"prev":"630e419ad2afbcb3171177430d8701e4931a571ac10f0d5f635c4f37e8bbd965","seq":9,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2050","currency":"USD","id":7,"name":"Company card","parent":null,"placeholder":false},"prev":"dca477176282fc4b6f9adbf3ad5450198c24647eec5ece472127faa0d256f012","seq":10,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":8,"name":"Due to members","parent":null,"placeholder":false},"prev":"d635bc76135ad344c1b6151ecff322849af89e0ea159124704af70dfdeaadff2","seq":11,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":9,"name":"Capital A","parent":null,"placeholder":false},"prev":"ebe9968feff05860f2da8a2e3cd4bc07d7d3f0c3922f037e238fcedfc10f00d7","seq":12,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3001","currency":"USD","id":10,"name":"Capital B","parent":null,"placeholder":false},"prev":"3ae241ef0c699d888c8c50f5bcf6b141ff10963cb4ed3880b6b02eec5268cbdc","seq":13,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3900","currency":"USD","id":11,"name":"Retained earnings","parent":null,"placeholder":false},"prev":"6d13b74f8e9ef3b15f1ea75751fa8070ec0f83f073fc091be0c154a019f030c6","seq":14,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":12,"name":"Consulting revenue","parent":null,"placeholder":false},"prev":"a0db3c9413975175f75fe1c37e6831c4758d331f52fb9378863180162c5ca8ad","seq":15,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":13,"name":"Supplies","parent":null,"placeholder":false},"prev":"78a4ebd277a2f9e3ea76414a0bf8f7406cc3e30dbfa48754366752f940b15075","seq":16,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":14,"name":"Software","parent":null,"placeholder":false},"prev":"5b6f921b6731bddc951abd2912231e2282111b9ad2e1ddb12e108846cd396d44","seq":17,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6200","currency":"USD","id":15,"name":"Depreciation","parent":null,"placeholder":false},"prev":"41b76a17cf130608e358df070a5917f86eeb3c229ba02a7e06322355a3bfaaea","seq":18,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6300","currency":"USD","id":16,"name":"Insurance","parent":null,"placeholder":false},"prev":"2d13667fdebc469d0d59bee761e650325d7a402186dbb84890168adf8d741f7d","seq":19,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6400","currency":"USD","id":17,"name":"Contract labor","parent":null,"placeholder":false},"prev":"819cfd940a1b72d4e4a6e3c9283579cc8c06cf5ff4339a28f88f2c16b24fe88f","seq":20,"ts":"2026-10-21T21:28:50Z","type":"account.added"}
{"payload":{"changes":[{"key":"company.members","value":[{"capital_account":"3000","name":"Avery Quill","share":"0.5"},{"capital_account":"3001","name":"Rowan Pike","share":"0.5"}]},{"key":"books.cash_accounts","value":["1000"]},{"key":"books.financing_accounts","value":["2100"]},{"key":"backup.git_remote","value":"file:///home/swaits/.cache/wanderbooks/p3-rev-data-e2e/p2home/remote.git"},{"key":"backup.folder","value":"/home/swaits/.cache/wanderbooks/p3-rev-data-e2e/p2home/usb"}],"source":{"decided_by":"Avery Quill","decided_on":"2026-10-21"}},"prev":"92acb98bfa80e1e722c6158bbe6e9ed4c8c2fc31ad1adb14b9f61d304043ca6d","seq":21,"ts":"2026-10-21T21:28:50Z","type":"settings.set"}
{"payload":{"id":1,"name":"Avery Quill","roles":["member"]},"prev":"a182ca8fabcebddef6818143575d3749d0e8fc036baf6e312bd0a849f78436cf","seq":22,"ts":"2026-10-21T21:28:50Z","type":"party.added"}
{"payload":{"id":2,"name":"Rowan Pike","roles":["member"]},"prev":"fa48b3d8f4372ee6ae38661251659ce4b4374f54fa51329e974a09ba16815448","seq":23,"ts":"2026-10-21T21:28:50Z","type":"party.added"}
{"payload":{"id":1,"name":"Harbor Design LLC"},"prev":"596325a7614846296dd6d073baf2f543107b665c35328447459012c74855c461","seq":24,"ts":"2026-10-21T21:28:50Z","type":"vendor.added"}
{"payload":{"id":2,"name":"Bigco Supply"},"prev":"c424a9910d582b5f0359b5b6705e7d96dc5bcb1914ced459a4276f27d0e36f01","seq":25,"ts":"2026-10-21T21:28:50Z","type":"vendor.added"}
{"payload":{"id":1,"name":"Cedar Client Co"},"prev":"46c8d1500320b40b9666a5a42d931822f05cf054165d410c5cf5732250bed40f","seq":26,"ts":"2026-10-21T21:28:51Z","type":"customer.added"}
{"payload":{"facts":{"last4":"6780","purged":null,"tin_type":"ein","vault_record":"69788eb06e09d55ca50e3ec73dd403baa06fc5818ce36d34547aa8de3563df43"},"id":3},"prev":"80907e1ddb276655db6cdea50222daa46b589455b4a4d5394bc53d62dc48fcd0","seq":27,"ts":"2026-10-21T21:28:51Z","type":"party.tin_set"}
{"payload":{"facts":{"last4":"3456","purged":null,"tin_type":"ssn","vault_record":"18a2ba28ac3346b30598fb86d645d75b756bc43116cf0c5a78b3ffbd168e537a"},"id":1},"prev":"6be2d55137906b67c54034f40be01ad0d7736c7057ff4b4285a2f0494e920a63","seq":28,"ts":"2026-10-21T21:28:51Z","type":"party.tin_set"}
{"payload":{"form_1099":"nec","tin_on_file":true,"vendor_id":1},"prev":"1607945e13e8fa9b142a7a089d5679a544384d5e44e051042690bac65fdc4f7f","seq":29,"ts":"2026-10-21T21:28:51Z","type":"vendor.1099_set"}
{"payload":{"form_1099":null,"tin_on_file":false,"vendor_id":2},"prev":"8d815aad8481d577c28fd177765464119020cd3223240c3d8a9449eca05763e8","seq":30,"ts":"2026-10-21T21:28:51Z","type":"vendor.1099_set"}
{"payload":{"date":"2025-01-02","description":"Capital in A","id":1},"prev":"e21b02f65b2d01347d2280a81d5c684947acf795a902bc6329c63b48fb636468","seq":31,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":1,"party_id":1},"prev":"4ada99e48cccb19f70a89f57cc2fe28c9d8a38a7b7c204b2c64c8511304e4dc0","seq":32,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":1,"side":"debit"},"prev":"89bd45cc0937030d7d7cfb81d816d341b000bfb020d7bfda50b8b84f89536675","seq":33,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":9,"amount":"5000.00","currency":"USD","id":1,"side":"credit"},"prev":"2dd8bcd4901d1c419d862293a52c42a42c4e77d415df5f623db0d4b4a0d4e484","seq":34,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":1,"blob_sha":"aeb484e66061b3e49eb809f0ddf792afdee2da15cb69885b91ae8c12a4a1e59b","category":"receipts","description":null,"entry_id":1,"file_name":"r1.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"441c5859752bfa9639bef24b417d2b43a7c99d5a755e4820fd987664e3b93910","seq":35,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-02","description":"Capital in A","id":1,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":9,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"dc86cc21e1409de4aa1fe4b966d96e7de7dbb065eaad7852d6782d02d2f8310c","seq":36,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-02","description":"Capital in B","id":2},"prev":"45e788b2c6173364749d188be75732039813e67ea27ce66d2203cb10906e1832","seq":37,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":2,"party_id":2},"prev":"78bf42bcd02c0fa6e5f028baa69f13b3490be06db837734b944a2c63f3acf4c9","seq":38,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"5000.00","currency":"USD","id":2,"side":"debit"},"prev":"5d56e32e035b1305e9aa7bcef55f2fcaf6958f25869c99b958d1f8e82a4f7520","seq":39,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":10,"amount":"5000.00","currency":"USD","id":2,"side":"credit"},"prev":"8d722949f628f0e20e87aaa8b024cbf9987e773b4f7113a37a0ba17d4386d6d1","seq":40,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":2,"blob_sha":"23191deb4999b3f1726e6d382a6c0d020a3eb9ffb2e4f06e6c4208120a2b1dc7","category":"receipts","description":null,"entry_id":2,"file_name":"r2.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"7298f2f2b297b9025d4203a7a07dde44fb99ae473d5d7f0fbab1967a585a133b","seq":41,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-02","description":"Capital in B","id":2,"lines":[{"account":1,"amount":"5000.00","currency":"USD","side":"debit"},{"account":10,"amount":"5000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f1b98f8e16280ce39da64198a583957caa67585c96db16a30d5dc697c1549457","seq":42,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-03","description":"Consulting 1","id":3},"prev":"6816748843a8a19a8327aa5756c751706dee3f93e41a5c13816e5642072f8896","seq":43,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":3,"party_id":5},"prev":"2e9fb36852fca6384a4eed6289e66659453943c0d7c2988d3cdae83abb3352ce","seq":44,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"425.45","currency":"USD","id":3,"side":"debit"},"prev":"a55a24cf00fcfd9097f868cfaa432a4b1d274ffc4c777d8b460e01a71a714aac","seq":45,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"425.45","currency":"USD","id":3,"side":"credit"},"prev":"d5fe8b6df89d5df23335d2292cac76e3200d3e96020a3be40cb819ce71565e95","seq":46,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":3,"blob_sha":"a1fd86e4db54256d3cbcc42c003499e77bea22309d3ca22936c9a3119f951eb8","category":"receipts","description":null,"entry_id":3,"file_name":"r3.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"b0f2ab24fac7904b1c1cd1f98d8043fe94f2300259476149fabdfe728a31f8b3","seq":47,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-03","description":"Consulting 1","id":3,"lines":[{"account":1,"amount":"425.45","currency":"USD","side":"debit"},{"account":12,"amount":"425.45","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9299ff9a6d7d14512b050e9e37bbf6fd0880c8bd4211f0f1c2579236e9a585b0","seq":48,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-08","description":"Design work 1","id":4},"prev":"dabd38384f06af507b8b6440966c7591ec779586137a07d0af17dc49288bf3a1","seq":49,"ts":"2026-10-21T21:28:51Z","type":"entry.created"}
{"payload":{"entry_id":4,"party_id":3},"prev":"741fa40bc0e48646e50d6223d5051bf77d472834f462464f57e8560365a21904","seq":50,"ts":"2026-10-21T21:28:51Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"198.72","currency":"USD","id":4,"side":"debit"},"prev":"3d1fc1b51c92da1e773b9c45f83102ca73b79bdb29b39346ff298e50da7f83cf","seq":51,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"198.72","currency":"USD","id":4,"side":"credit"},"prev":"a5e24ac9d61aa1c8f077b1dc2afa4d54d2193035c61d6a5afb909ee0c3d09e84","seq":52,"ts":"2026-10-21T21:28:51Z","type":"entry.line_added"}
{"payload":{"attachment_id":4,"blob_sha":"967db8ecef54a42031f76d14ef0493b998d08c941bf8a739edf1237f903d4649","category":"receipts","description":null,"entry_id":4,"file_name":"r4.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"5dd3bf41b20fa2d306e946df90d519ec2e187dbc22d3f007206fee08fce75c94","seq":53,"ts":"2026-10-21T21:28:51Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-08","description":"Design work 1","id":4,"lines":[{"account":17,"amount":"198.72","currency":"USD","side":"debit"},{"account":1,"amount":"198.72","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"052111cbc65ecee4c26cd5b628101afbe96dc7cdcfd8e6401515f4f5435cf242","seq":54,"ts":"2026-10-21T21:28:51Z","type":"entry.posted"}
{"payload":{"date":"2025-01-13","description":"Supplies 1","id":5},"prev":"edd15c9fc1945f0b8cce5cd7a595433830fd1eef2788b6b9b0f09e7e2e0b9555","seq":55,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":5,"party_id":4},"prev":"314ccb3da1e29adfde995b4aa48ba4de31ec4bdb5bb905abaa4307017865b6f7","seq":56,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"518.50","currency":"USD","id":5,"side":"debit"},"prev":"329b80dad0d3ddfbf70b48903836835e85b908ec9374fd0843f04b2216dddfdb","seq":57,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"518.50","currency":"USD","id":5,"side":"credit"},"prev":"693c0aa1c33cb0c1bfec75eca43bf0ce7fe078110dc8387b9e8daee72246c698","seq":58,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":5,"blob_sha":"a38f2f89b4c09962c99eca9b842a2382f2bda3726357af210626543cc52f1e7c","category":"receipts","description":null,"entry_id":5,"file_name":"r5.pdf","mime_type":"application/pdf","size_bytes":51},"prev":"794a8c3d0292339a0b8cacd6fa57e14dad135466963c2ed994341edac2f4dccd","seq":59,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-13","description":"Supplies 1","id":5,"lines":[{"account":13,"amount":"518.50","currency":"USD","side":"debit"},{"account":7,"amount":"518.50","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b66e18803ac268c5f2ddd31f076ec3198a4d29375619a8b40a2d7b0edb5992d3","seq":60,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-01-18","description":"Software paid by member 1","id":6},"prev":"d52c34b2f167deb3492e8a7d2e2b50ed8a592e47c5b7a8b1f7cf41d263b15b10","seq":61,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":6,"party_id":1},"prev":"12dd3a4a341d12ec13b2735e91081655de1d76c45b350cbdb1b25e0743aab0e8","seq":62,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"854.19","currency":"USD","id":6,"side":"debit"},"prev":"58a015bb07e9ce1d353eef832298587ffe4d0ef5cfd70b80b25888137ae4e5f4","seq":63,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"854.19","currency":"USD","id":6,"side":"credit"},"prev":"e58d9f1c7e5b0533a7a82455b0cb7a8d60d7eb1ce87c9b03ecf1695517d7c63f","seq":64,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":6,"blob_sha":"17ecf1539551d918be6e6ef9f3decc93c3e093a4985b1153f08d2c18da545b7b","category":"receipts","description":null,"entry_id":6,"file_name":"r6.pdf","mime_type":"application/pdf","size_bytes":66},"prev":"21934f4937d4bf5ccdaca3c967d8ae75d9f03379a9afbe158ffa7f7d12a0a4e0","seq":65,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-18","description":"Software paid by member 1","id":6,"lines":[{"account":14,"amount":"854.19","currency":"USD","side":"debit"},{"account":8,"amount":"854.19","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"51a777f27ddcf09ad862b622bd53e185907af1338799a4d06cb412b3845c8deb","seq":66,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-01-23","description":"Bank fee 1","id":7},"prev":"197bde02ee7ed2a3a12a378984865819548cdb778b6a33c57dac74552f5522c5","seq":67,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":7,"reason":"bank fee"},"prev":"e50d6c5f0b1c2c497dc2f37dd889365e9fc2d7b0a00a35a89c66c9d39b332c3e","seq":68,"ts":"2026-10-21T21:28:52Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":7,"side":"debit"},"prev":"c95c400f054caae9bd95457abb2f534d6ec693e8a826dddc3136d4b25ba37851","seq":69,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":7,"side":"credit"},"prev":"9e6be2d5eea5fbc795e421c3e4f35275ee5d1e8a68d9d18e99eb1492ffac343f","seq":70,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":7,"blob_sha":"7d85565514a409a2d8bbacdfc2c68a2260ad0e311328cd0bda91800600098ed4","category":"receipts","description":null,"entry_id":7,"file_name":"r7.pdf","mime_type":"application/pdf","size_bytes":51},"prev":"ce4ff33af6791a860889ff147bb7725b3e256925f421a9d98d29cd105771dc61","seq":71,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-01-23","description":"Bank fee 1","id":7,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4416f011c27bcc9dafb26d68f4a989df6973c49b86aa396277c990994a29aa19","seq":72,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-03","description":"Consulting 2","id":8},"prev":"86f329829a794b9337f0a756d6dcc8fab39ba4ae96fc3d30201693a39ba050c4","seq":73,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":8,"party_id":5},"prev":"e1ce616977d845dab6738b3753469116c3f773a510389b84fff4f7879a223283","seq":74,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"95.94","currency":"USD","id":8,"side":"debit"},"prev":"8e54df62ea15c54668a59912517daf3655c0d095ddc8ef7d64b2fd13dcef9892","seq":75,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"95.94","currency":"USD","id":8,"side":"credit"},"prev":"245ec39fe29eb8be7ca72c83a0dc45f88bb58dbc0a75a7aa2e8cf0de973697ed","seq":76,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":8,"blob_sha":"57d707ac4c8f4987ad0de50f710d25d20729d276e5bb57963191e07c29c59b7a","category":"receipts","description":null,"entry_id":8,"file_name":"r8.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"a4d90d26d8c94e3d6e8c4861e8b78ad02aca3305fbd1ea4ff3ccef611f9851a4","seq":77,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-03","description":"Consulting 2","id":8,"lines":[{"account":1,"amount":"95.94","currency":"USD","side":"debit"},{"account":12,"amount":"95.94","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"aa51bf5a3c0b3b357a3c9b27619f538c5efe9cf1415c402279b23f4dc930133e","seq":78,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-08","description":"Design work 2","id":9},"prev":"515e776c06c84b658bd6775a915942c25c35d5b5dbc85085647f17bcb8bfe14f","seq":79,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":9,"party_id":3},"prev":"01e212769b1b8a6ff8a60bb62f2315b651a888d095cc91b7e9328743901dc71f","seq":80,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"703.39","currency":"USD","id":9,"side":"debit"},"prev":"7ba725f7b5db6995c5388ffe70ade057eb195cbefa6f849a7dd0b2657ed2ab26","seq":81,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"703.39","currency":"USD","id":9,"side":"credit"},"prev":"99228fcdcfc3dfccfdf6692ca4474c05ecd9b479ad6dbf1dab58c7b8b58e1b35","seq":82,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":9,"blob_sha":"44ecd22cf54e2cfe9331fb015aa011affd23aad6788939c86a85de028a34df14","category":"receipts","description":null,"entry_id":9,"file_name":"r9.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"6d944d78bb57b1556b6b8e07b5f7f2a3286e408efad2ff116b28f1e48514fe29","seq":83,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-08","description":"Design work 2","id":9,"lines":[{"account":17,"amount":"703.39","currency":"USD","side":"debit"},{"account":1,"amount":"703.39","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8ecdbd55959498b2ac506e96ce3cd31f0c4bd715bf78decc97fb300b6bc3d9fb","seq":84,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-13","description":"Supplies 2","id":10},"prev":"eb4c97113391df5c0a12a8ad1b745b7bb40b528c87dae70b235aa873034d46b6","seq":85,"ts":"2026-10-21T21:28:52Z","type":"entry.created"}
{"payload":{"entry_id":10,"party_id":4},"prev":"0ff6a2037bfc1949862708059d124a80c1fd8863a7822593ecd51bcc8ff0ec10","seq":86,"ts":"2026-10-21T21:28:52Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"124.37","currency":"USD","id":10,"side":"debit"},"prev":"4f596d265ad21904214fe768fa6d813b5b91684bb1f57e21e68a7a2aac4ea597","seq":87,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"124.37","currency":"USD","id":10,"side":"credit"},"prev":"c21eb19cd2ac7da02f3e563c2e43470cf6243d0526bac2c31051990f6a1a7cc7","seq":88,"ts":"2026-10-21T21:28:52Z","type":"entry.line_added"}
{"payload":{"attachment_id":10,"blob_sha":"36ffc77eeb9f51878977e69d554148bec710772fcdac8a85291d1cc6d85fc1a5","category":"receipts","description":null,"entry_id":10,"file_name":"r10.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"45bedecd2fd71a2d2f6e28c04ebe9b07bd69e90a19551f15ef589229c23c92e5","seq":89,"ts":"2026-10-21T21:28:52Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-13","description":"Supplies 2","id":10,"lines":[{"account":13,"amount":"124.37","currency":"USD","side":"debit"},{"account":7,"amount":"124.37","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c73b6e6219916b20250550d10023467b2608ed6a871294e5ffd19f81589f6638","seq":90,"ts":"2026-10-21T21:28:52Z","type":"entry.posted"}
{"payload":{"date":"2025-02-18","description":"Software paid by member 2","id":11},"prev":"3bcea4d211bccb6c3f965b63ed9926e0a2ff8e03e524671e5d16bccbf8c7f2b2","seq":91,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":11,"party_id":1},"prev":"593b7a10014a3aae4db791dc88f01059de5dba30e0c3d8d81d8981919426fad0","seq":92,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"480.31","currency":"USD","id":11,"side":"debit"},"prev":"0d0c93ffcf4737d6b2aed0f6ac106b168731acf3ac9c8dfe1ebe58a07b500ee4","seq":93,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"480.31","currency":"USD","id":11,"side":"credit"},"prev":"c62930589fd64363d19044508091db6f1a7a31bc2d6229392d293175c6dd43b6","seq":94,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":11,"blob_sha":"adb51e4f7d428927236d250e88465a36f56a842d7878a62222fe2bd29f4d73fd","category":"receipts","description":null,"entry_id":11,"file_name":"r11.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"9e909f641d42f42854e4d2569d10aca5210336d4a266416fab450c2b9ad2d328","seq":95,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-18","description":"Software paid by member 2","id":11,"lines":[{"account":14,"amount":"480.31","currency":"USD","side":"debit"},{"account":8,"amount":"480.31","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e3ea21147baef2177949040be10c4a4a2ce12f82c1e1f1c9e7ed4a0fae93c655","seq":96,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-02-23","description":"Bank fee 2","id":12},"prev":"1f1994c6bb186155ff6c1e452247eff373ce903fcb04bd0a2c670160c5a639f7","seq":97,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":12,"reason":"bank fee"},"prev":"5b275e93057427c467c4790c51432a25180f6b02ec28db4fd3ea516bbccff9d8","seq":98,"ts":"2026-10-21T21:28:53Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":12,"side":"debit"},"prev":"3b0e4b89a80cc27b5d471c5db255b01ea8941f335d47d0355361f4162828c806","seq":99,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":12,"side":"credit"},"prev":"4847abf2d56ce8584c9f9b9ff581d8fdbe76b4f7931ac4f03a1a059c224c187d","seq":100,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":12,"blob_sha":"8dc13ceaede8f28843e0cce29d7361079db7db662dddcad5363206973983a5ac","category":"receipts","description":null,"entry_id":12,"file_name":"r12.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"8fbd785163460476a7f249b8a186388ace63db4126ce98b251fcb85f8446b6c6","seq":101,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-23","description":"Bank fee 2","id":12,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"741e73614fc19a61c148919eadaac97feccfe63e10dd033a37e1a4278749dff2","seq":102,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-03","description":"Consulting 3","id":13},"prev":"bcded4e6928cb1d98cfecad9418c88868173a31715d9c7258fd1ac43f47eeb50","seq":103,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":13,"party_id":5},"prev":"4ae7dbfad307311ffd842c853b5c38e490f1cf90337feadf063a1f8a11094d01","seq":104,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"77.02","currency":"USD","id":13,"side":"debit"},"prev":"4a5a7d56822cefdfa32825eeea6649f8d5fe417bd6af61bf25572c3321c4dc8a","seq":105,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"77.02","currency":"USD","id":13,"side":"credit"},"prev":"f480211077e50b34ed8d410b94a2a896e3aa41e5e976ac23d026956c925f5846","seq":106,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":13,"blob_sha":"bdd80cf4f88b7072fe957a747868541c636fd110390c977807cbc27ece7fc3f5","category":"receipts","description":null,"entry_id":13,"file_name":"r13.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"37e9bdee99d55fbca6ee1ed3a5c7b5930a07f63726b25e01e4f5d3f8f6332d39","seq":107,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-03","description":"Consulting 3","id":13,"lines":[{"account":1,"amount":"77.02","currency":"USD","side":"debit"},{"account":12,"amount":"77.02","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"75b43dc62ba4de561993dac61fa66b6cb36f25db4ee76f9c44b37186b20799dc","seq":108,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-08","description":"Design work 3","id":14},"prev":"6787e29c68214763e273f8c51a03f6eb7e9e8325beab276dcf057a5d2e1c5257","seq":109,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":14,"party_id":3},"prev":"fd970f288cd034209f01fe016b5c14d6fdb63059b7db0fcf90a064d1274f0b41","seq":110,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"666.10","currency":"USD","id":14,"side":"debit"},"prev":"361e3e012906190157032b7f1e266f4ffbb3a44c3220dccbd42bca8fc8acf43e","seq":111,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"666.10","currency":"USD","id":14,"side":"credit"},"prev":"31d5b169b81aa9feb3df1c3b9f736601e1c82de28be4c54723fc0d50cb3f68d8","seq":112,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":14,"blob_sha":"53d0a291ee3782d571157ef0c84f5862c9fe3ea33964edc3c4bd6d5a966d91e5","category":"receipts","description":null,"entry_id":14,"file_name":"r14.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"48d9582f794a39423947fe63c4bc1f954413f8aa0d8b0a20d7e7ca1a7945dd2c","seq":113,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-08","description":"Design work 3","id":14,"lines":[{"account":17,"amount":"666.10","currency":"USD","side":"debit"},{"account":1,"amount":"666.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"65233a70ad796f6819b5f3b142db86baee2f534df196c647ed9d64524815e651","seq":114,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-13","description":"Supplies 3","id":15},"prev":"fef2b0e474a659625d01ae2eb4546bd2e320331e47a5493c07a453f7e7f2e60f","seq":115,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":15,"party_id":4},"prev":"d232fa4453f161809c08271b5f2da9ef6abe5a88658309e6eb874630cd8d815d","seq":116,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"282.40","currency":"USD","id":15,"side":"debit"},"prev":"329b0488d4a9da17b537ca08c02848943e31cd33634cb15ae2ded821ca026936","seq":117,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"282.40","currency":"USD","id":15,"side":"credit"},"prev":"c7951f1954727979a4b4f22a21b3582c889324a17257a16e30dc5ca51bf1fb2f","seq":118,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":15,"blob_sha":"50362975c484841d345e5b431ff1de0c91a38a3a609a10b446e92214b12b89c7","category":"receipts","description":null,"entry_id":15,"file_name":"r15.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3c47ea24e1c379899e0959dedbea581010b89520066efda366a503c42b9ea429","seq":119,"ts":"2026-10-21T21:28:53Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-13","description":"Supplies 3","id":15,"lines":[{"account":13,"amount":"282.40","currency":"USD","side":"debit"},{"account":7,"amount":"282.40","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5be04d392a8bb37c8f46eaf40b9475596780b133c089fbf2c55611aa514e881b","seq":120,"ts":"2026-10-21T21:28:53Z","type":"entry.posted"}
{"payload":{"date":"2025-03-18","description":"Software paid by member 3","id":16},"prev":"e144d0ead638ccef5dbb3515ce9fcf44847ff1572b51f4e0333db8d6ed9b742b","seq":121,"ts":"2026-10-21T21:28:53Z","type":"entry.created"}
{"payload":{"entry_id":16,"party_id":1},"prev":"13fc8211de63981e31fe6636cb8cbfd133c330f63233fc72378425677c1463fb","seq":122,"ts":"2026-10-21T21:28:53Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"50.14","currency":"USD","id":16,"side":"debit"},"prev":"e1a1ded0ba49a9db6cde6792e1b714366a00671549adf4324a4598b1d74a5c55","seq":123,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"50.14","currency":"USD","id":16,"side":"credit"},"prev":"c6e25c989a0b701eb022c2ba61ca3fb87889035ec3a41504f1f0c05202a4e882","seq":124,"ts":"2026-10-21T21:28:53Z","type":"entry.line_added"}
{"payload":{"attachment_id":16,"blob_sha":"d362d36ef90e3eaa91a0c8dda829b78c6af7bbaf6d780a4c6ccb2b5fc3847677","category":"receipts","description":null,"entry_id":16,"file_name":"r16.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"8d896806acde9e0d97d7858b61e300f6952744ddb6d35605ed64dac5254e140a","seq":125,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-18","description":"Software paid by member 3","id":16,"lines":[{"account":14,"amount":"50.14","currency":"USD","side":"debit"},{"account":8,"amount":"50.14","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"3e65cb179dde4a39b10434b8c68b4de5afe0e31de2427367b37b72472d852e61","seq":126,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-03-23","description":"Bank fee 3","id":17},"prev":"01d5f1f87c958891490e89de23951f85d0e60ad048d9057be9c843ddf2a8624d","seq":127,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":17,"reason":"bank fee"},"prev":"4d765897ebb1b5caa54ccc9da98db76071dba2d990a3ad508d3d35c10e4842de","seq":128,"ts":"2026-10-21T21:28:54Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":17,"side":"debit"},"prev":"d900fa39a22dc0af4519b5b24d271a874058d348a19ab91edc7c5b978bd34774","seq":129,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":17,"side":"credit"},"prev":"54c9aa19896f483d6611034b2fabd50368127f65b3f5b14fa7b859d5afad739f","seq":130,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":17,"blob_sha":"697a5b6d8f74f341972c78207e471c30d07ced60ef8a00827be79dadd9e8948b","category":"receipts","description":null,"entry_id":17,"file_name":"r17.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"898692b09d5bcce584af16f0e0dc2897481796bc7a607558c3e71da57fdef6a6","seq":131,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-23","description":"Bank fee 3","id":17,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"117202b822b0090e7c45baabdcbf9cace96d6d64760776fcef1d71e815b2c8bc","seq":132,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-03","description":"Consulting 4","id":18},"prev":"98b756a3cf8dc05c724889a67a011f587dd0f528547fb085ee40d23fe92c9b83","seq":133,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":18,"party_id":5},"prev":"b460b4e56a6c09995d0765b0f728acd5252808587aaab153af0980ed9eb29bdd","seq":134,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"569.38","currency":"USD","id":18,"side":"debit"},"prev":"f25e935d9606f16026377498d191ee8fb6fc84e06e7b1acbf308b46d4465ddf0","seq":135,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"569.38","currency":"USD","id":18,"side":"credit"},"prev":"405ea8804c75f2a21bce3abe2c5ae46c74a833c04c32df66fa05976586757e7e","seq":136,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":18,"blob_sha":"135feedb9b3f3931a9af46640e942d497cd2c7eedd46cc49c31116107d63e1af","category":"receipts","description":null,"entry_id":18,"file_name":"r18.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"10aa42e5c28659af0088f3f2bdfb76f9cf16f7f96697409c43ee35e79e0eb31e","seq":137,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-03","description":"Consulting 4","id":18,"lines":[{"account":1,"amount":"569.38","currency":"USD","side":"debit"},{"account":12,"amount":"569.38","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9cd8f1fc5f278e051552cac6a067bfdea4462c2a16998c87d4ee9fb229f5a9b1","seq":138,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-08","description":"Design work 4","id":19},"prev":"81b61a711735d100665af0f90f66427fa58b12c2609a4366c92eb595aa54bfe7","seq":139,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":19,"party_id":3},"prev":"0b06a1d6e8db2b3d1541c41653104843e37a5e9c9403869f850b3b23ce4ff318","seq":140,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"549.10","currency":"USD","id":19,"side":"debit"},"prev":"de1602a62459badaca7c86d11a80fb8fb077efbb4c0e0a96aa5f57fa4d46e69b","seq":141,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"549.10","currency":"USD","id":19,"side":"credit"},"prev":"c4fd94a05cec6453bda42d40960b8ab1648faa17a73f85298aff2d02f9d674cd","seq":142,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":19,"blob_sha":"7c621c63ab0b77d026b903714442e0cb16d786386abc15873937a05304dad04a","category":"receipts","description":null,"entry_id":19,"file_name":"r19.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"78d0f1a293a59f415181156b936e46d37a2d9d445f5692eb308b615990fbcb27","seq":143,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-08","description":"Design work 4","id":19,"lines":[{"account":17,"amount":"549.10","currency":"USD","side":"debit"},{"account":1,"amount":"549.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"155d95bc1fe3bb903e700a0ee45a1a78be072e67df5a3a83ecbda62413526b54","seq":144,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-13","description":"Supplies 4","id":20},"prev":"bebb19d5b7bb2ff595ec01c3fd236202e0317d9173b54cef1d3539ec6efbae54","seq":145,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":20,"party_id":4},"prev":"c5a504b1fab56ae93e77357100fe56af642434409293f5bacaa281e19befc344","seq":146,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"92.56","currency":"USD","id":20,"side":"debit"},"prev":"542ee36d9463ffa01d276fcc5479b656a8617393d1c61f53fecf78ccb2632da8","seq":147,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"92.56","currency":"USD","id":20,"side":"credit"},"prev":"b03b1f3cb6cb871a4da589887226b65415677c75b92ba0d0e8aace71bb0a7940","seq":148,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":20,"blob_sha":"1ccdb1535622cb9fd70388cead476435d5a0bf48cb0f5486569d072b79702e3a","category":"receipts","description":null,"entry_id":20,"file_name":"r20.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"b0561a03b062d4b99c583d3bde744eccb66df3b8bbfff0ffd00a8151a92563ca","seq":149,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-13","description":"Supplies 4","id":20,"lines":[{"account":13,"amount":"92.56","currency":"USD","side":"debit"},{"account":7,"amount":"92.56","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"17df72a2ff4d8ad8e7b53ebcae37547075dd6e867974a331b90de12af50d9678","seq":150,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-18","description":"Software paid by member 4","id":21},"prev":"aa98755989b66c1168da2ebf9ef2f667bf17dbfb090b0feb228abd47ada4145d","seq":151,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":21,"party_id":1},"prev":"08c220fb3aa840582e44d05113bcf17ef393044a1ee9a13d8bd1628efc543454","seq":152,"ts":"2026-10-21T21:28:54Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"316.44","currency":"USD","id":21,"side":"debit"},"prev":"b7b2e135f947b02ce7b13bd5397fe0ad6ee6c5953365384b3c975024f0a35c1e","seq":153,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"316.44","currency":"USD","id":21,"side":"credit"},"prev":"5b78e258d4718d45fb43f8295d70ee1b5e29f97a1404e53287355ddbbdf63596","seq":154,"ts":"2026-10-21T21:28:54Z","type":"entry.line_added"}
{"payload":{"attachment_id":21,"blob_sha":"5484fb84ff7718440c626ab6f5ea214c193f00a84079b51d811df2cdf35af58c","category":"receipts","description":null,"entry_id":21,"file_name":"r21.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"615867565e9b02e72a0e8d5180e5d43a0c5a4c0fdd8e8d1f4d98535c14c92f3e","seq":155,"ts":"2026-10-21T21:28:54Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-18","description":"Software paid by member 4","id":21,"lines":[{"account":14,"amount":"316.44","currency":"USD","side":"debit"},{"account":8,"amount":"316.44","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"61daf7a1e6e75e7f3e1d4adb1b69e07997ab36225fd1add8f7a238af7adfc8d3","seq":156,"ts":"2026-10-21T21:28:54Z","type":"entry.posted"}
{"payload":{"date":"2025-04-23","description":"Bank fee 4","id":22},"prev":"210ff2e8a7172d687a06e850e0421355f0f142eae1844e3b841c19a83866e492","seq":157,"ts":"2026-10-21T21:28:54Z","type":"entry.created"}
{"payload":{"entry_id":22,"reason":"bank fee"},"prev":"c31826899ed4c4775b47f6d0fcb0877caa7d8e52eac8dc1e82c5dff809789713","seq":158,"ts":"2026-10-21T21:28:54Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":22,"side":"debit"},"prev":"e1a92cfbcbee238dc149050bb0116a091bd9a49730eef6b32991b4c462012752","seq":159,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":22,"side":"credit"},"prev":"eaf21d71f261786cb6aa58a92577bc8c282bb889255593560d29670947e6d5a3","seq":160,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":22,"blob_sha":"a1cfdbd7df3bd87d040f3f88ba9fd852d57c9e308a8fd8884b5872f5ab65d955","category":"receipts","description":null,"entry_id":22,"file_name":"r22.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"df36dae34a74a00382ef386d1275e9cfc125dd24d0e8ebccf23628ccf9063b5d","seq":161,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-04-23","description":"Bank fee 4","id":22,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4aea0b2d4309babbd6be12d741a242b394b86b74b67c0cc124f148318710c3ff","seq":162,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-03","description":"Consulting 5","id":23},"prev":"6ef10f9bad3e4e57d67e5af36eeeca16cd685409a31578ab3e145472c91efbe9","seq":163,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":23,"party_id":5},"prev":"0185d788c816eac9251c2011ab63ea1314e2053a29f914540e84e0c1f9a5b51e","seq":164,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"723.26","currency":"USD","id":23,"side":"debit"},"prev":"2bed4193fe6f8e6a79b6f3a26d257520c203d25d21f88700b2b795eb38cf2640","seq":165,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"723.26","currency":"USD","id":23,"side":"credit"},"prev":"f1d76570adf1ca7b0cd93e478ae8394b7639e545295b0a459bd1cf1426877245","seq":166,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":23,"blob_sha":"603f71cd317e709741b1c58e20690f48592dfc1e13c91b169d5207e8b3db45ca","category":"receipts","description":null,"entry_id":23,"file_name":"r23.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"9584359be3c76055e35579fe3cd88b8d0c0b5ad93deda34f8ea15bda937739b7","seq":167,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-03","description":"Consulting 5","id":23,"lines":[{"account":1,"amount":"723.26","currency":"USD","side":"debit"},{"account":12,"amount":"723.26","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d6ad64035427d8ab4243493aa7c39c1207f62a8c815fcd98eb609a58cab5f7eb","seq":168,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-08","description":"Design work 5","id":24},"prev":"04d667574c83c71c34d17f508bf5d6f64ac549b7c03968cf645c03e0ea007236","seq":169,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":24,"party_id":3},"prev":"582fa7556c44d7d006428010ebdd06f6717fa3e4349e9d1da43286093fc2d804","seq":170,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"557.42","currency":"USD","id":24,"side":"debit"},"prev":"4d7f750fe1197f5dc45cf3b36f0a8b5a8bbaefd59e0e70964e850468f15c806d","seq":171,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"557.42","currency":"USD","id":24,"side":"credit"},"prev":"b349beaeafe3b3a159d9e430afdf9b5c16245922ece79168e7374f4d0ac3c226","seq":172,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":24,"blob_sha":"e2150d5a433a641134c1c1e927a05dea2fddf44775f2b9dd2c23366e79d38389","category":"receipts","description":null,"entry_id":24,"file_name":"r24.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"40139e3503971af4374cf0b4b56c707d6525ac946e1ef2b1f35322f5b6b34850","seq":173,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-08","description":"Design work 5","id":24,"lines":[{"account":17,"amount":"557.42","currency":"USD","side":"debit"},{"account":1,"amount":"557.42","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b1966191ebf5b382aad792634f4c2c5daaaf5f84a739ac3c3507153c9031756c","seq":174,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-13","description":"Supplies 5","id":25},"prev":"1225dc7926558c39b65926da7e4e9cea942713a29451d670aa458c4411cdadf2","seq":175,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":25,"party_id":4},"prev":"0387af139832d42580c0d9d61990c7a0ed8f2c6e260bfb10b061882bb210885e","seq":176,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"78.47","currency":"USD","id":25,"side":"debit"},"prev":"7217b65bae27528c5586423029b77c7d17bf039d993011221b46b91f6bad4adf","seq":177,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"78.47","currency":"USD","id":25,"side":"credit"},"prev":"2ba1a67b7064dafbc8b921da91fe35e1dda7093eb9dc94ca1ebac7dbcf904c8f","seq":178,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":25,"blob_sha":"930a53802a91886ad895106907b55e56fad62a32d8933ecf10fa6ffda0ca4d99","category":"receipts","description":null,"entry_id":25,"file_name":"r25.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"341c0fe14ddff9e61aaff5b254088fdcdc22fe7b9569e8269c9b82e704d0c4fb","seq":179,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-13","description":"Supplies 5","id":25,"lines":[{"account":13,"amount":"78.47","currency":"USD","side":"debit"},{"account":7,"amount":"78.47","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b3b056c6c81e328b556a0e5f701d03cca27b0b535a3d6e5fa713c49038e17f35","seq":180,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-18","description":"Software paid by member 5","id":26},"prev":"589d6238947cb71db0fc7ca67fe191bd21815d6f2f73321866966cdcdc25cd5f","seq":181,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":26,"party_id":1},"prev":"e189d25bbbe2f1584aad41d5745394d64375193da201844a3a249d5107678894","seq":182,"ts":"2026-10-21T21:28:55Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"742.15","currency":"USD","id":26,"side":"debit"},"prev":"5b631f9b461464590322911961ef79fed887a1d13f711698ccf8346641ad6caa","seq":183,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"742.15","currency":"USD","id":26,"side":"credit"},"prev":"bb62bc83d88c4d51892ac46bea4973a9551a5eba47146590d4c1f64b93094001","seq":184,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":26,"blob_sha":"c5ec7ae0d321249ce2b1ca7ada6605a45e4afd8a09e8e3321b09427cf46fa876","category":"receipts","description":null,"entry_id":26,"file_name":"r26.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"55ef1c2620ed5e12ed6ebc4924d601d0b225a8597d33641a9b9cae40878008c2","seq":185,"ts":"2026-10-21T21:28:55Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-18","description":"Software paid by member 5","id":26,"lines":[{"account":14,"amount":"742.15","currency":"USD","side":"debit"},{"account":8,"amount":"742.15","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1561153fc0243c3fd1a7c4827e594f6989329425d8af48e8b400383b8ce8bfbf","seq":186,"ts":"2026-10-21T21:28:55Z","type":"entry.posted"}
{"payload":{"date":"2025-05-23","description":"Bank fee 5","id":27},"prev":"56423388ab6c87b857dda42ae3c223be85fd9f252ed6ab3c8e6e1263bf1de933","seq":187,"ts":"2026-10-21T21:28:55Z","type":"entry.created"}
{"payload":{"entry_id":27,"reason":"bank fee"},"prev":"191c29d74cfba04703fcce75bfe2bca04f2b55ec53fd59f648f0870167e27309","seq":188,"ts":"2026-10-21T21:28:55Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":27,"side":"debit"},"prev":"b98709e04a040c95b35edda750ac20a2cbf12bbd1671750037dafc00d66a3b13","seq":189,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":27,"side":"credit"},"prev":"4409fdddbe25988a80846a91a889333ff58f3cabeb93013380c89e0043bf0ad4","seq":190,"ts":"2026-10-21T21:28:55Z","type":"entry.line_added"}
{"payload":{"attachment_id":27,"blob_sha":"154a2bd5bd5bf103a7a5dbb129fcf44a3a6c2c4a3214cc0bb6a17311c82d8061","category":"receipts","description":null,"entry_id":27,"file_name":"r27.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"328edf40f496826dca364f529faadb44b22a1630307911a0d019be7870988e10","seq":191,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-05-23","description":"Bank fee 5","id":27,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d80aa5223319bac0ada01e8e5f33d0277139a8d500539cf31131e2292390b787","seq":192,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-03","description":"Consulting 6","id":28},"prev":"b69840e0dca6e2119aa5009e125e962377b01649f8f3e283374d7073ef23e799","seq":193,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":28,"party_id":5},"prev":"1bdea236f3725c76feaf17f990e44450b488b4406d8c3ba327a3e8b284c7bb45","seq":194,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"293.60","currency":"USD","id":28,"side":"debit"},"prev":"24f568bccbdea731f1d3d65a3b0a7b1ee71b3328d5ed98990460d4be384658fa","seq":195,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"293.60","currency":"USD","id":28,"side":"credit"},"prev":"9d884d36fd35dcd0d94c02f7f9800c3132ad0c5754d77b89f3c184c442b405eb","seq":196,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":28,"blob_sha":"a8234e922d52bb6f585639bd99714794d6a62a089faedb24711f7f7b8ff24091","category":"receipts","description":null,"entry_id":28,"file_name":"r28.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"615f1b0c850ea082b2caf71aade572e0d98e8fa7c181077ec0c493685af02129","seq":197,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-03","description":"Consulting 6","id":28,"lines":[{"account":1,"amount":"293.60","currency":"USD","side":"debit"},{"account":12,"amount":"293.60","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"cdcc49409546f1fb63a7847dd8862e8ffc00eec2439b2700378cb67f1d935d36","seq":198,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-08","description":"Design work 6","id":29},"prev":"a45ca1c0c8ca330f2de5a0bfc150d3cc9a1f0dd4bd434347c23393638a2ec71f","seq":199,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":29,"party_id":3},"prev":"a23eb273a2b7a8423bd7fda205a7801d3b259ff9290fe4199d58b9a70bc3cd11","seq":200,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"827.57","currency":"USD","id":29,"side":"debit"},"prev":"86a6323eec09275c1a2cafa1e570f479198b41d7d7d0fbb9901c43954718e070","seq":201,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"827.57","currency":"USD","id":29,"side":"credit"},"prev":"9cab5dc278d32771d1dfec9f002b4a8213cd2dfa341f6348cbc4b3db4d563af2","seq":202,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":29,"blob_sha":"bd7936f79f1b7e603feaa91d6e66a165bb5959bc91519f884f60dd63012b53eb","category":"receipts","description":null,"entry_id":29,"file_name":"r29.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"7f05ec8fd278f0c25556041310ca0ee32b6cf1d6d9101f4e336c1dae0bef6484","seq":203,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-08","description":"Design work 6","id":29,"lines":[{"account":17,"amount":"827.57","currency":"USD","side":"debit"},{"account":1,"amount":"827.57","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"a2a196019c9ca93a5ee9e6b7394d7d909cd213e2728cf4d18015f5d65cea2d54","seq":204,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-13","description":"Supplies 6","id":30},"prev":"b709f836b0dff6856f22fda0dea4990df172f62a354e25123609bd0174be8d66","seq":205,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":30,"party_id":4},"prev":"97f1b47f77f8c1197de24bb989fa8bb7e9083641fac69fba471b6545ab2e9687","seq":206,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"823.38","currency":"USD","id":30,"side":"debit"},"prev":"6b625c1035f8054a7c2754a992fb590385b0c6ebe41518e16c207cc1322d63c8","seq":207,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"823.38","currency":"USD","id":30,"side":"credit"},"prev":"83fcd89112f49407b564c85dc788120a63b8abd8df9822abdd35199aed997a9e","seq":208,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":30,"blob_sha":"80776469bdd56d9957eaef7735f9d603ef7d4b69a51fa500aa4f0b70d24fd95e","category":"receipts","description":null,"entry_id":30,"file_name":"r30.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"b585d23d0d3f25c96fc81432c71382825debd976de9862fb19541204abdf83a7","seq":209,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-13","description":"Supplies 6","id":30,"lines":[{"account":13,"amount":"823.38","currency":"USD","side":"debit"},{"account":7,"amount":"823.38","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fa950b384f9c5307170c95c335c272362859e427eaf29402fead4f447fc2002","seq":210,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-18","description":"Software paid by member 6","id":31},"prev":"af902bae6a319f9efca635c501b5c005c68cf58c69ef6341e9db5a7f25a6c651","seq":211,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":31,"party_id":1},"prev":"17bd5f87e9abb49150365543faaf258265fa368fd005e551f16465575f9b38ba","seq":212,"ts":"2026-10-21T21:28:56Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"765.14","currency":"USD","id":31,"side":"debit"},"prev":"830f09569349f9ae18605bf6f977294e4580d28d1511954347b4df606068d0e4","seq":213,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"765.14","currency":"USD","id":31,"side":"credit"},"prev":"4d92bcf5c31923002854dc00bdccce819209ac05144d7f3cbe5a1f951788ec17","seq":214,"ts":"2026-10-21T21:28:56Z","type":"entry.line_added"}
{"payload":{"attachment_id":31,"blob_sha":"372029955692c864bd5f8fc93da8a3512bcfa45099626c289eca75de84478f0c","category":"receipts","description":null,"entry_id":31,"file_name":"r31.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"22323b6e007830ba4fa0e701318ab0fb1a40764022fda8067b9076843fa938b6","seq":215,"ts":"2026-10-21T21:28:56Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-18","description":"Software paid by member 6","id":31,"lines":[{"account":14,"amount":"765.14","currency":"USD","side":"debit"},{"account":8,"amount":"765.14","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"07b9323c35fa23cd4e7a009ea458f095b71924fc00dfe24a9b0e6e7f5f163665","seq":216,"ts":"2026-10-21T21:28:56Z","type":"entry.posted"}
{"payload":{"date":"2025-06-23","description":"Bank fee 6","id":32},"prev":"070e5f13698f701be19ab710494ab4cedf78e46f94b6d8f3fad55a90405f0590","seq":217,"ts":"2026-10-21T21:28:56Z","type":"entry.created"}
{"payload":{"entry_id":32,"reason":"bank fee"},"prev":"09be96a0740db022c5bf2a3c466597811c18cc5ea62b09a2fe81ede6bf832a16","seq":218,"ts":"2026-10-21T21:28:56Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":32,"side":"debit"},"prev":"1e79025529dca4144f1ca4a9b1a2466aca3ce101ece1a6ff57cbbee0d6c2d766","seq":219,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":32,"side":"credit"},"prev":"64c01758d90db85b0d33d743d2955b66e00041e8babda6a49139f47f0bf71828","seq":220,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":32,"blob_sha":"4e2d180772eae2ddf0596a5444f301e052f93f7266845eecf454bc31f053d36f","category":"receipts","description":null,"entry_id":32,"file_name":"r32.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3a6f46683d9c4e507d0fd4268c4be69f372f779b43b8ab6ffb42ae79fc28869c","seq":221,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-06-23","description":"Bank fee 6","id":32,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"6c5b6d6badc42ae5dd9d5045c2ba54fac1e353bc99a87a6c3ff2186e369fadc9","seq":222,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-03","description":"Consulting 7","id":33},"prev":"f759dd462a9cb7019463699c98db1902a07c4caa63648830a693217df128dacf","seq":223,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":33,"party_id":5},"prev":"0c980e96236d23fa01a2a67fab09b71cca3068ccafef1c19c6e6287bd2dc65a1","seq":224,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"757.42","currency":"USD","id":33,"side":"debit"},"prev":"0d721835811bdfa94d879f95cbd8ff64ad5c6b8c3c315a26918605d1a2833b30","seq":225,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"757.42","currency":"USD","id":33,"side":"credit"},"prev":"68c5f21aef69766ec2494c0a6f5bfa670541ca15160d63613b3b37d93de96111","seq":226,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":33,"blob_sha":"b05c9555f4e49468970319698e33cab20430bd03a877183870c44f037aebd740","category":"receipts","description":null,"entry_id":33,"file_name":"r33.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"173360449b8d5b426a30c584b46b4ec9c86a86caae4ec52bdef7569657698d41","seq":227,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-03","description":"Consulting 7","id":33,"lines":[{"account":1,"amount":"757.42","currency":"USD","side":"debit"},{"account":12,"amount":"757.42","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8b92ffbf33a62e49a33d458ed9c40a051fa1fb7deb6db790588f7f1fef5e4c04","seq":228,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-08","description":"Design work 7","id":34},"prev":"9591c74c137256cf21719fa68c52044f8af84880de48c0630770cb410cb7cab0","seq":229,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":34,"party_id":3},"prev":"248bbe7571c71f650ccf9e71419e0375cf1aab0b9a526f7726e247109b7ebe8c","seq":230,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"768.48","currency":"USD","id":34,"side":"debit"},"prev":"713137aed828f3a99c9a4e47084102d60ec3cce05a5b7583bfbe2d0cc3c45861","seq":231,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"768.48","currency":"USD","id":34,"side":"credit"},"prev":"dce879af18b9a6eaed664dc7fdbb4e72aced9e82993d8505b3ed0c31a14fe40a","seq":232,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":34,"blob_sha":"3acdde86472882410cc59ff61474bff5e8272eb4411654d5d57965b34d65673d","category":"receipts","description":null,"entry_id":34,"file_name":"r34.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"11c5349b0772892ed01eb3952297621029f63bc5766a5b5aaa56910cff860818","seq":233,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-08","description":"Design work 7","id":34,"lines":[{"account":17,"amount":"768.48","currency":"USD","side":"debit"},{"account":1,"amount":"768.48","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e3aba3df02941358e8c2b4d3f1175d60d930b35d2538f164e95fe182abff6a0d","seq":234,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-13","description":"Supplies 7","id":35},"prev":"eabdfe6299076017215304052f4942730cd9dce056bd341c1901798d8c062107","seq":235,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":35,"party_id":4},"prev":"1293dd57ef8c2dec6095abed25e7477a9d8ab03ffc0e784559cb4ac82c765cad","seq":236,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"520.93","currency":"USD","id":35,"side":"debit"},"prev":"6c85e1110a15121c335512824b0c90d3323aa81031d0ad067d981c5233b2e6be","seq":237,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"520.93","currency":"USD","id":35,"side":"credit"},"prev":"ee3b23302c580618337aed8a4298786f6fc1b81b53f0b2b4000fa921fea7fb8b","seq":238,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":35,"blob_sha":"c090474697214b4507cdbb20ee2ba05be74d257a9d0f7ce06e0c16cf6d071dc3","category":"receipts","description":null,"entry_id":35,"file_name":"r35.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"9b10307ae7dd15778ad0d930f1e238728d3b9596fafa2131d0ef11b4a958c6d0","seq":239,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-13","description":"Supplies 7","id":35,"lines":[{"account":13,"amount":"520.93","currency":"USD","side":"debit"},{"account":7,"amount":"520.93","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c965d22e48395c8a99933fe3e80292fe503d41f37b708340400425eb6f6d2f42","seq":240,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-18","description":"Software paid by member 7","id":36},"prev":"cf4466aae68140545f41cc3baf77c3163ee7640b620d2624e77f7f76b73c7610","seq":241,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":36,"party_id":1},"prev":"945b101940418f2f4eb207ca138ea3f83d2aa9c01cd569037058948e7b697153","seq":242,"ts":"2026-10-21T21:28:57Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"65.99","currency":"USD","id":36,"side":"debit"},"prev":"95c9efa06f94ed2770c960850c4dfabb63a7de6244346cf883c08bc2bdf36132","seq":243,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"65.99","currency":"USD","id":36,"side":"credit"},"prev":"01426ffdbc7e438ce7a8952fff163c5551f40b070b200140ab9c0f78f78fbdfd","seq":244,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"attachment_id":36,"blob_sha":"210959e4644efeb3e0dec3b5da30d3882c2f6bf1dd05d36b81b3f9ab1d7cf8d5","category":"receipts","description":null,"entry_id":36,"file_name":"r36.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"1ba7cb63e6d6f1f1b6b31e48ae575fc85b58a3a4f70f8f92c054ad40a8e34f48","seq":245,"ts":"2026-10-21T21:28:57Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-18","description":"Software paid by member 7","id":36,"lines":[{"account":14,"amount":"65.99","currency":"USD","side":"debit"},{"account":8,"amount":"65.99","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1183a1094dac472704a51c0252b676b6763ee599348228a61766f4d062e625e5","seq":246,"ts":"2026-10-21T21:28:57Z","type":"entry.posted"}
{"payload":{"date":"2025-07-23","description":"Bank fee 7","id":37},"prev":"cd136c62a69f1b7cf15e5bb6038726abf06b9562118a38a0a34a8a0511ea6c9f","seq":247,"ts":"2026-10-21T21:28:57Z","type":"entry.created"}
{"payload":{"entry_id":37,"reason":"bank fee"},"prev":"f592fc409d09e4d968191cbdfff9385d0e6290f0a27b3cc058b30f8e71d1e793","seq":248,"ts":"2026-10-21T21:28:57Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":37,"side":"debit"},"prev":"d0ddd4d22bd0478793c92fc15c9b4dff85a4bf937099c3198919d045617ba8db","seq":249,"ts":"2026-10-21T21:28:57Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":37,"side":"credit"},"prev":"b8526b55e451046fc8121ae3b975406e84268dd9fec5ebf2d77fec8977463a15","seq":250,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":37,"blob_sha":"af8c085ab8779b9ae9fcb3989f60a55719db4d91dbdf5c5e60f3ac39c6efd05b","category":"receipts","description":null,"entry_id":37,"file_name":"r37.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"3978dac72a1bf49c6c488d99ae9e2480ca1ff0dd5d0493b657c936ba2f467e5f","seq":251,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-07-23","description":"Bank fee 7","id":37,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4edb283721c38215f970a20de821fc6afd5e503c5fc5687c521f7d24f66dc016","seq":252,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-03","description":"Consulting 8","id":38},"prev":"b0a4871b1c83e596c62bdb9495bb00f48f2b4c7afcf55b324d0629aed0c7f81d","seq":253,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":38,"party_id":5},"prev":"fdd9ea92046c54ed28685e1ca916ab5059298e9c0c68e7c0ee2d7e9491ab2c67","seq":254,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"62.05","currency":"USD","id":38,"side":"debit"},"prev":"5a21e496268f99275bbd57a3b1c031768d4734b28b3688485cb62fe4bb452f1c","seq":255,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"62.05","currency":"USD","id":38,"side":"credit"},"prev":"6ac8d4eb51cb07c0ef399001f7075a3833f8efc20c8287c48ddda31e1be49b5d","seq":256,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":38,"blob_sha":"350c8d74e3937e0d444fae5eef32a5c1e10fb32fa5aced6d07405ab36aeae8a7","category":"receipts","description":null,"entry_id":38,"file_name":"r38.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"e1b62c3f89d8fced181f6f6ff01135f2aace75318a2e9f95bb91609cd9c516e2","seq":257,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-03","description":"Consulting 8","id":38,"lines":[{"account":1,"amount":"62.05","currency":"USD","side":"debit"},{"account":12,"amount":"62.05","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2a17b77d74e6376166edf3dbdfc145da3fa4b25cbb202bf3f3244ca8bc1b1836","seq":258,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-08","description":"Design work 8","id":39},"prev":"3fa32a40fc5211fa6c9e2fff209b74841b8b85f75901e4e23be7902777022f21","seq":259,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":39,"party_id":3},"prev":"ce87e0b80d60bd26ba2c22f542bd6e1ff212ab868e445b12aa158b0213d54d17","seq":260,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"730.63","currency":"USD","id":39,"side":"debit"},"prev":"87b250f95f256c3ada0bcdffc0a18e8d0334839735b198031591c9f0b2140112","seq":261,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"730.63","currency":"USD","id":39,"side":"credit"},"prev":"f63dd64c97b5195f8b14ea8ac55418e66f3609ed931cd64f318012431a42c716","seq":262,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":39,"blob_sha":"090a938b8299df0c5b93304381d24a65b7feb45a6ed923c5fe10c10d7118e336","category":"receipts","description":null,"entry_id":39,"file_name":"r39.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"86f9aaa2f67bfd2dde1f76b83d5c618aadaca66e781e0a6ccceb08b557e94821","seq":263,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-08","description":"Design work 8","id":39,"lines":[{"account":17,"amount":"730.63","currency":"USD","side":"debit"},{"account":1,"amount":"730.63","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7dbc46d17cedd252e198846fb543182ef4d8a8195c96239e396afb4e669043ca","seq":264,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-13","description":"Supplies 8","id":40},"prev":"63f3ad6ce777ce928bbd9eae7689c5e677bb2d37b0fb75dd2d0b382c7212a220","seq":265,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":40,"party_id":4},"prev":"10425e29f230394a89838ce3c41229d8f3e567766c13276740ea9e0855ae11a4","seq":266,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"175.55","currency":"USD","id":40,"side":"debit"},"prev":"988a8fbd3403db7add3364ded562d4864774c19b7ded2cc8eb1ed429478e33d6","seq":267,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"175.55","currency":"USD","id":40,"side":"credit"},"prev":"c8d9dbf34eb4052c69c4f2bfb5310e9d3517d14a4b39144d094ecf9c361cc22d","seq":268,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":40,"blob_sha":"f52f29e9a5eb89e4f0851f39f7ee9c25ffb63e7973bc4dfaf45c3359fe923f1e","category":"receipts","description":null,"entry_id":40,"file_name":"r40.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"cf459ec8aadf61e86f470d4c975401aedd639ecf7bfc4ab72f982c2905d1d37a","seq":269,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-13","description":"Supplies 8","id":40,"lines":[{"account":13,"amount":"175.55","currency":"USD","side":"debit"},{"account":7,"amount":"175.55","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"df91e170c107eb3ac37ceb43079954efb97d2b36b8fda8d9953e0592b9e6fc7e","seq":270,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-18","description":"Software paid by member 8","id":41},"prev":"28d8955805ea47de1856adcab5ea51f354e06a781e92407f4e7fd2dadc012655","seq":271,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":41,"party_id":1},"prev":"2ec6d9a3319a17a11e52838665efcfdde6050dcc73ef822718a70c41834c75ee","seq":272,"ts":"2026-10-21T21:28:58Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"380.59","currency":"USD","id":41,"side":"debit"},"prev":"86cf71ad7ca19f8873609ee21fc01d7189cb41c62b773d612d22c7261cc3f483","seq":273,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"380.59","currency":"USD","id":41,"side":"credit"},"prev":"01a14c8f086622be0bff9f8af0d50a540acf7b916b5d2428c20ec5f400e9b5c6","seq":274,"ts":"2026-10-21T21:28:58Z","type":"entry.line_added"}
{"payload":{"attachment_id":41,"blob_sha":"266487055317ad4a9ec8504c20da90ea14fd7b101894b0c001c7106ead417c20","category":"receipts","description":null,"entry_id":41,"file_name":"r41.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"290b112f536d6d414aafd2ba484f337f3112e29f394696bbcf15b34b75ae0c9c","seq":275,"ts":"2026-10-21T21:28:58Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-18","description":"Software paid by member 8","id":41,"lines":[{"account":14,"amount":"380.59","currency":"USD","side":"debit"},{"account":8,"amount":"380.59","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5724ec3db36f52fd2076c0c15cf6207753056f9896cb822c410cbf1cff967731","seq":276,"ts":"2026-10-21T21:28:58Z","type":"entry.posted"}
{"payload":{"date":"2025-08-23","description":"Bank fee 8","id":42},"prev":"1f4cde58fd842e88ce9747b62094cd58db56f4e7106fc18b1d8696026367f3e6","seq":277,"ts":"2026-10-21T21:28:58Z","type":"entry.created"}
{"payload":{"entry_id":42,"reason":"bank fee"},"prev":"13d7dfd5202e9f27bd46870c7f342690e09a322d3669fa14b82c5ff65ac9787d","seq":278,"ts":"2026-10-21T21:28:58Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":42,"side":"debit"},"prev":"1fddaed992fda0912cf718f8a9dcae48100a5daf9ed1cac2bbdac95ff75ee5db","seq":279,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":42,"side":"credit"},"prev":"2a815bee24fa4ad299249b6d18bfa4263a46236e4cf95c62e4b0013cb0bed4e0","seq":280,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":42,"blob_sha":"09d567fff5b3478f1c5e388f0b173dfb2f45eb553528c95b6723d5f13a6b0dba","category":"receipts","description":null,"entry_id":42,"file_name":"r42.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"40ad0df5d16cc355e28c6dba42ae850632367993a0bd6a356a01b1db4cab2d01","seq":281,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-08-23","description":"Bank fee 8","id":42,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1c0a55791cafa9e099b572f2b7e6abfc455dd77f89df4bdda91b62a97a682453","seq":282,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-03","description":"Consulting 9","id":43},"prev":"b5acb73ab3af2bf2efa177dcda6010d7fbe66626a86dc3589e601b4c5a82a91d","seq":283,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":43,"party_id":5},"prev":"d0a8d7e0443823abbc31649fa177d7f284b25884dcb11bad39a7a505b5405130","seq":284,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"190.07","currency":"USD","id":43,"side":"debit"},"prev":"a896cb4223177131ab30faf764086948804bc85af2b0ca7ae052f77d5b28d138","seq":285,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"190.07","currency":"USD","id":43,"side":"credit"},"prev":"36db1572ca560eee9633eff1d343258e96247f9fb05589c031aead4c0008c0de","seq":286,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":43,"blob_sha":"549225ddc356bd1a217220cc6681b76dba4ea9eff24a5f34ca6e832a1e7dfc97","category":"receipts","description":null,"entry_id":43,"file_name":"r43.pdf","mime_type":"application/pdf","size_bytes":54},"prev":"0fb8e62c8d504fb5e6f4f849f8d6816c49be78cf68e77d8c3a9d921425fa7d59","seq":287,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-03","description":"Consulting 9","id":43,"lines":[{"account":1,"amount":"190.07","currency":"USD","side":"debit"},{"account":12,"amount":"190.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"33eba839f1657919df5693dee0d20385a562e96f25f66046ab591910f71e5802","seq":288,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-08","description":"Design work 9","id":44},"prev":"0185772912d7905d64b60989068f41fd2b61a2f19542d6d8663704e61e0b314c","seq":289,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":44,"party_id":3},"prev":"c81d8a7e54878101c1d36e4a120485b555f0b7d9476145dea2cef53b52831fc6","seq":290,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"709.68","currency":"USD","id":44,"side":"debit"},"prev":"30b4d9e23c114c65583c94594fd0f59763f95cf13c7c6baffbe4eb955646ef57","seq":291,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"709.68","currency":"USD","id":44,"side":"credit"},"prev":"d56cf6fde598e32ffeddc8a792895884703a69e3e27a7bd2dac5c8624bccdd76","seq":292,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":44,"blob_sha":"160144f9771ebf02e5e5c914137f3ae642dcbf75b8f44443296f32fab855b62a","category":"receipts","description":null,"entry_id":44,"file_name":"r44.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"84bb3fcb531b4d64803c05e7e16043e0f7ac5a4b95a3b90a8909edc3b2e004f4","seq":293,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-08","description":"Design work 9","id":44,"lines":[{"account":17,"amount":"709.68","currency":"USD","side":"debit"},{"account":1,"amount":"709.68","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c9142d9f7a6dabb1e117100951ea1186914766cd2f21cb59c06285fd7f2b9462","seq":294,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-13","description":"Supplies 9","id":45},"prev":"8e62d11efb2f897c1886a72cea2a10024d19bcefa948f39d801ed746c554c45b","seq":295,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":45,"party_id":4},"prev":"9fe86a91236d52e50d70e5dffc7ccc10989700ee85999f8181a1ed45fb598da9","seq":296,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"155.39","currency":"USD","id":45,"side":"debit"},"prev":"534bbb9f5c0790c0e4f332b9857aa54563b5fc8dbba582763997cb2e373999f3","seq":297,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"155.39","currency":"USD","id":45,"side":"credit"},"prev":"7116dfa0caa53e9eb5e5199afb7d693f1cfb9ae715d0b694d08118ab7f22ed14","seq":298,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":45,"blob_sha":"7fcbd4774d24625ed2dd3195dc8b298a41e19dc56c6e5a3fdafb3862a1198eeb","category":"receipts","description":null,"entry_id":45,"file_name":"r45.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"c39dcd970e9da7d21aa1924cbd662d973c60cf9497af73ecb5379d6b1d17ae05","seq":299,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-13","description":"Supplies 9","id":45,"lines":[{"account":13,"amount":"155.39","currency":"USD","side":"debit"},{"account":7,"amount":"155.39","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"07dad89fbefd0e89de4920a26cb2da5d61f88365d09ed327f6a753b12c7fd952","seq":300,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-18","description":"Software paid by member 9","id":46},"prev":"002822633eb6996ee1ed69849ac2e2027b238e39c8c38fbfe2e1b54c8ca97a3f","seq":301,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":46,"party_id":1},"prev":"a954c30417563df040b718dab32da9f78978a8154fafdbade90fa33e51fb08b9","seq":302,"ts":"2026-10-21T21:28:59Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"749.30","currency":"USD","id":46,"side":"debit"},"prev":"40bc1d6f6745146f9adfa43ba29d203f70c6fe065809922f462288544a62c7d1","seq":303,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"749.30","currency":"USD","id":46,"side":"credit"},"prev":"a43d18d01a5373d97c8b8d39dd7d8e7cccaa27d21bab78d04ff360da6278ba86","seq":304,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":46,"blob_sha":"0126c8f9f243e03c0cb71f8d47ba27730f9a0503e25abdbd43f94e3a44479e6b","category":"receipts","description":null,"entry_id":46,"file_name":"r46.pdf","mime_type":"application/pdf","size_bytes":67},"prev":"2eefc744c374f495f10c6b59738903fcdd77c96f0bc2440fa71c43e7072047b6","seq":305,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-18","description":"Software paid by member 9","id":46,"lines":[{"account":14,"amount":"749.30","currency":"USD","side":"debit"},{"account":8,"amount":"749.30","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1030dd48e2298995eafd77d4864b87e492ea842cfe6294018e6bb7a2889f6ab5","seq":306,"ts":"2026-10-21T21:28:59Z","type":"entry.posted"}
{"payload":{"date":"2025-09-23","description":"Bank fee 9","id":47},"prev":"0cc00d0afccbc3cbc47fa69f46c35f70b495d88fe093da4ecc02f9146f585c99","seq":307,"ts":"2026-10-21T21:28:59Z","type":"entry.created"}
{"payload":{"entry_id":47,"reason":"bank fee"},"prev":"e38279f0f1ac2d1ba9347ac3dadeed99198a1c8f53603875c140ac79155ebfaa","seq":308,"ts":"2026-10-21T21:28:59Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":47,"side":"debit"},"prev":"e7f860c5ed73429179d942ed735f3620a5c67a251fd884dcdc81ad2268b98c71","seq":309,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":47,"side":"credit"},"prev":"e02d2ff27e7ca3599be224bfefcf1e0df5b98c0308526953a73603f66f199eb7","seq":310,"ts":"2026-10-21T21:28:59Z","type":"entry.line_added"}
{"payload":{"attachment_id":47,"blob_sha":"288625ee69e0c00d34b1defbf7e02b95768dfdcaa3df3fefd684a56a3b2c3d4a","category":"receipts","description":null,"entry_id":47,"file_name":"r47.pdf","mime_type":"application/pdf","size_bytes":52},"prev":"e5f958df210efc615129fe33643298b61bc436a92660d61a707215175ab87169","seq":311,"ts":"2026-10-21T21:28:59Z","type":"receipt.attached"}
{"payload":{"date":"2025-09-23","description":"Bank fee 9","id":47,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7777a10915275157ba3f997c41a0e0015607caab9e153489f85f62c4a1af1add","seq":312,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-03","description":"Consulting 10","id":48},"prev":"ce7052dbc841adff64afdb9078936d72dd1dd90db17519c517498630f8399f4c","seq":313,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":48,"party_id":5},"prev":"3af46e02bc03d2bc4b0eb68c4beeeef2dbf155b2ecc9740123ccd44c3466ae19","seq":314,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"735.34","currency":"USD","id":48,"side":"debit"},"prev":"2224b0c15c2fa780a17b51d03afae3f7b71bb26f82744d0ffeb90313781b8d34","seq":315,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"735.34","currency":"USD","id":48,"side":"credit"},"prev":"71bc9269902f739f8d1e6b82c12d319831d0d79a7da56242c3847856b3911e28","seq":316,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":48,"blob_sha":"3ff73e7967f4f72c974941bce53012f75a995b229982457def7312c6547df6f9","category":"receipts","description":null,"entry_id":48,"file_name":"r48.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"6c290b24a3ec2d89183d05ee66db17cd4c9f7640671f7312300d6a7834f96269","seq":317,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-03","description":"Consulting 10","id":48,"lines":[{"account":1,"amount":"735.34","currency":"USD","side":"debit"},{"account":12,"amount":"735.34","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"7d22a280a8ddaedaf9f32f223b27134c70797f259ba9844bdffdd99f10e25df1","seq":318,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-08","description":"Design work 10","id":49},"prev":"2957de627eb301a870f9c7247e2e4a76499615f25395b6a9f39912b66f3727c4","seq":319,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":49,"party_id":3},"prev":"1b7bbe892805e43738683ffbb05f37970dc4b0a9c7696b2fe0c12924b8ea8d4c","seq":320,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"894.91","currency":"USD","id":49,"side":"debit"},"prev":"7385e04ea8e892fb45c846291e5b095766f7545e6ee17de349d565ff0fec9178","seq":321,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"894.91","currency":"USD","id":49,"side":"credit"},"prev":"7936ae3480dee70dea7c6f4266074a57cd05f0ff174ab39619a50db805762b1b","seq":322,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":49,"blob_sha":"98ad30c16a2f0b8f462702570194a2ca2ccd95703535299753e773d8ba737d09","category":"receipts","description":null,"entry_id":49,"file_name":"r49.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"2efea58df2f347f7f38c6a4beb5177aa72c5d41d6f32183ca35b8af99736fb1f","seq":323,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-08","description":"Design work 10","id":49,"lines":[{"account":17,"amount":"894.91","currency":"USD","side":"debit"},{"account":1,"amount":"894.91","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8706e7f9660800873bd26f0ffd4ff13d49e891fadf8da3961a4ca6f1a1e1de88","seq":324,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-13","description":"Supplies 10","id":50},"prev":"a83eb84e3fa5a51a8238dc9ad9e60c35dae9b74d8a291f997a3a18f4ae1a1cce","seq":325,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":50,"party_id":4},"prev":"1a26a3c924bcd3620a6df36922496ef395ec9cb245ac79fc396c9884a7740e86","seq":326,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"237.88","currency":"USD","id":50,"side":"debit"},"prev":"80e6be396f1610f675b29c2f92363a0e7d70807cabd4206f88571e3965fb82bf","seq":327,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"237.88","currency":"USD","id":50,"side":"credit"},"prev":"fd43c46e495a652eb8a6a26f02343b14856d784b40ac52a673efd9fc99085c1a","seq":328,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":50,"blob_sha":"218116eca79b67dbe85d3d813d3b0b8373a2eef6dde93ccb201458a74bfdafb1","category":"receipts","description":null,"entry_id":50,"file_name":"r50.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"a72dec373ee67c4db566214f887c4c973ef4e03b217e96c5dd1b958f6d3519bf","seq":329,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-13","description":"Supplies 10","id":50,"lines":[{"account":13,"amount":"237.88","currency":"USD","side":"debit"},{"account":7,"amount":"237.88","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"75aca5b510317e463c70c556001f7a89f9ced1a3887a70f544014057115bf939","seq":330,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-18","description":"Software paid by member 10","id":51},"prev":"eed78f69673c4d35ff80e0c782097c6865fc19823aef6898416d84405c050b16","seq":331,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":51,"party_id":1},"prev":"7e053ae25b809a194c8160018d66e31575d647adc60a1ed9b874bbcfd0ef3146","seq":332,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"136.07","currency":"USD","id":51,"side":"debit"},"prev":"b24fe59527a0287a9ed30fd6f8bfa239d847d7336838da4cc2accb34999d0650","seq":333,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"136.07","currency":"USD","id":51,"side":"credit"},"prev":"30b459d0cd9e01a392ed4b97bdec83eb59538928a4d757264e9423facefea8c3","seq":334,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":51,"blob_sha":"53f7de0945923a52b4fbcd56659f6dd4d3a02830ba0adcfbe81df0f1b7bd8ff2","category":"receipts","description":null,"entry_id":51,"file_name":"r51.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"f30844a58b3d32afe3c18c4ad003cff39cf607e715c0e285e89436a4b864cb15","seq":335,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-18","description":"Software paid by member 10","id":51,"lines":[{"account":14,"amount":"136.07","currency":"USD","side":"debit"},{"account":8,"amount":"136.07","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9a14e758062a1c678e3b0d56a3fd14338fe10e303665365d8c060ae5d6b4a97b","seq":336,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-10-23","description":"Bank fee 10","id":52},"prev":"189502c8c423a7264d9071c8b32ccfb1b1bda2c1176969d00a36dff1bb828dd0","seq":337,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":52,"reason":"bank fee"},"prev":"0e2d33f282a68cbbd94dcfbb854d82a66c99089618dcf6f6d0b3f34bb2ce1b9a","seq":338,"ts":"2026-10-21T21:29:00Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":52,"side":"debit"},"prev":"507b44679c2bf90e6d2a946a5ea757cbc259b37adae922324d79139126e0a5a9","seq":339,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":52,"side":"credit"},"prev":"5a2c0b9f8e4eeb5dc3471c36b8eaca3dd64de3e495f01c03c99a4b9fcca99462","seq":340,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":52,"blob_sha":"37fa15b60bca1e11c46715e3b270a60573ab89be57f4908623a73572ee0325cb","category":"receipts","description":null,"entry_id":52,"file_name":"r52.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"1f672e76d179887d18ed8c28b1a78d57e1392528911d24615fbdda9f6b2c2cd5","seq":341,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-10-23","description":"Bank fee 10","id":52,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"545c33cbafa6fc1486f05c3aafec0948e5052be6037850cad5d5aa94323f635e","seq":342,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-11-03","description":"Consulting 11","id":53},"prev":"f3052b35a608797568c42f3ab2b8238f661718bd6e60275b15a0a99a7198828c","seq":343,"ts":"2026-10-21T21:29:00Z","type":"entry.created"}
{"payload":{"entry_id":53,"party_id":5},"prev":"2e7a295cc168ed4fae9d5b6e338403c574804b8298f2cb14d74638f216cef928","seq":344,"ts":"2026-10-21T21:29:00Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"749.68","currency":"USD","id":53,"side":"debit"},"prev":"ef7b3258ac6edc69a5bc0a6ac8885d475cfb9285d6bd774ba6e706ca3b804645","seq":345,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"749.68","currency":"USD","id":53,"side":"credit"},"prev":"5e38b5b521a8edb5200f0dcb42b7c6eb4d1446ce023b2d205f6dcacb65fe1e28","seq":346,"ts":"2026-10-21T21:29:00Z","type":"entry.line_added"}
{"payload":{"attachment_id":53,"blob_sha":"5ba9b95dcfaad7cd77f590a2dbc8d14ddeb8f9694ec5f393da4b4802fc7462dd","category":"receipts","description":null,"entry_id":53,"file_name":"r53.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"8f9427d783c6683a07b7b29cb951475610ce8f829d63f89f4683b3424967c636","seq":347,"ts":"2026-10-21T21:29:00Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-03","description":"Consulting 11","id":53,"lines":[{"account":1,"amount":"749.68","currency":"USD","side":"debit"},{"account":12,"amount":"749.68","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4b1d07090bc763bae6c8d4778d86d1b636e3da123b1497f773786fef3bd0a039","seq":348,"ts":"2026-10-21T21:29:00Z","type":"entry.posted"}
{"payload":{"date":"2025-11-08","description":"Design work 11","id":54},"prev":"c7eccb78beb92636d00e47362c9760656ee5587ba8318fd5ab73027187aa43a2","seq":349,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":54,"party_id":3},"prev":"2a6c05d2e9362aeedadde55e878bf45407f1daf46cf6f786c8d0b6c293a54072","seq":350,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"838.43","currency":"USD","id":54,"side":"debit"},"prev":"f56fc8fc42dbd05b165a4588689ae1c2b85c3e344aa628a486b0a2f0ea236726","seq":351,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"838.43","currency":"USD","id":54,"side":"credit"},"prev":"7d5e712f56cc3c476fdc5c7f99b08ab4301254acd288ac85d8c58ab5213cfa3f","seq":352,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":54,"blob_sha":"cf63d0935c457f42cf6610cdec14c1072de13b8e0dbd63e83518821e8fd4341b","category":"receipts","description":null,"entry_id":54,"file_name":"r54.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"5cbc46b789656498728ade76bf7291fc277a22d07b7c1932bb4abea0b7608ff0","seq":353,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-08","description":"Design work 11","id":54,"lines":[{"account":17,"amount":"838.43","currency":"USD","side":"debit"},{"account":1,"amount":"838.43","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"2041d4dfcad47aa29439edffeb9a1e72f15baef31ce8ca64289985fbbdbc7737","seq":354,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-11-13","description":"Supplies 11","id":55},"prev":"bf3119d06d07a984345850e0f7ab0b59737fac22b13573149fcf5dc05e1b644a","seq":355,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":55,"party_id":4},"prev":"bc57f2095a4cf1e6a9ac606552f42d065dd7b13e7d0798cacf6b228143ed08e5","seq":356,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"247.24","currency":"USD","id":55,"side":"debit"},"prev":"3a6e73647d7b10b82b2e6ef071eb9ff4e11b4b92ec8aaaca997840518655c43b","seq":357,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"247.24","currency":"USD","id":55,"side":"credit"},"prev":"753b14754cb738bc6a744497d02a57291fcaa9a4d6257ca387f7b79c5e080781","seq":358,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":55,"blob_sha":"dfdd55e9d11c066afa47ae3e93b7ec3aa7b77a41bb21ffd7cfd17a260eb4f915","category":"receipts","description":null,"entry_id":55,"file_name":"r55.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"b7a4ea5387942f62b0121f43ac2c6415b2a38020612d1642f0ae843b580604fd","seq":359,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-13","description":"Supplies 11","id":55,"lines":[{"account":13,"amount":"247.24","currency":"USD","side":"debit"},{"account":7,"amount":"247.24","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"14a2c5a6a2b1834df2790e7a12b649f9b421e52084a4a500daea236f66bca068","seq":360,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-11-18","description":"Software paid by member 11","id":56},"prev":"2a619bea70d624bac05f2429919e6d5e20192f5615a49b634f3910f89dc41ce9","seq":361,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":56,"party_id":1},"prev":"3d05fd3fad0d19264976f97f1c59c6ef5dfb1545bfd54f110a229e649c74ae87","seq":362,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"489.10","currency":"USD","id":56,"side":"debit"},"prev":"2402181c094548ca28bbb23300cfa632ee0494cfae468ee1bceb4f2176c213a5","seq":363,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"489.10","currency":"USD","id":56,"side":"credit"},"prev":"51fb21d99531ac22e80984309cebd23e3a014d1966e2f3632e7706579489567f","seq":364,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":56,"blob_sha":"82c5c2c9cb7af39e0859bcc399f0df627105673fac9a02f3172af63fae493cc1","category":"receipts","description":null,"entry_id":56,"file_name":"r56.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"0a953433470f13fba6e489fa10abd60e7b6483b57b4461133ab4ad093a908843","seq":365,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-18","description":"Software paid by member 11","id":56,"lines":[{"account":14,"amount":"489.10","currency":"USD","side":"debit"},{"account":8,"amount":"489.10","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"40f6bfba12a8454fc2f3b3d90c1d3c40c2be881baebe0366b592c397e4fb46d1","seq":366,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-11-23","description":"Bank fee 11","id":57},"prev":"68b996e35b3e97a95059bef317cb2d828353eef17c71c5fa7fa7b6816f4454e1","seq":367,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":57,"reason":"bank fee"},"prev":"8b6b7a8298429f51a2fb2a292243f31da54d5afa44a1a2106ef0e1824d20668c","seq":368,"ts":"2026-10-21T21:29:01Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":57,"side":"debit"},"prev":"03cbc8ea6f20210066385046c3153caf4f339147b28075dd404769e23674e7a0","seq":369,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":57,"side":"credit"},"prev":"093db4b5c2dda279d565e1a52e412ff58b87cce5901c5df39be7787fad8efec6","seq":370,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":57,"blob_sha":"58132a2be4c24afc7e9fa6824021b731c1e7ab947950b330efaac5c23838a65f","category":"receipts","description":null,"entry_id":57,"file_name":"r57.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"5e69ad8e782af70a8916c65cb7044e1cdc9ab8b38a7999dcefc8142bf8c7ad69","seq":371,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-11-23","description":"Bank fee 11","id":57,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"ccd47b8a432670e86f576d0894c0288e8abf773811901cdecefb590acd90086b","seq":372,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-12-03","description":"Consulting 12","id":58},"prev":"5a869fd53ac45a8a6db2b2fddefa199472ca491de5225cc9a9651840075c82e4","seq":373,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":58,"party_id":5},"prev":"d4f4e8c092e759349398e17701cc3a8f1c6df7a386970db45145c429b12a6e77","seq":374,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":1,"amount":"718.93","currency":"USD","id":58,"side":"debit"},"prev":"7c4bb51ac57db7a1d5bb1c8bbc1c9849ba657b5d90208269e0c80f6224886e86","seq":375,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":12,"amount":"718.93","currency":"USD","id":58,"side":"credit"},"prev":"3bd4f070afd2f5e608553123084c96a9927f78d30ed480c3c93c10a255c98fab","seq":376,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"attachment_id":58,"blob_sha":"80107280d3e5d99e971770a018db9285c9e1e46498eab9dd77535cec612e7708","category":"receipts","description":null,"entry_id":58,"file_name":"r58.pdf","mime_type":"application/pdf","size_bytes":55},"prev":"4ce4bb82870b6e9a1679b10621d158cb35e69ad39b63032809fb1a0cbddce7fb","seq":377,"ts":"2026-10-21T21:29:01Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-03","description":"Consulting 12","id":58,"lines":[{"account":1,"amount":"718.93","currency":"USD","side":"debit"},{"account":12,"amount":"718.93","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9ec10febf57a82f705df03a25e7610527e6d4e84c1654a26cd984527ea969df0","seq":378,"ts":"2026-10-21T21:29:01Z","type":"entry.posted"}
{"payload":{"date":"2025-12-08","description":"Design work 12","id":59},"prev":"1322577f2db48ce75ae7a874ec48681ed33b709c7a68d8652294f22a82943f4a","seq":379,"ts":"2026-10-21T21:29:01Z","type":"entry.created"}
{"payload":{"entry_id":59,"party_id":3},"prev":"a7715accec23f324d8c6ea7fa92f9400d2d6792d5f319912da7b55916ad5d79c","seq":380,"ts":"2026-10-21T21:29:01Z","type":"entry.party_set"}
{"payload":{"account":17,"amount":"83.29","currency":"USD","id":59,"side":"debit"},"prev":"b92370edeb9fbbc30c3dac971d0514ffd6492c88d7105b22e530fa9bda25354c","seq":381,"ts":"2026-10-21T21:29:01Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"83.29","currency":"USD","id":59,"side":"credit"},"prev":"c815de80d2a481c8e5b38881fe8bb947b6c9f439c05e08eb6987653ef8da5933","seq":382,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":59,"blob_sha":"e554361bcf2115a5a55b5ccb80f32fe860710dd679f917f99686d1171e49964d","category":"receipts","description":null,"entry_id":59,"file_name":"r59.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"793ed5800359e366d9500a921eb1eb6de1b03fa1112cd8ce32bc2cb0af267e81","seq":383,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-08","description":"Design work 12","id":59,"lines":[{"account":17,"amount":"83.29","currency":"USD","side":"debit"},{"account":1,"amount":"83.29","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0d759e13534b33038fbd4592af647111c9dc82a81be2106c803e64213a871e92","seq":384,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-12-13","description":"Supplies 12","id":60},"prev":"73580cfcac242f8c5266594daf1263def1f1a5813885071dd26ea85ed474e1da","seq":385,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":60,"party_id":4},"prev":"818998c07fbfb3e620d6a8c007a2933dae383d4b242767542431dca70f2adb2b","seq":386,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
{"payload":{"account":13,"amount":"740.72","currency":"USD","id":60,"side":"debit"},"prev":"ca2b88a667f101307223e3d3a682e344ebbe0670c3e2e7bccba356b504e19724","seq":387,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":7,"amount":"740.72","currency":"USD","id":60,"side":"credit"},"prev":"c9758d36322d2439ad0842dd94c7a69ba2f2cc7f4f2788f6ec13a1f352d02511","seq":388,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":60,"blob_sha":"348514bbc9e4a658e2681f0981d359d8f8494de6b17459fc4d730503b5a19024","category":"receipts","description":null,"entry_id":60,"file_name":"r60.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"d9a8c135c5412fa3a9a9e87ad3714497788153b8df424d82463f91f7a160c3c1","seq":389,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-13","description":"Supplies 12","id":60,"lines":[{"account":13,"amount":"740.72","currency":"USD","side":"debit"},{"account":7,"amount":"740.72","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"975afeb93d9c8ae6b21c0b79082e9b3094b0df96ba4ab2965bb6fba929f6f099","seq":390,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-12-18","description":"Software paid by member 12","id":61},"prev":"e939e60e9d522abf781d651ff470cf4236af80d0d497177ac2907f2107a6f66f","seq":391,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":61,"party_id":1},"prev":"ca11d669a78b359190d893df4d57375c941549fd9ba3b0796deef9dfaa0f5b35","seq":392,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
{"payload":{"account":14,"amount":"79.12","currency":"USD","id":61,"side":"debit"},"prev":"e1ef9eb8784d6a8159a2b06eb1e3b19ac8d9c4e5f65741f1d05ebffa91edb9f5","seq":393,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":8,"amount":"79.12","currency":"USD","id":61,"side":"credit"},"prev":"91d0bf2878f1b44bef06a569984a5a9e79445606da929eb6cf0ddc178562a107","seq":394,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":61,"blob_sha":"3339b8401edf750b6b34d0d90ec5b7a35fe8b6e26dba68d15b9c65f60d11e251","category":"receipts","description":null,"entry_id":61,"file_name":"r61.pdf","mime_type":"application/pdf","size_bytes":68},"prev":"04399b473dc1425a688aaf3159cbd88cd78fca54a7f8db5b99c9e215da838aa7","seq":395,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-18","description":"Software paid by member 12","id":61,"lines":[{"account":14,"amount":"79.12","currency":"USD","side":"debit"},{"account":8,"amount":"79.12","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0b280a4a27211eacf8d5c7e25b795fdf959ec3b8b69fde307fad7fb0ccc925a9","seq":396,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-12-23","description":"Bank fee 12","id":62},"prev":"27f88d75fc1e88cd66d408e5fa89a9d5b818e4baaa71ff6ab154650f393c851b","seq":397,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":62,"reason":"bank fee"},"prev":"f76c2e6adeb67d7760aadedb534445d632030c9bd12292dac5de5b2b2828ae2a","seq":398,"ts":"2026-10-21T21:29:02Z","type":"entry.no_party"}
{"payload":{"account":13,"amount":"12.00","currency":"USD","id":62,"side":"debit"},"prev":"c2f347917a69a154836e7ddf4dec3fe2ab1e15cf2a8f8225c36ebc969ce65328","seq":399,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":62,"side":"credit"},"prev":"2601edb1df9aaf7fcf821ffea0a5aeecb064cc66cb246817e32d9daf265775c9","seq":400,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":62,"blob_sha":"8d95aa1b0d183a31141f7093bc4ef2dcf0b165d42625efe2c50fac9bba78bd7f","category":"receipts","description":null,"entry_id":62,"file_name":"r62.pdf","mime_type":"application/pdf","size_bytes":53},"prev":"c21f7b405b5f50ef6c104c456e7ab42439f4ab617bae4323cc5e7cfb733ad667","seq":401,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-12-23","description":"Bank fee 12","id":62,"lines":[{"account":13,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"97b789402ca050d2816977e1866958a0206c2682e97cd84c4dab37cffa0fad02","seq":402,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"date":"2025-03-01","description":"Laptop","id":63},"prev":"dd3373bb32e5656fa6c7b14e46fbcd0af332c111c79fdae8c6a33585d2203f2f","seq":403,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"entry_id":63,"party_id":4},"prev":"ed9d7324d7524ed6c45eb23fc8efbd43984855d7710d75a9aedb7cbf8b325fc4","seq":404,"ts":"2026-10-21T21:29:02Z","type":"entry.party_set"}
{"payload":{"account":3,"amount":"2400.00","currency":"USD","id":63,"side":"debit"},"prev":"65c34e5cbb2233e08a10fc5fa93334dcfd1cd2d1c6431f004d4b7a7b982a15c6","seq":405,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"2400.00","currency":"USD","id":63,"side":"credit"},"prev":"478214a7e48572598610da9801e4e4bd07d99b8fa0ac230474828c15b195ec47","seq":406,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"attachment_id":63,"blob_sha":"8676902d877e2c45200f351eb1671f62e598df60d83bb2f3da74f60d85a76d5f","category":"receipts","description":null,"entry_id":63,"file_name":"r63.pdf","mime_type":"application/pdf","size_bytes":48},"prev":"77bda531c990996596b82a1155feefa838a7b39b9b218ec5fc289508c7cc0587","seq":407,"ts":"2026-10-21T21:29:02Z","type":"receipt.attached"}
{"payload":{"date":"2025-03-01","description":"Laptop","id":63,"lines":[{"account":3,"amount":"2400.00","currency":"USD","side":"debit"},{"account":1,"amount":"2400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"276fde3703b75331f11e24df3022332cc285f72e27020cc8e1e13528f80c988e","seq":408,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"accum_depr_account":4,"cost":{"amount":"2400.00","currency":"USD"},"cost_account":3,"description":"Laptop","expense_account":15,"id":1,"method":"straight_line","purchase_date":"2025-03-01","salvage_value":{"amount":"0","currency":"USD"},"useful_life_months":36},"prev":"c69cdd6ff196ad5da8f6d7b3242681868f9a1ba4fd42dffb14922ba9c8ce6d2a","seq":409,"ts":"2026-10-21T21:29:02Z","type":"asset.registered"}
{"payload":{"asset_id":1,"previous_tag":null,"tag":"FL-0001"},"prev":"3cfb8d20209d19fc8b1718379037da8f3eca780c182a006fe0a000ac4ceb87b0","seq":410,"ts":"2026-10-21T21:29:02Z","type":"asset.tagged"}
{"payload":{"asset_id":1,"entry_id":63},"prev":"569bf2e5019bf47db07a22fd34725d254544f5d2faa1286884f46caaa98bd54a","seq":411,"ts":"2026-10-21T21:29:02Z","type":"asset.acquisition_linked"}
{"payload":{"date":"2025-03-31","description":"Depreciation — Laptop — 2025-03","id":64},"prev":"22809023c78b048f64008e4e40402ea222a6d5a257112db2f224ca26d3ee41e7","seq":412,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":64,"side":"debit"},"prev":"863171f7c7793cebbb32a192056eaff8b28f4dfc79c99a8367f92d6eec49378f","seq":413,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":64,"side":"credit"},"prev":"2bee4b7f54dc66c67eb2655fd936adfb57f8d05555cf77cb6b48989d4255821f","seq":414,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"date":"2025-03-31","description":"Depreciation — Laptop — 2025-03","id":64,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"48ded01bfc4b8265a49204c4fb8372f99481ba5f3d656dbe498f3d332e47c448","seq":415,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":64,"period":"2025-03"},"prev":"c30add1e4ead7b0dcb3948bb83b5244a30b9c733a92ec4732999532b5c9eae59","seq":416,"ts":"2026-10-21T21:29:02Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-04-30","description":"Depreciation — Laptop — 2025-04","id":65},"prev":"40653fd1e994a584030625fe577665c5db4b2c8ea70772b2ad6ac7dc154ea25d","seq":417,"ts":"2026-10-21T21:29:02Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":65,"side":"debit"},"prev":"67bc4b24d3cc20a221c1bd14d02c4cf5b7dc19333d187541e94dd29e72a5d6e5","seq":418,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":65,"side":"credit"},"prev":"3e94b0205bc23dd2f530689d6aca732d68235e20f46ae8416fb03bb729306811","seq":419,"ts":"2026-10-21T21:29:02Z","type":"entry.line_added"}
{"payload":{"date":"2025-04-30","description":"Depreciation — Laptop — 2025-04","id":65,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b4b04853c7e85f6f7bab1b542e904ac054d6778e9159a47d0b2b921990f3e8ab","seq":420,"ts":"2026-10-21T21:29:02Z","type":"entry.posted"}
{"payload":{"amount":"66.66","asset_id":1,"entry_id":65,"period":"2025-04"},"prev":"981a7a51a034c228969d13ab2eab20613a2bd57e01b28b75f9ed17bbb0c4076c","seq":421,"ts":"2026-10-21T21:29:02Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-05-31","description":"Depreciation — Laptop — 2025-05","id":66},"prev":"41caf1b729a253a90ca48b694c895a8ad078dfd27ec5e75bb362aa1c79ec402f","seq":422,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":66,"side":"debit"},"prev":"69d804f402662361cf9ba7135420e11f2ebaa014824df99ceaa1afbdc384d620","seq":423,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":66,"side":"credit"},"prev":"d3019602e2c0e2cf92840eb6c4b9ba77132b79b071cc88e5e928a5525d5a7f0a","seq":424,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-05-31","description":"Depreciation — Laptop — 2025-05","id":66,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f1c7cabf8106ace4486696906ca27948644fea2149c5607191ef8b6426ecaaf9","seq":425,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":66,"period":"2025-05"},"prev":"291bfae8f7cdf9132827647ab388af8708a400945bd33bfd98518f43717f6ae2","seq":426,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-06-30","description":"Depreciation — Laptop — 2025-06","id":67},"prev":"1e5895685f8c6248412f13ec2af34949a24b4493efb3e322db370ca7c76f0207","seq":427,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":67,"side":"debit"},"prev":"94c19beb788960587d6c44f9c7c7294bde4813e015d9444803e3409671371456","seq":428,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":67,"side":"credit"},"prev":"1e5159ac81c1951152d1d871d112658e7c239aa6ec50affc82291136151057d0","seq":429,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-06-30","description":"Depreciation — Laptop — 2025-06","id":67,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"04efed2f4e6761c9588b9316d64122f3672e1fed213b1a2f3f1f2868c4b60444","seq":430,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":67,"period":"2025-06"},"prev":"5584eb6e1aeeb38029e118670e8379df574a4a499acd38e7158c8b5b45594092","seq":431,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-07-31","description":"Depreciation — Laptop — 2025-07","id":68},"prev":"d738713444f0c465415e5248388ce05b4bd114e6f0fa6570cdc66cc70e7c2ea2","seq":432,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":68,"side":"debit"},"prev":"8760646dd6d3d731d83b7944c8aa1865ff0aa0c57f5b08daf649b6acb9f68232","seq":433,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":68,"side":"credit"},"prev":"f75caf0d430852a76f6225a72baab3cc1639d87d6e64ae13f728685a7b19ccca","seq":434,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-07-31","description":"Depreciation — Laptop — 2025-07","id":68,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e2d21284301eb08363234118bda170f78a5bc6712355398132b57f61ebc90ccf","seq":435,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.66","asset_id":1,"entry_id":68,"period":"2025-07"},"prev":"dda1ba8941f6686fc22bafec2e0daa0f9d62a5b5be9ed100670713526d4548c4","seq":436,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-08-31","description":"Depreciation — Laptop — 2025-08","id":69},"prev":"4ab1cdd0071cb84e114c5bc461bbafe0969bb845bbb05f070db067dffeb9e371","seq":437,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":69,"side":"debit"},"prev":"95bad6ff059f689293534f0f19cc7b57284f0125fa65e6c1e76e8341f5bd66f4","seq":438,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":69,"side":"credit"},"prev":"257ffe0e663df221be536ab21c9429b5ad663f8e3140b256575f4f1ac14f3795","seq":439,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-08-31","description":"Depreciation — Laptop — 2025-08","id":69,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"52e67b69798e6e42e4d721c942fc9c661cad3c9dd21e88cebd0968312708e796","seq":440,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":69,"period":"2025-08"},"prev":"3d7052193c152126591fdc07e445276ea04114b10f1db38c18029d34c6a1a457","seq":441,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-09-30","description":"Depreciation — Laptop — 2025-09","id":70},"prev":"df0d66ff32bdb0c201e38864413b65321da625ddedc49f131345e8a17d7dc6af","seq":442,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":70,"side":"debit"},"prev":"353f3e42cc1f1e5c191d9f344cb26c06456fc8c601f4ae86e0420122afb805ca","seq":443,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":70,"side":"credit"},"prev":"545d4a21746f97d8460679b2d01fecf161df220d4fbcefcce23786be972b2f01","seq":444,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-09-30","description":"Depreciation — Laptop — 2025-09","id":70,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"5515388f429b3bb9234967689c10701f688ffbcacb9eb42e507a56b910a5eca4","seq":445,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":70,"period":"2025-09"},"prev":"134600a07a7f9be26ef29d9b3609d6103c22dd5a5acefadd121c5769bec1ab82","seq":446,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-10-31","description":"Depreciation — Laptop — 2025-10","id":71},"prev":"9a088a890a8286cadc7d0df46ef42e343eb5ded8c739fee6ae77bb4eb11cefce","seq":447,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.66","currency":"USD","id":71,"side":"debit"},"prev":"cdb6b65327e40050fa39cd7d8c32c9e499129fc06a512b945bb94124b34fa548","seq":448,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.66","currency":"USD","id":71,"side":"credit"},"prev":"1b763138e97fbb88aac5d9ecf34d2c6426530408b4b8986b797442ce82b88f61","seq":449,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-10-31","description":"Depreciation — Laptop — 2025-10","id":71,"lines":[{"account":15,"amount":"66.66","currency":"USD","side":"debit"},{"account":4,"amount":"66.66","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"8f55d708971978b7818f921861f031617adaa6d794f4a369c0137c6be77e6c1d","seq":450,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.66","asset_id":1,"entry_id":71,"period":"2025-10"},"prev":"27bfba00c4431cefff8e551102f87feaddbc00e4ac4ec8081171b0f357a8cb4c","seq":451,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-11-30","description":"Depreciation — Laptop — 2025-11","id":72},"prev":"5f05265a9f7207106edef9d8e570d4e63b9f748e6fd382b66b7cb4fd5f026611","seq":452,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":72,"side":"debit"},"prev":"c5ba941fc07e87c4698b2de0ecf3cc982dc3446084fe99332502a182e006692a","seq":453,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":72,"side":"credit"},"prev":"a77601eb122a34e9d816a28d91efd9c6546f1ab56bad24fca07be761fa51b6b5","seq":454,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-11-30","description":"Depreciation — Laptop — 2025-11","id":72,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"d630324206e2658b2855228bded486e09580cda4901906a9cb37ac1c3827694d","seq":455,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":72,"period":"2025-11"},"prev":"1b5195c8a8d25c63523207169bf4e1bcc31b9b1e381ccac28ac910cd45ec0bd4","seq":456,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-12-31","description":"Depreciation — Laptop — 2025-12","id":73},"prev":"5b1227aee10a524c14b49637a7423676999a21a95f161164dc6cc4890d172624","seq":457,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"account":15,"amount":"66.67","currency":"USD","id":73,"side":"debit"},"prev":"e21de75b07cc38719cd1eb40177a876faf4b84dfe9791606dc50315750a7cbdf","seq":458,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"66.67","currency":"USD","id":73,"side":"credit"},"prev":"b8a2f7d8cd30e888fa68f883989190bf6635a4f7f4c90840b6a8a7bb0ef3bda8","seq":459,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"date":"2025-12-31","description":"Depreciation — Laptop — 2025-12","id":73,"lines":[{"account":15,"amount":"66.67","currency":"USD","side":"debit"},{"account":4,"amount":"66.67","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"474e2016cd9deff5c0fcd864ba32f4abce897c35050378958f2eed8342e15e06","seq":460,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"amount":"66.67","asset_id":1,"entry_id":73,"period":"2025-12"},"prev":"4496340b38dcfe3c449c5a7d5ce15e98f8052f6130405dd209a54fc3e155d04d","seq":461,"ts":"2026-10-21T21:29:03Z","type":"asset.depreciation_posted"}
{"payload":{"date":"2025-02-01","description":"Insurance year","id":74},"prev":"8a3ce2e06e292d10ea2648daf59f15b845ff4954f5b071e7d0d034ee31919b9f","seq":462,"ts":"2026-10-21T21:29:03Z","type":"entry.created"}
{"payload":{"entry_id":74,"party_id":4},"prev":"8b3a320f0a1931010797ebae04ea284d7afc902376e5528a1e42cab2bf6dfbf5","seq":463,"ts":"2026-10-21T21:29:03Z","type":"entry.party_set"}
{"payload":{"account":2,"amount":"1200.00","currency":"USD","id":74,"side":"debit"},"prev":"bfb05eb96932c13c0bf9f6088f96e5456753d8abe274282d0c5b71bf72c3d45d","seq":464,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"1200.00","currency":"USD","id":74,"side":"credit"},"prev":"0e46904b35239d223507dd3249a48e72abf8c2b72787e5a71577285dc7a3fd13","seq":465,"ts":"2026-10-21T21:29:03Z","type":"entry.line_added"}
{"payload":{"attachment_id":64,"blob_sha":"407b7b6f86ec969d83c5370e7a2f639522bbdb7bceafb7cb3dad36a384c2f4ba","category":"receipts","description":null,"entry_id":74,"file_name":"r64.pdf","mime_type":"application/pdf","size_bytes":56},"prev":"fa55ae4bf5fbf7d8e65d0bfa4466c6ba6675e0e33cf08c52e0536c4e734e359c","seq":466,"ts":"2026-10-21T21:29:03Z","type":"receipt.attached"}
{"payload":{"date":"2025-02-01","description":"Insurance year","id":74,"lines":[{"account":2,"amount":"1200.00","currency":"USD","side":"debit"},{"account":1,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"39cc2c369dec884abebbaf2afa842e530c0a7d2f9c928cfa658e3ba2dc6877fe","seq":467,"ts":"2026-10-21T21:29:03Z","type":"entry.posted"}
{"payload":{"balance_account":2,"description":"Insurance","id":1,"kind":"prepaid_expense","months":12,"pl_account":16,"start":"2025-02-01","total":{"amount":"1200.00","currency":"USD"}},"prev":"7576a892214455b4f54a9ba4d3a54b31d6e72f95b3c2d57b197bdd86d54de240","seq":468,"ts":"2026-10-21T21:29:03Z","type":"schedule.registered"}
{"payload":{"end":"2025-03-31","id":1,"name":"2025-Q1","start":"2025-01-01"},"prev":"ff0fc0c6c7bc3537bd06b5a1e73d325af2e183e54a75a441e20a740db0bb5cbd","seq":469,"ts":"2026-10-21T21:29:03Z","type":"period.created"}
{"payload":{"close_to":{"members":[{"account":9,"distributions":null,"share":"0.5"},{"account":10,"distributions":null,"share":"0.5"}],"rounding":"cumulative"},"closing_entry":75,"entry":{"date":"2025-03-31","description":"Close period 2025-Q1","id":75,"lines":[{"account":12,"amount":"598.41","currency":"USD","side":"debit"},{"account":13,"amount":"961.27","currency":"USD","side":"credit"},{"account":14,"amount":"1384.64","currency":"USD","side":"credit"},{"account":15,"amount":"66.67","currency":"USD","side":"credit"},{"account":17,"amount":"1568.21","currency":"USD","side":"credit"},{"account":9,"amount":"1691.19","currency":"USD","side":"debit"},{"account":10,"amount":"1691.19","currency":"USD","side":"debit"}]},"id":1},"prev":"7c2011737f719566576cb5834961a755e285f77373c976213bf976e3f1ca4622","seq":470,"ts":"2026-10-21T21:29:03Z","type":"period.closed"}
@@ -0,0 +1 @@
bebe2eda9edb31285dbc4d6ae7a3761d88e260af13a929ba8f1b97baf3d83178
@@ -0,0 +1 @@
WBV14\�8�>F+��(�򦣲��PC7A��7��Y��maM��l��M6/K_�rt�p���K����Uz<��6f�l��!g�
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
Loaded 100 of 732 files, more files were not shown because too many files have changed in this diff. Show more