Architecture
wealthbraid has one source of truth: an append-only log of typed records plus the documents they cite. Everything else is derived from that log and can be rebuilt at any time. That includes balances, reports, the review queue, explanations, and the web UI.
Layers¶
wealthbraid.cli agent-facing CLI: --json everywhere, stable exit codes
wealthbraid.web human-facing review UI (FastAPI + Jinja2 + vendored htmx)
│
wealthbraid.services deterministic tools: import, categorize, reconcile,
│ explain, reports, scenarios, review queue
wealthbraid.book Book facade (propose / decide), schemas, projection, verify
│
wealthbraid.store record envelopes, canonical JSON, append-only log, evidence blobs
wealthbraid.engine pure double-entry engine: Decimal amounts, accounts,
balancing, inventories, prices (no I/O, stdlib only)
The engine is pure and has no knowledge of files or JSON. The store only
appends. BookState is a pure fold over records. Only Book.propose and
Book.decide write.
On disk¶
wealthbraid.toml settings: currency, user, policy, rules, import profiles
records/2026/09.jsonl append-only log segments, one canonical JSON record per line
records/HEAD anchor: sequence number and id of the newest record
evidence/sha256/ab/abcdef… original documents, read-only, named by SHA-256
.wealthbraid/ lock file only (git-ignored; a lock left by a dead process is recovered)
A record:
{
"id": "ent_3f2a…",
"v": 1,
"seq": 57,
"prev": "dec_91c0…",
"kind": "entry",
"recorded_at": "2026-09-16T14:06:03Z",
"actor": "agent:claude",
"operation": "opr_9c82…",
"data": {
"date": "2026-08-04",
"narration": "ALBERT HEIJN 1043",
"postings": [
{
"account": "Assets:Bank:Checking",
"amount": "-86.40",
"commodity": "EUR"
},
{
"account": "Expenses:Groceries",
"amount": "86.40",
"commodity": "EUR"
}
],
"lines": ["lin_9bc6…"],
"evidence": ["evd_ee43…"]
}
}
idis a kind prefix plus the first 128 bits of the SHA-256 of the canonical JSON of every other field.previs the previous record's id. Editing, deleting, or reordering any record changes the ids that follow it.- Amounts are decimal strings. Floats are rejected at the schema boundary.
- Segments are named by write month, but a write never goes into a segment older than the newest one. File order is always chain order.
- A write takes an exclusive lock, validates, and appends all new records in a
single
write+fsync. It then rewritesrecords/HEADatomically, fsyncing the directory. The hash chain catches edits, reordering, and interior deletion; the anchor catches records removed from the end of the log. - When folding the log, every record's id, sequence number, and
prevlink are checked first. A record that fails is excluded from all state, so reports never include tampered content. Reports then showbasis.integrity_issues, and writes are refused until the book verifies.
Record kinds¶
| Kind | Meaning | Sensitive |
|---|---|---|
evidence |
A stored source document | no |
line |
One statement row from evidence (not an entry) | yes |
note |
An explanation attached to records | no |
account.open / account.close |
Chart of accounts, with dates | yes |
entry |
A balanced transaction, optionally matching lines | yes |
correction |
Supersedes or voids a current entry, line, or account record | yes |
price |
An exchange rate | yes |
reconciliation |
A statement balance checked against the ledger | yes |
operation / decision / applied |
The workflow itself | — |
A statement line changes no balance, but it is sensitive: its fingerprint decides whether a bank row can ever be imported, so a fabricated line could hide a real one. Only evidence and notes are non-sensitive.
The operation workflow¶
propose ──► operation (pending) ──► decision: approve ──► produced records ──► applied
└► decision: reject
- Propose. A proposal lists changes with an optional rationale per change.
It also records the tool, inputs (including the settings used), evidence,
reasoning summary, confidence, and the head it was made against. It is
validated by a dry-run against the current book. Invalid proposals are
refused and nothing is written. A reference field (
evidence,lines,subjects,target) may refer to the id produced by an earlier change in the same proposal with"$N". Free text is never rewritten. - Decide. Only
human:actors can decide.system:policymay approve only operations whose changes are all non-sensitive; the projection enforces that. Whether policy approves at all is thepolicy.auto_apply_non_sensitivesetting, which is applied when proposing. Settings are not part of the log, so the projection cannot tell whether the setting was on. Approval re-validates against the current book. If the book moved on, for example because another proposal already matched the same statement line, nothing is written. - Apply. The decision, the produced records, and the
appliedmarker are appended together. Each produced record names its operation, and the projection checks that it matches the proposed change exactly.
The projection enforces provenance as well as accounting. A record written
outside this workflow is excluded from the ledger and reported by
wealthbraid verify. That covers an entry without an approved operation and a
decision forged by an agent.
Corrections¶
Entries, statement lines, and account open/close records are identified by their
current version id. A correction targets that id and becomes the new current
version, or voids it. The original stays in the log, and trace shows the whole
chain. A correction that targets a superseded version is refused as stale, so
two reviewers cannot silently overwrite each other.
- Voiding an entry releases the statement lines it matched.
- A statement line can be corrected only while no entry matches it. Voiding it releases its fingerprint, so the genuine row can be imported.
- Correcting an
account.openkeeps the account name and may not strand existing entries. Voiding anaccount.closereopens the account.
Statements, matching, reconciliation¶
Importing stores the file as evidence and proposes one line per row, for human
approval. Each line has a fingerprint built from the account, date, amount,
commodity, normalised description, and occurrence number among identical rows in
the file. Bank references are stored but left out of the fingerprint: banks
reuse them across statements, and the same transaction exported with and without
a reference must still match. Re-imports and overlapping statements skip rows
already recorded or already proposed.
Amounts are parsed strictly. Thousands separators must form groups of three, so a profile with the wrong decimal separator is an error, never a 100× amount.
Categorization proposes one entry per line. An entry that cites statement lines must post to each statement account exactly the total of the lines it cites there, and a line can be matched only once.
A reconciliation records the statement balance, the ledger balance, the
difference, and the unmatched lines on that date. reconciliations re-checks
every approved reconciliation against today's book and reports one status:
balanced: the ledger now agrees with the statement.changed: it agreed when approved, but a later change moved that balance.discrepancy: it never agreed and still doesn't.new_lines: statement lines inside the period arrived later and are unmatched.superseded: a newer reconciliation covers the same account, date, and commodity.
balanced and superseded need no attention.
Derived views and reproducibility¶
Reports, explanations, and scenario runs include a basis: the head record id,
the record count, and the parameters. --at RECORD projects the log only up to
that record, so any past view can be reproduced exactly.
explain change breaks an account's balance change into contributions by
counter account. Within each entry, the explained postings are balanced by the
other postings, so the contributions add up exactly. The result reports this as
reconciles rather than assuming it. Scenarios report every figure in cents,
with the reported growth as the balancing figure. The identity
end = start + contributions − withdrawals + growth therefore holds exactly for
the printed numbers. growth_exact and reconciles show that the rounding cost
at most two cents.
Net worth rounds converted amounts to cents. It lists each recorded price it used, with that price's date and age, and warns when a pair's direct and inverse prices disagree by more than 1%. Holdings with no price path are reported per side and never netted.
Security model¶
wealthbraid runs for one person on a machine they trust.
- The approval rule protects against mistakes and overreach by agents that
identify themselves honestly. An identity is never inferred: every write
must name its actor, and a terminal being attached proves nothing. A local
process with shell access can still claim
--actor human:…or edit files. wealthbraid doesn't prevent that; it makes it detectable. Every decision names its actor, edits to past records break the hash chain, and removing records from the end breaks the head anchor. Someone who rewrites the whole log and the anchor together is caught only by an external copy, so keep the book in git and review the diffs. The log only grows, so those diffs are easy to read. - The web UI has no authentication, so it only runs on loopback.
wealthbraid servebinds127.0.0.1only. The app refuses requests from non-loopback client addresses and requires an exactHostof127.0.0.1:<port>orlocalhost:<port>. The Host check guards against DNS rebinding; it is not authentication. Every write needs the per-process token and anOriginorReferernaming this server. Every response, including server errors, carries a strict CSP andCache-Control: no-store. Evidence is only served as a download. For remote access, use an authenticated tunnel such asssh -L. - wealthbraid makes no network calls. htmx is vendored.
static/VENDOR.jsonrecords its source, version, license, and SHA-256, and a test checks the digest.
Known limitations¶
- The projection is rebuilt from the full log on each command. That's fast for personal ledgers, but a cached index would help very large books.
- Lot and cost-basis tracking for investments is not modelled yet. Holdings are valued with prices only.
- The fingerprint's occurrence number assumes overlapping statements list identical same-day rows consistently. The fingerprint also depends on the description, so the same transaction exported with different description text is not deduplicated.
- A torn final record (for example after a power loss mid-write) is reported with the number of intact records before it, but there is no automatic repair command yet.
- The policy setting is not recorded in the log, so the projection cannot tell
whether a
system:policyapproval of evidence or a note matched the setting at the time.